From a7d4a8f2168f7b0e158976c85064e5d1c6820419 Mon Sep 17 00:00:00 2001 From: Andrey Nikiforov Date: Wed, 27 Sep 2023 10:09:22 -0700 Subject: [PATCH] add type checks (#694) * pin dependencies * add doctest support * add mypy * adjust lint and format scripts * add types * bump min python 3.7 -> 3.8 * update type_check flags --- .github/workflows/quality-checks.yml | 41 +++++- pyproject.toml | 78 +++++----- scripts/format | 3 +- scripts/lint | 2 +- scripts/run_all_checks | 1 + scripts/type_check | 5 + src/icloudpd/authentication.py | 7 +- src/icloudpd/autodelete.py | 16 ++- src/icloudpd/base.py | 207 ++++++++++++++------------- src/icloudpd/constants.py | 6 +- src/icloudpd/counter.py | 2 +- src/icloudpd/download.py | 29 ++-- src/icloudpd/email_notifications.py | 25 ++-- src/icloudpd/exif_datetime.py | 6 +- src/icloudpd/string_helpers.py | 2 +- src/pyicloud_ipd/exceptions.py | 4 - tests/helpers/__init__.py | 4 +- tests/test_cli.py | 4 +- 18 files changed, 259 insertions(+), 183 deletions(-) create mode 100755 scripts/type_check diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml index a57a10357..50952cb82 100644 --- a/.github/workflows/quality-checks.yml +++ b/.github/workflows/quality-checks.yml @@ -13,11 +13,11 @@ on: jobs: - check_quality: + lint: runs-on: ubuntu-20.04 strategy: matrix: - python-version: [3.7, 3.8, 3.9, '3.10', 3.11] + python-version: [3.8, 3.9, '3.10', 3.11] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} @@ -31,7 +31,42 @@ jobs: - name: Lint run: | scripts/lint - + + type_check: + runs-on: ubuntu-20.04 + strategy: + matrix: + python-version: [3.8, 3.9, '3.10', 3.11] + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + - name: Install Test dependencies + run: | + pip install -e .[test] + - name: Type Check + run: | + scripts/type_check + + test: + runs-on: ubuntu-20.04 + strategy: + matrix: + python-version: [3.8, 3.9, '3.10', 3.11] + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + - name: Install Test dependencies + run: | + pip install -e .[test] + - name: Test run: | scripts/test diff --git a/pyproject.toml b/pyproject.toml index b77dcd813..e299697a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [build-system] requires = [ - "setuptools>=68.0.0,<69", - "wheel>=0.40.0,<0.41", + "setuptools==68.0.0", + "wheel==0.41.1", ] build-backend = "setuptools.build_meta" @@ -10,7 +10,7 @@ version="1.16.1" name = "icloudpd" description = "icloudpd is a command-line tool to download photos and videos from iCloud." readme = "README_PYPI.md" -requires-python = ">=3.7,<3.12" +requires-python = ">=3.8,<3.12" keywords = ["icloud", "photo"] license = {file="LICENSE.md"} authors=[ @@ -23,41 +23,49 @@ classifiers = [ "License :: OSI Approved :: MIT License", ] dependencies = [ - "requests>=2.28.2,<3", - "schema>=0.7.5,<0.8", - "click>=8.1.3,<9", - "python-dateutil>=2.8.2,<3", - "tqdm>=4.64.1,<5", - "piexif>=1.1.3,<2", - "urllib3>=1.26.14,<2", + "requests==2.31.0", + "schema==0.7.5", + "click==8.1.6", + "python-dateutil==2.8.2", + "tqdm==4.66.0", + "piexif==1.1.3", + "urllib3==1.26.16", # from pyicloud_ipd - "six>=1.16.0,<2", - "tzlocal>=4.2,<5", - "pytz>=2022.7.1,<2023", - "certifi>=2022.12.7,<2023", - "future>=0.18.3,<0.19", - "keyring>=23.13.1,<24", - "keyrings-alt>=4.2.0,<5" + "six==1.16.0", + "tzlocal==4.3.1", + "pytz==2022.7.1", + "certifi==2022.12.7", + "future==0.18.3", + "keyring==23.13.1", + "keyrings-alt==4.2.0" ] [project.optional-dependencies] dev = [ - "twine>=4.0.0,<5", - "pyinstaller>=5.7.0,<6", - "wheel>=0.40.0,<0.41", - "auditwheel>=5.4.0,<5.5" + "twine==4.0.2", + "pyinstaller==5.13.0", + "wheel==0.41.1", + "auditwheel==5.4.0" ] test = [ - "pytest>=7.2.1,<8", - "mock>=5.0.1,<6", - "freezegun>=1.2.2,<2", - "vcrpy>=4.2.1,<5", - "pytest-cov>=4.0.0,<5", - "pylint>=2.15.10,<3", - "coveralls>=3.3.1,<4", - "autopep8>=2.0.1,<3", - "pytest-timeout>=2.1.0,<3", - "pytest-xdist>=3.1.0,<4" + "pytest==7.4.0", + "mock==5.1.0", + "freezegun==1.2.2", + "vcrpy==4.4.0", + "pytest-cov==4.1.0", + "pylint==2.17.5", + "coveralls==3.3.1", + "autopep8==2.0.2", + "pytest-timeout==2.1.0", + "pytest-xdist==3.3.1", + "mypy==1.5.0", + "types-pytz==2022.7.1.2", + "types-tzlocal==4.3.0.0", + "types-requests==2.31.0.2", + "types-six==1.16.0", + "types-urllib3==1.26.16", + "types-tqdm==4.66.0.1", + "types-mock==5.1.0.1" ] [project.urls] @@ -72,12 +80,18 @@ log_format = "%(levelname)-8s %(message)s" log_date_format = "%Y-%m-%d %H:%M:%S" timeout = 300 testpaths = [ - "tests" + "tests", + "src" # needed for doctests ] pythonpath = [ "src" ] +addopts = "--doctest-modules" [tool.setuptools.packages.find] where = ["src"] # list of folders that contain the packages (["."] by default) exclude = ["starters"] + +[[tool.mypy.overrides]] +module = ['piexif.*', 'future.*', 'vcr.*'] +ignore_missing_imports = true diff --git a/scripts/format b/scripts/format index 5cd8b1b11..3c8154288 100755 --- a/scripts/format +++ b/scripts/format @@ -1,3 +1,4 @@ #!/bin/bash set -euo pipefail -autopep8 -r --in-place --aggressive --aggressive icloudpd/ +echo "Running autopep8..." +autopep8 -r --in-place --aggressive --aggressive src/ --exclude src/pyicloud_ipd diff --git a/scripts/lint b/scripts/lint index 8e5ca121d..1dda5e88d 100755 --- a/scripts/lint +++ b/scripts/lint @@ -1,4 +1,4 @@ #!/bin/bash set -euo pipefail echo "Running pylint..." -python3 -m pylint src/icloudpd +python3 -m pylint src --ignore-paths src/icloud,src/pyicloud_ipd,src/starters diff --git a/scripts/run_all_checks b/scripts/run_all_checks index 0108afe26..3136d8b25 100755 --- a/scripts/run_all_checks +++ b/scripts/run_all_checks @@ -11,4 +11,5 @@ ROOT_DIR="$(realpath $(dirname "$0")/..)" $CURRENT_DIR/format $CURRENT_DIR/lint +$CURRENT_DIR/type_check $CURRENT_DIR/test diff --git a/scripts/type_check b/scripts/type_check new file mode 100755 index 000000000..dfb1208ff --- /dev/null +++ b/scripts/type_check @@ -0,0 +1,5 @@ +#!/bin/bash +set -euo pipefail +echo "Running mypy..." +python3 -m mypy src tests +# too strict now: --disallow-any-generics --disallow-untyped-defs --strict-equality --disallow-untyped-calls --warn-return-any \ No newline at end of file diff --git a/src/icloudpd/authentication.py b/src/icloudpd/authentication.py index e6ee5007a..e92ea4355 100644 --- a/src/icloudpd/authentication.py +++ b/src/icloudpd/authentication.py @@ -1,5 +1,6 @@ """Handles username/password authentication and two-step authentication""" +import logging import sys import click import pyicloud_ipd @@ -12,7 +13,7 @@ class TwoStepAuthRequiredError(Exception): """ -def authenticator(logger, domain): +def authenticator(logger: logging.Logger, domain: str): """Wraping authentication with domain context""" def authenticate_( username, @@ -20,7 +21,7 @@ def authenticate_( cookie_directory=None, raise_error_on_2sa=False, client_id=None, - ): + ) -> pyicloud_ipd.PyiCloudService: """Authenticate with iCloud username and password""" logger.debug("Authenticating...") while True: @@ -49,7 +50,7 @@ def authenticate_( return authenticate_ -def request_2sa(icloud, logger): +def request_2sa(icloud: pyicloud_ipd.PyiCloudService, logger: logging.Logger): """Request two-step authentication. Prompts for SMS or device""" devices = icloud.trusted_devices devices_count = len(devices) diff --git a/src/icloudpd/autodelete.py b/src/icloudpd/autodelete.py index 540af959a..7303e4a6c 100644 --- a/src/icloudpd/autodelete.py +++ b/src/icloudpd/autodelete.py @@ -1,30 +1,32 @@ """ Delete any files found in "Recently Deleted" """ +import logging import os from tzlocal import get_localzone from icloudpd.paths import local_download_path +import pyicloud_ipd -def delete_file(logger, path) -> bool: +def delete_file(logger: logging.Logger, path: str) -> bool: """ Actual deletion of files """ os.remove(path) logger.info("Deleted %s", path) return True -def delete_file_dry_run(logger, path) -> bool: +def delete_file_dry_run(logger: logging.Logger, path: str) -> bool: """ Dry run deletion of files """ logger.info("[DRY RUN] Would delete %s", path) return True def autodelete_photos( - logger, - dry_run, + logger: logging.Logger, + dry_run: bool, library_object, - folder_structure, - directory): + folder_structure: str, + directory: str): """ Scans the "Recently Deleted" folder and deletes any matching files from the download directory. @@ -46,7 +48,7 @@ def autodelete_photos( date_path = folder_structure.format(created_date) download_dir = os.path.join(directory, date_path) - for size in [None, "original", "medium", "thumb"]: + for size in ["small", "original", "medium", "thumb"]: path = os.path.normpath( local_download_path( media, size, download_dir)) diff --git a/src/icloudpd/base.py b/src/icloudpd/base.py index 3661e3ead..584139a5d 100644 --- a/src/icloudpd/base.py +++ b/src/icloudpd/base.py @@ -6,18 +6,21 @@ import time import datetime import logging +from logging import Logger import itertools import subprocess import json -from typing import Callable, TypeVar +from typing import Callable, Optional, TypeVar, cast import urllib import click from tqdm import tqdm from tqdm.contrib.logging import logging_redirect_tqdm from tzlocal import get_localzone +from pyicloud_ipd import PyiCloudService from pyicloud_ipd.exceptions import PyiCloudAPIResponseError +from pyicloud_ipd.services.photos import PhotoAsset from icloudpd.authentication import authenticator, TwoStepAuthRequiredError from icloudpd import download @@ -237,40 +240,40 @@ # pylint: disable-msg=too-many-arguments,too-many-statements # pylint: disable-msg=too-many-branches,too-many-locals def main( - directory, - username, - password, - cookie_directory, - size, - live_photo_size, - recent, - until_found, - album, - list_albums, + directory: Optional[str], + username: Optional[str], + password: Optional[str], + cookie_directory: str, + size: str, + live_photo_size: str, + recent: Optional[int], + until_found: Optional[int], + album: str, + list_albums: bool, library, list_libraries, - skip_videos, - skip_live_photos, - force_size, - auto_delete, - only_print_filenames, - folder_structure, - set_exif_datetime, - smtp_username, - smtp_password, - smtp_host, - smtp_port, - smtp_no_tls, - notification_email, - notification_email_from, - log_level, - no_progress_bar, - notification_script, - threads_num, # pylint: disable=W0613 - delete_after_download, - domain, - watch_with_interval, - dry_run + skip_videos: bool, + skip_live_photos: bool, + force_size: bool, + auto_delete: bool, + only_print_filenames: bool, + folder_structure: str, + set_exif_datetime: bool, + smtp_username: Optional[str], + smtp_password: Optional[str], + smtp_host: str, + smtp_port: int, + smtp_no_tls: bool, + notification_email: Optional[str], + notification_email_from: Optional[str], + log_level: str, + no_progress_bar: bool, + notification_script: Optional[str], + threads_num: int, # pylint: disable=W0613 + delete_after_download: bool, + domain: str, + watch_with_interval: Optional[int], + dry_run: bool ): """Download all iCloud photos to a local directory""" @@ -322,7 +325,7 @@ def main( set_exif_datetime, skip_live_photos, live_photo_size, - dry_run), + dry_run) if directory is not None else (lambda _s: lambda _c, _p: False), directory, username, password, @@ -355,25 +358,26 @@ def main( ) ) + # pylint: disable-msg=too-many-arguments,too-many-statements # pylint: disable-msg=too-many-branches,too-many-locals def download_builder( - logger, - skip_videos, - folder_structure, - directory, - size, - force_size, - only_print_filenames, - set_exif_datetime, - skip_live_photos, - live_photo_size, - dry_run): + logger: logging.Logger, + skip_videos: bool, + folder_structure: str, + directory: str, + size: str, + force_size: bool, + only_print_filenames: bool, + set_exif_datetime: bool, + skip_live_photos: bool, + live_photo_size: str, + dry_run: bool) -> Callable[[PyiCloudService], Callable[[Counter, PhotoAsset], bool]]: """factory for downloader""" - def state_(icloud): - def download_photo_(counter, photo) -> bool: + def state_(icloud: PyiCloudService) -> Callable[[Counter, PhotoAsset], bool]: + def download_photo_(counter: Counter, photo: PhotoAsset) -> bool: """internal function for actually downloading the photos""" filename = clean_filename(photo.filename) if skip_videos and photo.item_type != "image": @@ -590,7 +594,7 @@ def download_photo_(counter, photo) -> bool: return state_ -def delete_photo(logger, icloud, photo): +def delete_photo(logger: logging.Logger, icloud: PyiCloudService, photo: PhotoAsset): """Delete a photo from the iCloud account.""" clean_filename_local = clean_filename(photo.filename) logger.debug( @@ -621,7 +625,7 @@ def delete_photo(logger, icloud, photo): "Deleted %s in iCloud", clean_filename_local) -def delete_photo_dry_run(logger, _icloud, photo): +def delete_photo_dry_run(logger: logging.Logger, _icloud: PyiCloudService, photo: PhotoAsset): """Dry run for deleting a photo from the iCloud""" logger.info( "[DRY RUN] Would delete %s in iCloud", @@ -648,7 +652,7 @@ def retrier( raise -def session_error_handle_builder(logger, icloud): +def session_error_handle_builder(logger: Logger, icloud: PyiCloudService): """Build handler for session error""" def session_error_handler(ex, attempt): """Handles session errors in the PhotoAlbum photos iterator""" @@ -668,9 +672,9 @@ def session_error_handler(ex, attempt): return session_error_handler -def internal_error_handle_builder(logger): +def internal_error_handle_builder(logger: logging.Logger): """Build handler for internal error""" - def internal_error_handler(ex, attempt): + def internal_error_handler(ex: Exception, attempt: int) -> None: """Handles session errors in the PhotoAlbum photos iterator""" if "INTERNAL_ERROR" in str(ex): if attempt > constants.MAX_RETRIES: @@ -697,36 +701,36 @@ def composed(ex, retries): def core( - downloader, - directory, - username, - password, - cookie_directory, - size, - recent, - until_found, - album, - list_albums, + downloader: Callable[[PyiCloudService], Callable[[Counter, PhotoAsset], bool]], + directory: Optional[str], + username: Optional[str], + password: Optional[str], + cookie_directory: str, + size: str, + recent: Optional[int], + until_found: Optional[int], + album: str, + list_albums: bool, library, list_libraries, - skip_videos, - auto_delete, - only_print_filenames, - folder_structure, - smtp_username, - smtp_password, - smtp_host, - smtp_port, - smtp_no_tls, - notification_email, - notification_email_from, - no_progress_bar, - notification_script, - delete_after_download, - domain, - logger, - watch_interval, - dry_run + skip_videos: bool, + auto_delete: bool, + only_print_filenames: bool, + folder_structure: str, + smtp_username: Optional[str], + smtp_password: Optional[str], + smtp_host: str, + smtp_port: int, + smtp_no_tls: bool, + notification_email: Optional[str], + notification_email_from: Optional[str], + no_progress_bar: bool, + notification_script: Optional[str], + delete_after_download: bool, + domain: str, + logger: logging.Logger, + watch_interval: Optional[int], + dry_run: bool ): """Download all iCloud photos to a local directory""" @@ -748,6 +752,7 @@ def core( subprocess.call([notification_script]) if smtp_username is not None or notification_email is not None: send_2sa_notification( + logger, smtp_username, smtp_password, smtp_host, @@ -795,7 +800,9 @@ def core( album_titles = [str(a) for a in albums] print(*album_titles, sep="\n") return 0 - directory = os.path.normpath(directory) + # casting is okay since we checked for list_albums and directory compatibily upstream + # would be better to have that in types though + directory = os.path.normpath(cast(str, directory)) videos_phrase = "" if skip_videos else " and videos" logger.debug( @@ -813,27 +820,18 @@ def core( photos.exception_handler = error_handler - photos_count = len(photos) + photos_count: Optional[int] = len(photos) # Optional: Only download the x most recent photos. if recent is not None: photos_count = recent photos = itertools.islice(photos, recent) - tqdm_kwargs = {"total": photos_count} - if until_found is not None: - del tqdm_kwargs["total"] - photos_count = "???" + photos_count = None # ensure photos iterator doesn't have a known length photos = (p for p in photos) - # Use only ASCII characters in progress bar - tqdm_kwargs["ascii"] = True - - tqdm_kwargs["leave"] = False - tqdm_kwargs["dynamic_ncols"] = True - # Skip the one-line progress bar if we're only printing the filenames, # or if the progress bar is explicitly disabled, # or if this is not a terminal (e.g. cron or piping output to file) @@ -843,14 +841,25 @@ def core( photos_enumerator = photos # logger.set_tqdm(None) else: - photos_enumerator = tqdm(photos, **tqdm_kwargs) + photos_enumerator = tqdm( + iterable=photos, + total=photos_count, + leave=False, + dynamic_ncols=True, + ascii=True) # logger.set_tqdm(photos_enumerator) - plural_suffix = "" if photos_count == 1 else "s" - video_suffix = "" - photos_count_str = "the first" if photos_count == 1 else photos_count - if not skip_videos: - video_suffix = " or video" if photos_count == 1 else " and videos" + if photos_count is not None: + plural_suffix = "" if photos_count == 1 else "s" + video_suffix = "" + photos_count_str = "the first" if photos_count == 1 else photos_count + + if not skip_videos: + video_suffix = " or video" if photos_count == 1 else " and videos" + else: + photos_count_str = "???" + plural_suffix = "s" + video_suffix = " and videos" if not skip_videos else "" logger.info( ("Downloading %s %s" + " photo%s%s to %s ..."), @@ -863,7 +872,7 @@ def core( consecutive_files_found = Counter(0) - def should_break(counter): + def should_break(counter: Counter) -> bool: """Exit if until_found condition is reached""" return until_found is not None and counter.value() >= until_found diff --git a/src/icloudpd/constants.py b/src/icloudpd/constants.py index 28ed1a205..d2bd814e3 100644 --- a/src/icloudpd/constants.py +++ b/src/icloudpd/constants.py @@ -1,5 +1,7 @@ """Constants""" +from typing import Final + # For retrying connection after timeouts and errors -MAX_RETRIES = 5 -WAIT_SECONDS = 5 +MAX_RETRIES: Final[int] = 5 +WAIT_SECONDS: Final[int] = 5 diff --git a/src/icloudpd/counter.py b/src/icloudpd/counter.py index 6acaa93fe..0ea84b57b 100644 --- a/src/icloudpd/counter.py +++ b/src/icloudpd/counter.py @@ -17,6 +17,6 @@ def reset(self): with self.lock: self.val = RawValue('i', self.initial_value) - def value(self): + def value(self) -> int: with self.lock: return self.val.value diff --git a/src/icloudpd/download.py b/src/icloudpd/download.py index 9f6b6ef6b..f074ff678 100644 --- a/src/icloudpd/download.py +++ b/src/icloudpd/download.py @@ -1,18 +1,21 @@ """Handles file downloads with retries and error handling""" +import logging import os import socket import time import datetime from tzlocal import get_localzone -from requests.exceptions import ConnectionError # pylint: disable=redefined-builtin +from requests.exceptions import ConnectionError # pylint: disable=redefined-builtin +import pyicloud_ipd # pylint: disable=redefined-builtin from pyicloud_ipd.exceptions import PyiCloudAPIResponseError +from pyicloud_ipd.services.photos import PhotoAsset # Import the constants object so that we can mock WAIT_SECONDS in tests from icloudpd import constants -def update_mtime(created: datetime.datetime, download_path): +def update_mtime(created: datetime.datetime, download_path: str) -> None: """Set the modification time of the downloaded file to the photo creation date""" if created: created_date = None @@ -27,13 +30,13 @@ def update_mtime(created: datetime.datetime, download_path): set_utime(download_path, created_date) -def set_utime(download_path, created_date): +def set_utime(download_path: str, created_date: datetime.datetime) -> None: """Set date & time of the file""" ctime = time.mktime(created_date.timetuple()) os.utime(download_path, (ctime, ctime)) -def mkdirs_for_path(logger, download_path: str) -> bool: +def mkdirs_for_path(logger: logging.Logger, download_path: str) -> bool: """ Creates hierarchy of folders for file path if it needed """ try: # get back the directory for the file to be downloaded and create it if @@ -49,7 +52,7 @@ def mkdirs_for_path(logger, download_path: str) -> bool: return False -def mkdirs_for_path_dry_run(logger, download_path: str) -> bool: +def mkdirs_for_path_dry_run(logger: logging.Logger, download_path: str) -> bool: """ DRY Run for Creating hierarchy of folders for file path """ download_dir = os.path.dirname(download_path) if not os.path.exists(download_dir): @@ -61,7 +64,7 @@ def mkdirs_for_path_dry_run(logger, download_path: str) -> bool: def download_response_to_path( - _logger, + _logger: logging.Logger, response, download_path: str, created_date: datetime.datetime) -> bool: @@ -77,7 +80,7 @@ def download_response_to_path( def download_response_to_path_dry_run( - logger, + logger: logging.Logger, _response, download_path: str, _created_date: datetime.datetime) -> bool: @@ -92,12 +95,12 @@ def download_response_to_path_dry_run( def download_media( - logger, - dry_run, - icloud, - photo, - download_path, - size) -> bool: + logger: logging.Logger, + dry_run: bool, + icloud: pyicloud_ipd.PyiCloudService, + photo: PhotoAsset, + download_path: str, + size: str) -> bool: """Download the photo to path, with retries and error handling""" mkdirs_local = mkdirs_for_path_dry_run if dry_run else mkdirs_for_path diff --git a/src/icloudpd/email_notifications.py b/src/icloudpd/email_notifications.py index 6b67b6985..7cd85cdc2 100644 --- a/src/icloudpd/email_notifications.py +++ b/src/icloudpd/email_notifications.py @@ -1,25 +1,26 @@ """Send an email notification when 2SA is expired""" +import logging import smtplib import datetime -from icloudpd.logger import setup_logger +from typing import Optional, cast # pylint: disable-msg=too-many-arguments def send_2sa_notification( - smtp_email, - smtp_password, - smtp_host, - smtp_port, - smtp_no_tls, - to_addr, - from_addr=None): + logger: logging.Logger, + smtp_email: Optional[str], + smtp_password: Optional[str], + smtp_host: str, + smtp_port: int, + smtp_no_tls: bool, + to_addr: Optional[str], + from_addr: Optional[str]=None): """Send an email notification when 2SA is expired""" - to_addr = to_addr if to_addr else smtp_email - from_addr = from_addr or ( + to_addr = cast(str, to_addr if to_addr is not None else smtp_email) + from_addr = from_addr if from_addr is not None else ( f"iCloud Photos Downloader <{smtp_email}>" if smtp_email else to_addr) - logger = setup_logger() logger.info("Sending 'two-step expired' notification via email...") smtp = smtplib.SMTP(smtp_host, smtp_port) smtp.set_debuglevel(0) @@ -29,7 +30,7 @@ def send_2sa_notification( if not smtp_no_tls: smtp.starttls() - if smtp_email is not None or smtp_password is not None: + if smtp_email is not None and smtp_password is not None: smtp.login(smtp_email, smtp_password) subj = "icloud_photos_downloader: Two step authentication has expired" diff --git a/src/icloudpd/exif_datetime.py b/src/icloudpd/exif_datetime.py index b4897e5aa..e44df2876 100644 --- a/src/icloudpd/exif_datetime.py +++ b/src/icloudpd/exif_datetime.py @@ -1,10 +1,12 @@ """Get/set EXIF dates from photos""" +import datetime +import logging import piexif from piexif._exceptions import InvalidImageDataError -def get_photo_exif(logger, path): +def get_photo_exif(logger: logging.Logger, path:str): """Get EXIF date for a photo, return nothing if there is an error""" try: exif_dict = piexif.load(path) @@ -14,7 +16,7 @@ def get_photo_exif(logger, path): return None -def set_photo_exif(logger, path, date): +def set_photo_exif(logger: logging.Logger, path: str, date: datetime.datetime): """Set EXIF date on a photo, do nothing if there is an error""" try: exif_dict = piexif.load(path) diff --git a/src/icloudpd/string_helpers.py b/src/icloudpd/string_helpers.py index 241fe8fa3..b7be02192 100644 --- a/src/icloudpd/string_helpers.py +++ b/src/icloudpd/string_helpers.py @@ -1,7 +1,7 @@ """String helper functions""" -def truncate_middle(string, length): +def truncate_middle(string: str, length: int): """Truncates a string to a maximum length, inserting "..." in the middle""" if len(string) <= length: return string diff --git a/src/pyicloud_ipd/exceptions.py b/src/pyicloud_ipd/exceptions.py index 1ceb1f8e7..ef9c91729 100644 --- a/src/pyicloud_ipd/exceptions.py +++ b/src/pyicloud_ipd/exceptions.py @@ -31,10 +31,6 @@ def __init__(self, url): super(PyiCloud2SARequiredError, self).__init__(message) -class PyiCloudNoDevicesException(Exception): - pass - - class NoStoredPasswordAvailable(PyiCloudException): pass diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py index 70416f3eb..510951b9b 100644 --- a/tests/helpers/__init__.py +++ b/tests/helpers/__init__.py @@ -1,8 +1,10 @@ import os import shutil import traceback +from click.testing import Result -def print_result_exception(result): + +def print_result_exception(result: Result): ex = result.exception if ex: # This only works on Python 3 diff --git a/tests/test_cli.py b/tests/test_cli.py index 1fdc47476..6920a8751 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -9,7 +9,7 @@ import inspect import glob -from tests.helpers import path_from_project_root, recreate_path +from tests.helpers import path_from_project_root, print_result_exception, recreate_path vcr = VCR(decode_compressed_response=True) @@ -91,6 +91,8 @@ def test_tqdm(self): base_dir, ], ) + print_result_exception(result) + assert result.exit_code == 0 files_in_result = glob.glob(os.path.join(base_dir, "**/*.*"), recursive=True)