Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/quality-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ jobs:
runs-on: ubuntu-22.04
strategy:
matrix:
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
python-version: ['3.10', '3.11', '3.12', '3.13']
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
Expand All @@ -54,7 +54,7 @@ jobs:
runs-on: ubuntu-22.04
strategy:
matrix:
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
python-version: ['3.10', '3.11', '3.12', '3.13']
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
Expand All @@ -80,7 +80,7 @@ jobs:
runs-on: ubuntu-22.04
strategy:
matrix:
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
python-version: ['3.10', '3.11', '3.12', '3.13']
steps:
- name: Install Locales for Tests
run: |
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- chore: bump min python version 3.9->3.10

## 1.28.1 (2025-06-08)

- fix: UserWarning about obsoleted code [#1148](https://github.com/icloud-photos-downloader/icloud_photos_downloader/issues/1144) [#1142](https://github.com/icloud-photos-downloader/icloud_photos_downloader/issues/1148)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ version="1.28.1"
name = "icloudpd"
description = "icloudpd is a command-line tool to download photos and videos from iCloud."
readme = "README_PYPI.md"
requires-python = ">=3.9,<3.14"
requires-python = ">=3.10,<3.14"
keywords = ["icloud", "photo"]
license = {file="LICENSE.md"}
authors=[
Expand Down
2 changes: 1 addition & 1 deletion scripts/type_check
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
echo "Running mypy..."
python3 -m mypy src tests --strict --python-version 3.9
python3 -m mypy src tests --strict --python-version 3.10
# --strict-equality --warn-return-any --disallow-any-generics --disallow-untyped-defs --disallow-untyped-calls --check-untyped-defs
20 changes: 9 additions & 11 deletions src/foundation/core/optional/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Callable, Optional, TypeVar
from typing import Callable, TypeVar

_Tin = TypeVar("_Tin")
_Tin2 = TypeVar("_Tin2")
Expand All @@ -7,13 +7,13 @@


def bind(
func: Callable[[_Tin], Optional[_Tout]],
) -> Callable[[Optional[_Tin]], Optional[_Tout]]:
func: Callable[[_Tin], _Tout | None],
) -> Callable[[_Tin | None], _Tout | None]:
"""
Monadic bind for Optional.

Example usage:
>>> def div8(divider: int) -> Optional[float]:
>>> def div8(divider: int) -> float | None:
... if divider == 0:
... return None
... return 8 / divider
Expand Down Expand Up @@ -41,7 +41,7 @@ def bind(

"""

def _intern(input: Optional[_Tin]) -> Optional[_Tout]:
def _intern(input: _Tin | None) -> _Tout | None:
if input:
return func(input)
return None
Expand All @@ -51,7 +51,7 @@ def _intern(input: Optional[_Tin]) -> Optional[_Tout]:

def lift2(
func: Callable[[_Tin, _Tin2], _Tout],
) -> Callable[[Optional[_Tin], Optional[_Tin2]], Optional[_Tout]]:
) -> Callable[[_Tin | None, _Tin2 | None], _Tout | None]:
"""
Lifts regular function into Optional. (Lift2 for Optional Applicative Functor)
(a -> b -> c) -> Maybe a -> Maybe b -> Maybe c
Expand All @@ -75,7 +75,7 @@ def lift2(

"""

def _intern(input: Optional[_Tin], input2: Optional[_Tin2]) -> Optional[_Tout]:
def _intern(input: _Tin | None, input2: _Tin2 | None) -> _Tout | None:
if input and input2:
return func(input, input2)
return None
Expand All @@ -85,15 +85,13 @@ def _intern(input: Optional[_Tin], input2: Optional[_Tin2]) -> Optional[_Tout]:

def lift3(
func: Callable[[_Tin, _Tin2, _Tin3], _Tout],
) -> Callable[[Optional[_Tin], Optional[_Tin2], Optional[_Tin3]], Optional[_Tout]]:
) -> Callable[[_Tin | None, _Tin2 | None, _Tin3 | None], _Tout | None]:
"""
Lifts regular function into Optional. see lift2 for Optional Applicative Functor
(a -> b -> c) -> Maybe a -> Maybe b -> Maybe c
"""

def _intern(
input: Optional[_Tin], input2: Optional[_Tin2], input3: Optional[_Tin3]
) -> Optional[_Tout]:
def _intern(input: _Tin | None, input2: _Tin2 | None, input3: _Tin3 | None) -> _Tout | None:
if input and input2 and input3:
return func(input, input2, input3)
return None
Expand Down
16 changes: 7 additions & 9 deletions src/icloudpd/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import logging
import sys
import time
from typing import Callable, Dict, Optional, Tuple
from typing import Callable, Dict, Tuple

import click

Expand All @@ -28,24 +28,22 @@ def authenticator(
lp_filename_generator: Callable[[str], str],
raw_policy: RawTreatmentPolicy,
file_match_policy: FileMatchPolicy,
password_providers: Dict[
str, Tuple[Callable[[str], Optional[str]], Callable[[str, str], None]]
],
password_providers: Dict[str, Tuple[Callable[[str], str | None], Callable[[str, str], None]]],
mfa_provider: MFAProvider,
status_exchange: StatusExchange,
) -> Callable[[str, Optional[str], bool, Optional[str]], PyiCloudService]:
) -> Callable[[str, str | None, bool, str | None], PyiCloudService]:
"""Wraping authentication with domain context"""

def authenticate_(
username: str,
cookie_directory: Optional[str] = None,
cookie_directory: str | None = None,
raise_error_on_2sa: bool = False,
client_id: Optional[str] = None,
client_id: str | None = None,
) -> PyiCloudService:
"""Authenticate with iCloud username and password"""
logger.debug("Authenticating...")
icloud: Optional[PyiCloudService] = None
_valid_password: Optional[str] = None
icloud: PyiCloudService | None = None
_valid_password: str | None = None
for _, _pair in password_providers.items():
_reader, _ = _pair
_password = _reader(username)
Expand Down
76 changes: 35 additions & 41 deletions src/icloudpd/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,9 @@
Dict,
Iterable,
NoReturn,
Optional,
Sequence,
Tuple,
TypeVar,
Union,
cast,
)

Expand Down Expand Up @@ -146,17 +144,17 @@ def mfa_provider_generator(
raise ValueError(f"mfa provider has unsupported value of '{provider}'")


def ask_password_in_console(_user: str) -> Optional[str]:
return typing.cast(Optional[str], click.prompt("iCloud Password", hide_input=True))
def ask_password_in_console(_user: str) -> str | None:
return typing.cast(str | None, click.prompt("iCloud Password", hide_input=True))
# return getpass.getpass(
# f'iCloud Password for {_user}:'
# )


def get_password_from_webui(
logger: Logger, status_exchange: StatusExchange
) -> Callable[[str], Optional[str]]:
def _intern(_user: str) -> Optional[str]:
) -> Callable[[str], str | None]:
def _intern(_user: str) -> str | None:
"""Request two-factor authentication through Webui."""
if not status_exchange.replace_status(Status.NO_INPUT_NEEDED, Status.NEED_PASSWORD):
logger.error("Expected NO_INPUT_NEEDED, but got something else")
Expand Down Expand Up @@ -216,8 +214,8 @@ def _intern(username: str, password: str) -> None:

def password_provider_generator(
_ctx: click.Context, _param: click.Parameter, providers: Sequence[str]
) -> Dict[str, Tuple[Callable[[str], Optional[str]], Callable[[str, str], None]]]:
def _map(provider: str) -> Tuple[Callable[[str], Optional[str]], Callable[[str, str], None]]:
) -> Dict[str, Tuple[Callable[[str], str | None], Callable[[str, str], None]]]:
def _map(provider: str) -> Tuple[Callable[[str], str | None], Callable[[str, str], None]]:
if provider == "webui":
return (ask_password_in_console, dummy_password_writter)
if provider == "console":
Expand Down Expand Up @@ -263,7 +261,7 @@ def file_match_policy_generator(

def skip_created_before_generator(
_ctx: click.Context, _param: click.Parameter, formatted: str
) -> Optional[Union[datetime.datetime, datetime.timedelta]]:
) -> datetime.datetime | datetime.timedelta | None:
if formatted is None:
return None
result = parse_timestamp_or_timedelta(formatted)
Expand Down Expand Up @@ -606,16 +604,16 @@ def report_version(ctx: click.Context, _param: click.Parameter, value: bool) ->
callback=report_version,
)
def main(
directory: Optional[str],
directory: str | None,
username: str,
password: Optional[str],
password: str | None,
auth_only: bool,
cookie_directory: str,
size: Sequence[AssetVersionSize],
live_photo_size: LivePhotoVersionSize,
recent: Optional[int],
until_found: Optional[int],
album: Optional[str],
recent: int | None,
until_found: int | None,
album: str | None,
list_albums: bool,
library: str,
list_libraries: bool,
Expand All @@ -627,32 +625,30 @@ def main(
only_print_filenames: bool,
folder_structure: str,
set_exif_datetime: bool,
smtp_username: Optional[str],
smtp_password: Optional[str],
smtp_username: str | None,
smtp_password: str | None,
smtp_host: str,
smtp_port: int,
smtp_no_tls: bool,
notification_email: Optional[str],
notification_email_from: Optional[str],
notification_email: str | None,
notification_email_from: str | None,
log_level: str,
no_progress_bar: bool,
notification_script: Optional[str],
notification_script: str | None,
threads_num: int,
delete_after_download: bool,
keep_icloud_recent_days: Optional[int],
keep_icloud_recent_days: int | None,
domain: str,
watch_with_interval: Optional[int],
watch_with_interval: int | None,
dry_run: bool,
filename_cleaner: Callable[[str], str],
lp_filename_generator: Callable[[str], str],
raw_policy: RawTreatmentPolicy,
password_providers: Dict[
str, Tuple[Callable[[str], Optional[str]], Callable[[str, str], None]]
],
password_providers: Dict[str, Tuple[Callable[[str], str | None], Callable[[str, str], None]]],
file_match_policy: FileMatchPolicy,
mfa_provider: MFAProvider,
use_os_locale: bool,
skip_created_before: Optional[Union[datetime.datetime, datetime.timedelta]],
skip_created_before: datetime.datetime | datetime.timedelta | None,
) -> NoReturn:
"""Download all iCloud photos to a local directory"""

Expand Down Expand Up @@ -865,7 +861,7 @@ def download_builder(
dry_run: bool,
file_match_policy: FileMatchPolicy,
xmp_sidecar: bool,
skip_created_before: Optional[Union[datetime.datetime, datetime.timedelta]],
skip_created_before: datetime.datetime | datetime.timedelta | None,
) -> Callable[[PyiCloudService], Callable[[Counter, PhotoAsset], bool]]:
"""factory for downloader"""

Expand Down Expand Up @@ -1206,43 +1202,41 @@ def composed(ex: Exception, retries: int) -> None:

def core(
downloader: Callable[[PyiCloudService], Callable[[Counter, PhotoAsset], bool]],
directory: Optional[str],
directory: str | None,
username: str,
auth_only: bool,
cookie_directory: str,
primary_sizes: Sequence[AssetVersionSize],
recent: Optional[int],
until_found: Optional[int],
album: Optional[str],
recent: int | None,
until_found: int | None,
album: str | None,
list_albums: bool,
library: str,
list_libraries: bool,
skip_videos: bool,
auto_delete: bool,
only_print_filenames: bool,
folder_structure: str,
smtp_username: Optional[str],
smtp_password: Optional[str],
smtp_username: str | None,
smtp_password: str | None,
smtp_host: str,
smtp_port: int,
smtp_no_tls: bool,
notification_email: Optional[str],
notification_email_from: Optional[str],
notification_email: str | None,
notification_email_from: str | None,
no_progress_bar: bool,
notification_script: Optional[str],
notification_script: str | None,
delete_after_download: bool,
keep_icloud_recent_days: Optional[int],
keep_icloud_recent_days: int | None,
domain: str,
logger: logging.Logger,
watch_interval: Optional[int],
watch_interval: int | None,
dry_run: bool,
filename_cleaner: Callable[[str], str],
lp_filename_generator: Callable[[str], str],
raw_policy: RawTreatmentPolicy,
file_match_policy: FileMatchPolicy,
password_providers: Dict[
str, Tuple[Callable[[str], Optional[str]], Callable[[str, str], None]]
],
password_providers: Dict[str, Tuple[Callable[[str], str | None], Callable[[str, str], None]]],
mfa_provider: MFAProvider,
status_exchange: StatusExchange,
) -> int:
Expand Down Expand Up @@ -1344,7 +1338,7 @@ def core(

photos.exception_handler = error_handler

photos_count: Optional[int] = len(photos)
photos_count: int | None = len(photos)

photos_enumerator: Iterable[PhotoAsset] = photos

Expand Down
Loading
Loading