From df4376b586011bc1041bc5820892c70ce4ff3ebf Mon Sep 17 00:00:00 2001 From: aga Date: Mon, 30 Mar 2026 10:02:21 +0200 Subject: [PATCH 1/4] Show logs only in development --- .env.example | 1 + .gitignore | 3 ++- pyproject.toml | 6 +++++- src/photo_tools/cli.py | 8 +++++--- src/photo_tools/logging_config.py | 10 +++++++--- 5 files changed, 20 insertions(+), 8 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..54dacfb --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +PHOTO_TOOLS_DEBUG=1 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 8d9f191..50e5d70 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ __pycache__/ *.egg-info/ data dist/ -build/ \ No newline at end of file +build/ +.env \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 850503f..c77d5b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,11 @@ name = "photo-tools" version = "0.1.0" description = "Python CLI tools for photography workflows" requires-python = ">=3.13" -dependencies = ["typer>=0.24,<1.0", "pillow>=12.1,<13.0"] +dependencies = [ + "typer>=0.24,<1.0", + "pillow>=12.1,<13.0", + "python-dotenv>=1.2,<2.0", +] [dependency-groups] dev = ["pytest>=9,<10", "ruff>=0.15,<1.0", "mypy>=1.19,<2.0", "build>=1.4,<2.0"] diff --git a/src/photo_tools/cli.py b/src/photo_tools/cli.py index ee01e35..bf85bfa 100644 --- a/src/photo_tools/cli.py +++ b/src/photo_tools/cli.py @@ -1,4 +1,5 @@ import typer +from dotenv import load_dotenv from photo_tools.clean_unpaired_raws import clean_unpaired_raws from photo_tools.cli_errors import handle_cli_errors @@ -12,6 +13,10 @@ app = typer.Typer(help="CLI tools for organising and optimising photography workflows.") +load_dotenv() +setup_logging() + + @app.callback() def main() -> None: try: @@ -22,9 +27,6 @@ def main() -> None: raise typer.Exit(code=1) -setup_logging() - - @app.command("by-date") @handle_cli_errors def organise_by_date_cmd( diff --git a/src/photo_tools/logging_config.py b/src/photo_tools/logging_config.py index ce87448..bf942a8 100644 --- a/src/photo_tools/logging_config.py +++ b/src/photo_tools/logging_config.py @@ -1,11 +1,15 @@ import logging +import os -def setup_logging(level: int = logging.DEBUG) -> None: +def setup_logging() -> None: + debug = os.getenv("PHOTO_TOOLS_DEBUG") == "1" + level = logging.DEBUG if debug else logging.CRITICAL + logging.basicConfig( level=level, - format="%(levelname)s:%(name)s:%(message)s", + format="%(levelname)s: %(name)s: %(message)s", + force=True, # ensures config is applied even if already set ) - # suppress noisy debug logs from Pillow logging.getLogger("PIL").setLevel(logging.WARNING) From ff91c383e49ef4ad7dbaa945fd52b7579c811477 Mon Sep 17 00:00:00 2001 From: aga Date: Mon, 30 Mar 2026 10:03:30 +0200 Subject: [PATCH 2/4] Improve optimise logs - support verbose mode and add summary --- src/photo_tools/clean_unpaired_raws.py | 32 +++++++++-- src/photo_tools/cli.py | 54 ++++++++++++++++-- src/photo_tools/optimise.py | 53 ++++++++++++++---- src/photo_tools/organise_by_date.py | 76 +++++++++++++++++--------- src/photo_tools/separate_raws.py | 39 +++++++++++-- 5 files changed, 200 insertions(+), 54 deletions(-) diff --git a/src/photo_tools/clean_unpaired_raws.py b/src/photo_tools/clean_unpaired_raws.py index e315718..74437f0 100644 --- a/src/photo_tools/clean_unpaired_raws.py +++ b/src/photo_tools/clean_unpaired_raws.py @@ -14,6 +14,7 @@ def clean_unpaired_raws( raw_dir: str, jpg_dir: str, dry_run: bool = False, + verbose: bool = False, ) -> None: raw_path = Path(raw_dir) jpg_path = Path(jpg_dir) @@ -22,6 +23,11 @@ def clean_unpaired_raws( validate_input_dir(raw_path) validate_input_dir(jpg_path) + # For the summary + moved_count = 0 + dry_run_count = 0 + skipped_existing_count = 0 + jpg_files = [ f for f in jpg_path.iterdir() @@ -36,23 +42,39 @@ def clean_unpaired_raws( continue raw_stem = raw_file.stem.lower() - has_match = any(jpg.name.lower().startswith(raw_stem) for jpg in jpg_files) if has_match: - logger.debug(f"Keeping {raw_file.name} (matched JPG)") + logger.debug("Keeping %s (matched JPG)", raw_file.name) continue target_file = trash_dir / raw_file.name if target_file.exists(): - logger.info(f"Skipping (already moved): {target_file.name}") + skipped_existing_count += 1 + logger.warning(f"Skipping {raw_file.name}: already in raws-to-delete") continue if dry_run: - logger.info(f"[DRY RUN] Would move {raw_file.name} → {trash_dir}") + dry_run_count += 1 + if verbose: + logger.info(f"[DRY RUN] Would move {raw_file.name} -> {trash_dir}") continue trash_dir.mkdir(parents=True, exist_ok=True) shutil.move(str(raw_file), str(target_file)) - logger.info(f"Moved {raw_file.name} → {trash_dir}") + moved_count += 1 + + if verbose: + logger.info(f"Moved {raw_file.name} -> {trash_dir}") + + # Summary + if dry_run: + logger.info(f"Dry run complete: would move {dry_run_count} file(s)") + else: + logger.info(f"Moved {moved_count} file(s)") + + if skipped_existing_count: + logger.warning( + f"Skipped {skipped_existing_count} file(s): already exist in raws-to-delete" + ) \ No newline at end of file diff --git a/src/photo_tools/cli.py b/src/photo_tools/cli.py index bf85bfa..e9a3706 100644 --- a/src/photo_tools/cli.py +++ b/src/photo_tools/cli.py @@ -48,10 +48,21 @@ def organise_by_date_cmd( "--dry-run", help="Preview changes without moving files.", ), + verbose: bool = typer.Option( + False, + "--verbose", + "-v", + help="Show per-file output.", + ), ) -> None: """Organise images into folders based on capture date.""" - organise_by_date(input_dir, output_dir, suffix, dry_run) - + organise_by_date( + input_dir=input_dir, + output_dir=output_dir, + suffix=suffix, + dry_run=dry_run, + verbose=verbose, + ) @app.command( "raws", @@ -68,9 +79,19 @@ def separate_raws_cmd( "--dry-run", help="Preview changes without moving files.", ), + verbose: bool = typer.Option( + False, + "--verbose", + "-v", + help="Show per-file output.", + ), ) -> None: """Move RAW images into a 'raws' folder.""" - separate_raws(input_dir, dry_run) + separate_raws( + input_dir=input_dir, + dry_run=dry_run, + verbose=verbose, + ) @app.command( @@ -92,8 +113,19 @@ def clean_unpaired_raws_cmd( "--dry-run", help="Preview changes without moving files.", ), + verbose: bool = typer.Option( + False, + "--verbose", + "-v", + help="Show per-file output.", + ), ) -> None: - clean_unpaired_raws(raw_dir, jpg_dir, dry_run) + clean_unpaired_raws( + raw_dir=raw_dir, + jpg_dir=jpg_dir, + dry_run=dry_run, + verbose=verbose, + ) @app.command( @@ -112,9 +144,19 @@ def optimise_cmd( "--dry-run", help="Show resulting size and quality without writing files.", ), + verbose: bool = typer.Option( + False, + "--verbose", + "-v", + help="Show per-file output.", + ), ) -> None: - optimise(input_dir, dry_run) + optimise( + input_dir=input_dir, + dry_run=dry_run, + verbose=verbose, + ) if __name__ == "__main__": - app() + app() \ No newline at end of file diff --git a/src/photo_tools/optimise.py b/src/photo_tools/optimise.py index 9cd621f..4c69c7b 100644 --- a/src/photo_tools/optimise.py +++ b/src/photo_tools/optimise.py @@ -17,11 +17,19 @@ OUTPUT_PREFIX = "lq_" -def optimise(input_dir: str, dry_run: bool = False) -> None: +def optimise( + input_dir: str, + dry_run: bool = False, + verbose: bool = False, +) -> None: input_path = Path(input_dir) validate_input_dir(input_path) + optimised_count = 0 + dry_run_count = 0 + failed_count = 0 + for file_path in input_path.iterdir(): if not file_path.is_file(): continue @@ -36,23 +44,44 @@ def optimise(input_dir: str, dry_run: bool = False) -> None: output_path = file_path.with_name(f"{OUTPUT_PREFIX}{file_path.name}") - with Image.open(file_path) as original_img: - img = original_img.convert("RGB") - resized_img = resize_to_max_width(img, MAX_WIDTH) - jpeg_bytes, quality = optimise_jpeg(resized_img, MAX_FILE_SIZE_BYTES) + try: + with Image.open(file_path) as original_img: + img = original_img.convert("RGB") + resized_img = resize_to_max_width(img, MAX_WIDTH) + jpeg_bytes, quality = optimise_jpeg( + resized_img, + MAX_FILE_SIZE_BYTES, + ) + except Exception as e: + failed_count += 1 + logger.warning(f"Skipping {file_path.name}: could not optimise image") + logger.debug(f"Reason: {e}") + continue size_kb = len(jpeg_bytes) // 1024 if dry_run: + dry_run_count += 1 + if verbose: + logger.info( + f"[DRY RUN] Would optimise {file_path.name} -> {output_path.name} " + f"(quality={quality}, size={size_kb} KB)" + ) + continue + + output_path.write_bytes(jpeg_bytes) + optimised_count += 1 + + if verbose: logger.info( - f"[DRY RUN] Would optimise {file_path.name} → {output_path.name} " + f"Optimised {file_path.name} -> {output_path.name} " f"(quality={quality}, size={size_kb} KB)" ) - continue - output_path.write_bytes(jpeg_bytes) + if dry_run: + logger.info(f"Dry run complete: would optimise {dry_run_count} file(s)") + else: + logger.info(f"Optimised {optimised_count} file(s)") - logger.info( - f"Optimised {file_path.name} → {output_path.name} " - f"(quality={quality}, size={size_kb} KB)" - ) + if failed_count: + logger.warning(f"Skipped {failed_count} file(s): could not optimise image") \ No newline at end of file diff --git a/src/photo_tools/organise_by_date.py b/src/photo_tools/organise_by_date.py index d0a22bc..27eebda 100644 --- a/src/photo_tools/organise_by_date.py +++ b/src/photo_tools/organise_by_date.py @@ -19,53 +19,77 @@ def organise_by_date( output_dir: str, suffix: str | None = None, dry_run: bool = False, + verbose: bool = False, ) -> None: input_path = Path(input_dir) output_path = Path(output_dir) validate_input_dir(input_path) - output_path.mkdir(parents=True, exist_ok=True) + # For the summary + moved_count = 0 + dry_run_count = 0 + skipped_missing_date_count = 0 + skipped_existing_count = 0 + + cleaned_suffix = suffix.strip() if suffix and suffix.strip() else None for file_path in input_path.iterdir(): if not file_path.is_file(): continue if file_path.suffix.lower() not in IMAGE_EXTENSIONS: - logger.debug(f"Skipping (not an image): {file_path.name}") + logger.debug(f"Skipping unsupported file: {file_path.name}") continue try: date = get_image_date(file_path) + except Exception as e: + skipped_missing_date_count += 1 + logger.warning(f"Skipping {file_path.name}: could not read capture date") + logger.debug(f"Reason: {e}") + continue + + folder_name = date.strftime("%Y-%m-%d") + if cleaned_suffix: + folder_name = f"{folder_name} {cleaned_suffix}" - # 1. Build folder: YYYY-MM-DD suffix - folder_name = date.strftime("%Y-%m-%d") + target_dir = output_path / folder_name + target_file = target_dir / file_path.name - if suffix: - cleaned_suffix = suffix.strip() - if cleaned_suffix: - folder_name = f"{folder_name} {cleaned_suffix}" + if target_file.exists(): + skipped_existing_count += 1 + logger.warning(f"Skipping {file_path.name}: destination already exists") + continue + + if dry_run: + dry_run_count += 1 + if verbose: + logger.info( + f"[DRY RUN] Would move {file_path.name} -> {target_dir}" + ) + continue - target_dir = output_path / folder_name + target_dir.mkdir(parents=True, exist_ok=True) + shutil.move(str(file_path), str(target_file)) + moved_count += 1 - # 2. Ensure directory exists - if dry_run: - logger.info(f"[DRY RUN] Would ensure directory exists: {target_dir}") - else: - target_dir.mkdir(parents=True, exist_ok=True) + if verbose: + logger.info(f"Moved {file_path.name} -> {target_dir}") - # 3. Move file - target_file = target_dir / file_path.name + # Summary (always shown) - if target_file.exists(): - logger.info(f"Skipping (already exists): {target_file.name}") - continue + if dry_run: + logger.info(f"Dry run complete: would move {dry_run_count} file(s)") + else: + logger.info(f"Moved {moved_count} file(s)") - if dry_run: - logger.info(f"[DRY RUN] Would move {file_path.name} → {target_dir}") - else: - shutil.move(str(file_path), str(target_file)) - logger.info(f"Moved {file_path.name} → {target_dir}") + if skipped_missing_date_count: + logger.warning( + f"Skipped {skipped_missing_date_count} file(s): could not read capture date" + ) - except Exception as e: - logger.debug(f"Skipping {file_path.name}: {e}") + if skipped_existing_count: + logger.warning( + f"Skipped {skipped_existing_count} file(s): destination already exists" + ) \ No newline at end of file diff --git a/src/photo_tools/separate_raws.py b/src/photo_tools/separate_raws.py index 27d4684..4799310 100644 --- a/src/photo_tools/separate_raws.py +++ b/src/photo_tools/separate_raws.py @@ -8,13 +8,24 @@ RAW_EXTENSIONS = {".raf"} +OUTPUT_DIR = "raws" -def separate_raws(input_dir: str, dry_run: bool = False) -> None: + +def separate_raws( + input_dir: str, + dry_run: bool = False, + verbose: bool = False, +) -> None: input_path = Path(input_dir) validate_input_dir(input_path) - raws_dir = input_path / "raws" + raws_dir = input_path / OUTPUT_DIR + + # For the summary + moved_count = 0 + dry_run_count = 0 + skipped_existing_count = 0 for file_path in input_path.iterdir(): if not file_path.is_file(): @@ -27,13 +38,31 @@ def separate_raws(input_dir: str, dry_run: bool = False) -> None: target_file = raws_dir / file_path.name if target_file.exists(): - logger.info(f"Skipping (already exists): {target_file.name}") + skipped_existing_count += 1 + logger.warning(f"Skipping {file_path.name}: already exists in {OUTPUT_DIR}") continue if dry_run: - logger.info(f"[DRY RUN] Would move {file_path.name} → {raws_dir}") + dry_run_count += 1 + if verbose: + logger.info(f"[DRY RUN] Would move {file_path.name} -> {raws_dir}") continue raws_dir.mkdir(parents=True, exist_ok=True) shutil.move(str(file_path), str(target_file)) - logger.info(f"Moved {file_path.name} → {raws_dir}") + moved_count += 1 + + if verbose: + logger.info(f"Moved {file_path.name} -> {raws_dir}") + + # Summary (always) + + if dry_run: + logger.info(f"Dry run complete: would move {dry_run_count} file(s)") + else: + logger.info(f"Moved {moved_count} file(s)") + + if skipped_existing_count: + logger.warning( + f"Skipped {skipped_existing_count} file(s): already exist in raws" + ) \ No newline at end of file From a5130468e184b4550fd898d514c49088740f0e97 Mon Sep 17 00:00:00 2001 From: aga Date: Mon, 30 Mar 2026 12:46:13 +0200 Subject: [PATCH 3/4] Document global flags and remove repetitive command examples --- README.md | 39 ++++++++++++++++----------------------- 1 file changed, 16 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 4588e5d..045c752 100644 --- a/README.md +++ b/README.md @@ -65,17 +65,22 @@ Each command provides detailed usage, including arguments and options: photo-tools --help ``` -### Commands +**Note: All commands currently process files in the top-level input directory only.** -All commands support a `--dry-run` flag. Use this to safely preview changes before running a command. +### Global flags -- No files are modified -- No directories are created -- Actions are only printed +All commands support: -**All commands currently process files in the top-level input directory only.** +- `--dry-run` — preview changes without modifying files +- `--verbose` / `-v` — show per-file output -#### by-date +Flags can be combined: + +```shell +photo-tools ... --dry-run --verbose +``` + +### `by-date` - Organise images into date-based folders (`YYYY-MM-DD`, optional suffix) - Files are moved (not copied) into the output directory @@ -87,11 +92,9 @@ photo-tools by-date ```shell photo-tools by-date --suffix ``` -```shell -photo-tools by-date --dry-run -``` -#### raws + +### `raws` - Move RAW images into a `raws/` subfolder within the input directory - Non-RAW files are left unchanged @@ -101,11 +104,8 @@ photo-tools by-date --dry-run ```shell photo-tools raws ``` -```shell -photo-tools raws --dry-run -``` -#### clean-raws +### `clean-raws` - Move RAW files to `raws-to-delete/` if no matching JPG (same prefix) exists - Matching is based on filename prefix (e.g. `abcd.RAF` matches `abcd_edit.jpg`) @@ -115,11 +115,7 @@ photo-tools raws --dry-run photo-tools clean-raws ``` -```shell -photo-tools clean-raws --dry-run -``` - -#### optimise +### `optimise` - Resize images to a maximum width of `2500px` - Choose the highest quality that results in a file size ≤ `500 KB` (never below `70%`) @@ -130,9 +126,6 @@ photo-tools clean-raws --dry-run photo-tools optimise ``` -```shell -photo-tools optimise --dry-run -``` ## Local Development Setup From 7b468d1c00a79e94dcf9ce44e99f4d98320fa8b2 Mon Sep 17 00:00:00 2001 From: aga Date: Mon, 30 Mar 2026 13:47:09 +0200 Subject: [PATCH 4/4] Add reporter pattern for CLI output and decouple logging from core functions --- src/photo_tools/clean_unpaired_raws.py | 33 ++++++++----- src/photo_tools/cli.py | 12 +++-- src/photo_tools/cli_reporter.py | 17 +++++++ src/photo_tools/optimise.py | 39 ++++++++------- src/photo_tools/organise_by_date.py | 50 +++++++++++-------- src/photo_tools/separate_raws.py | 36 ++++++++------ tests/test_clean_unpaired_raws.py | 67 ++++++++++++++++++++++---- tests/test_optimise.py | 34 +++++++++++-- tests/test_organise_by_date.py | 55 ++++++++++++++++++--- tests/test_separate_raws.py | 46 +++++++++++++++--- 10 files changed, 290 insertions(+), 99 deletions(-) create mode 100644 src/photo_tools/cli_reporter.py diff --git a/src/photo_tools/clean_unpaired_raws.py b/src/photo_tools/clean_unpaired_raws.py index 74437f0..54e3190 100644 --- a/src/photo_tools/clean_unpaired_raws.py +++ b/src/photo_tools/clean_unpaired_raws.py @@ -1,5 +1,6 @@ import logging import shutil +from collections.abc import Callable from pathlib import Path from photo_tools.core.validation import validate_input_dir @@ -9,12 +10,14 @@ RAW_EXTENSIONS = {".raf"} JPG_EXTENSIONS = {".jpg", ".jpeg"} +Reporter = Callable[[str, str], None] + def clean_unpaired_raws( raw_dir: str, jpg_dir: str, + report: Reporter, dry_run: bool = False, - verbose: bool = False, ) -> None: raw_path = Path(raw_dir) jpg_path = Path(jpg_dir) @@ -23,7 +26,6 @@ def clean_unpaired_raws( validate_input_dir(raw_path) validate_input_dir(jpg_path) - # For the summary moved_count = 0 dry_run_count = 0 skipped_existing_count = 0 @@ -52,29 +54,36 @@ def clean_unpaired_raws( if target_file.exists(): skipped_existing_count += 1 - logger.warning(f"Skipping {raw_file.name}: already in raws-to-delete") + report( + "warning", + f"Skipping {raw_file.name}: already in raws-to-delete", + ) continue if dry_run: dry_run_count += 1 - if verbose: - logger.info(f"[DRY RUN] Would move {raw_file.name} -> {trash_dir}") + report( + "info", + f"[DRY RUN] Would move {raw_file.name} -> {trash_dir}", + ) continue trash_dir.mkdir(parents=True, exist_ok=True) shutil.move(str(raw_file), str(target_file)) moved_count += 1 - if verbose: - logger.info(f"Moved {raw_file.name} -> {trash_dir}") + report("info", f"Moved {raw_file.name} -> {trash_dir}") # Summary + if dry_run: - logger.info(f"Dry run complete: would move {dry_run_count} file(s)") + report("summary", f"Dry run complete: would move {dry_run_count} file(s)") else: - logger.info(f"Moved {moved_count} file(s)") + report("summary", f"Moved {moved_count} file(s)") if skipped_existing_count: - logger.warning( - f"Skipped {skipped_existing_count} file(s): already exist in raws-to-delete" - ) \ No newline at end of file + report( + "warning", + f"Skipped {skipped_existing_count} file(s): " + "already exist in raws-to-delete", + ) diff --git a/src/photo_tools/cli.py b/src/photo_tools/cli.py index e9a3706..b332e75 100644 --- a/src/photo_tools/cli.py +++ b/src/photo_tools/cli.py @@ -3,6 +3,7 @@ from photo_tools.clean_unpaired_raws import clean_unpaired_raws from photo_tools.cli_errors import handle_cli_errors +from photo_tools.cli_reporter import make_reporter from photo_tools.core.dependencies import validate_feature from photo_tools.exceptions import MissingDependencyError from photo_tools.logging_config import setup_logging @@ -59,11 +60,12 @@ def organise_by_date_cmd( organise_by_date( input_dir=input_dir, output_dir=output_dir, + report=make_reporter(verbose), suffix=suffix, dry_run=dry_run, - verbose=verbose, ) + @app.command( "raws", help="Move RAW images into a 'raws' folder", @@ -89,8 +91,8 @@ def separate_raws_cmd( """Move RAW images into a 'raws' folder.""" separate_raws( input_dir=input_dir, + report=make_reporter(verbose), dry_run=dry_run, - verbose=verbose, ) @@ -123,8 +125,8 @@ def clean_unpaired_raws_cmd( clean_unpaired_raws( raw_dir=raw_dir, jpg_dir=jpg_dir, + report=make_reporter(verbose), dry_run=dry_run, - verbose=verbose, ) @@ -153,10 +155,10 @@ def optimise_cmd( ) -> None: optimise( input_dir=input_dir, + report=make_reporter(verbose), dry_run=dry_run, - verbose=verbose, ) if __name__ == "__main__": - app() \ No newline at end of file + app() diff --git a/src/photo_tools/cli_reporter.py b/src/photo_tools/cli_reporter.py new file mode 100644 index 0000000..deba4d0 --- /dev/null +++ b/src/photo_tools/cli_reporter.py @@ -0,0 +1,17 @@ +from collections.abc import Callable + +import typer + +Reporter = Callable[[str, str], None] + + +def make_reporter(verbose: bool) -> Reporter: + def report(level: str, message: str) -> None: + if level == "warning": + typer.secho(message, fg=typer.colors.YELLOW, err=True) + elif level == "summary": + typer.echo(message) + elif level == "info" and verbose: + typer.echo(message) + + return report diff --git a/src/photo_tools/optimise.py b/src/photo_tools/optimise.py index 4c69c7b..eed0616 100644 --- a/src/photo_tools/optimise.py +++ b/src/photo_tools/optimise.py @@ -1,4 +1,5 @@ import logging +from collections.abc import Callable from pathlib import Path from PIL import Image @@ -16,11 +17,13 @@ MAX_QUALITY = 100 OUTPUT_PREFIX = "lq_" +Reporter = Callable[[str, str], None] + def optimise( input_dir: str, + report: Reporter, dry_run: bool = False, - verbose: bool = False, ) -> None: input_path = Path(input_dir) @@ -35,11 +38,11 @@ def optimise( continue if file_path.suffix.lower() not in IMAGE_EXTENSIONS: - logger.debug(f"Skipping (not a supported image): {file_path.name}") + logger.debug("Skipping (not a supported image): %s", file_path.name) continue if file_path.name.startswith(OUTPUT_PREFIX): - logger.debug(f"Skipping (already optimised): {file_path.name}") + logger.debug("Skipping (already optimised): %s", file_path.name) continue output_path = file_path.with_name(f"{OUTPUT_PREFIX}{file_path.name}") @@ -54,34 +57,34 @@ def optimise( ) except Exception as e: failed_count += 1 - logger.warning(f"Skipping {file_path.name}: could not optimise image") - logger.debug(f"Reason: {e}") + report("warning", f"Skipping {file_path.name}: could not optimise image") + logger.debug("Reason: %s", e) continue size_kb = len(jpeg_bytes) // 1024 if dry_run: dry_run_count += 1 - if verbose: - logger.info( - f"[DRY RUN] Would optimise {file_path.name} -> {output_path.name} " - f"(quality={quality}, size={size_kb} KB)" - ) + report( + "info", + f"[DRY RUN] Would optimise {file_path.name} -> {output_path.name} " + f"(quality={quality}, size={size_kb} KB)", + ) continue output_path.write_bytes(jpeg_bytes) optimised_count += 1 - if verbose: - logger.info( - f"Optimised {file_path.name} -> {output_path.name} " - f"(quality={quality}, size={size_kb} KB)" - ) + report( + "info", + f"Optimised {file_path.name} -> {output_path.name} " + f"(quality={quality}, size={size_kb} KB)", + ) if dry_run: - logger.info(f"Dry run complete: would optimise {dry_run_count} file(s)") + report("summary", f"Dry run complete: would optimise {dry_run_count} file(s)") else: - logger.info(f"Optimised {optimised_count} file(s)") + report("summary", f"Optimised {optimised_count} file(s)") if failed_count: - logger.warning(f"Skipped {failed_count} file(s): could not optimise image") \ No newline at end of file + report("warning", f"Skipped {failed_count} file(s): could not optimise image") diff --git a/src/photo_tools/organise_by_date.py b/src/photo_tools/organise_by_date.py index 27eebda..06a5e68 100644 --- a/src/photo_tools/organise_by_date.py +++ b/src/photo_tools/organise_by_date.py @@ -1,5 +1,6 @@ import logging import shutil +from collections.abc import Callable from pathlib import Path from photo_tools.core.validation import validate_input_dir @@ -13,20 +14,21 @@ ".raf", } +Reporter = Callable[[str, str], None] + def organise_by_date( input_dir: str, output_dir: str, + report: Reporter, suffix: str | None = None, dry_run: bool = False, - verbose: bool = False, ) -> None: input_path = Path(input_dir) output_path = Path(output_dir) validate_input_dir(input_path) - # For the summary moved_count = 0 dry_run_count = 0 skipped_missing_date_count = 0 @@ -39,15 +41,18 @@ def organise_by_date( continue if file_path.suffix.lower() not in IMAGE_EXTENSIONS: - logger.debug(f"Skipping unsupported file: {file_path.name}") + logger.debug("Skipping unsupported file: %s", file_path.name) continue try: date = get_image_date(file_path) except Exception as e: skipped_missing_date_count += 1 - logger.warning(f"Skipping {file_path.name}: could not read capture date") - logger.debug(f"Reason: {e}") + report( + "warning", + f"Skipping {file_path.name}: could not read capture date", + ) + logger.debug("Reason: %s", e) continue folder_name = date.strftime("%Y-%m-%d") @@ -59,37 +64,42 @@ def organise_by_date( if target_file.exists(): skipped_existing_count += 1 - logger.warning(f"Skipping {file_path.name}: destination already exists") + report( + "warning", + f"Skipping {file_path.name}: destination already exists", + ) continue if dry_run: dry_run_count += 1 - if verbose: - logger.info( - f"[DRY RUN] Would move {file_path.name} -> {target_dir}" - ) + report( + "info", + f"[DRY RUN] Would move {file_path.name} -> {target_dir}", + ) continue target_dir.mkdir(parents=True, exist_ok=True) shutil.move(str(file_path), str(target_file)) moved_count += 1 - if verbose: - logger.info(f"Moved {file_path.name} -> {target_dir}") + report("info", f"Moved {file_path.name} -> {target_dir}") - # Summary (always shown) + # Summary if dry_run: - logger.info(f"Dry run complete: would move {dry_run_count} file(s)") + report("summary", f"Dry run complete: would move {dry_run_count} file(s)") else: - logger.info(f"Moved {moved_count} file(s)") + report("summary", f"Moved {moved_count} file(s)") if skipped_missing_date_count: - logger.warning( - f"Skipped {skipped_missing_date_count} file(s): could not read capture date" + report( + "warning", + f"Skipped {skipped_missing_date_count} file(s): " + "could not read capture date", ) if skipped_existing_count: - logger.warning( - f"Skipped {skipped_existing_count} file(s): destination already exists" - ) \ No newline at end of file + report( + "warning", + f"Skipped {skipped_existing_count} file(s): destination already exists", + ) diff --git a/src/photo_tools/separate_raws.py b/src/photo_tools/separate_raws.py index 4799310..b900da2 100644 --- a/src/photo_tools/separate_raws.py +++ b/src/photo_tools/separate_raws.py @@ -1,5 +1,6 @@ import logging import shutil +from collections.abc import Callable from pathlib import Path from photo_tools.core.validation import validate_input_dir @@ -7,14 +8,15 @@ logger = logging.getLogger(__name__) RAW_EXTENSIONS = {".raf"} - OUTPUT_DIR = "raws" +Reporter = Callable[[str, str], None] + def separate_raws( input_dir: str, + report: Reporter, dry_run: bool = False, - verbose: bool = False, ) -> None: input_path = Path(input_dir) @@ -22,7 +24,6 @@ def separate_raws( raws_dir = input_path / OUTPUT_DIR - # For the summary moved_count = 0 dry_run_count = 0 skipped_existing_count = 0 @@ -32,37 +33,42 @@ def separate_raws( continue if file_path.suffix.lower() not in RAW_EXTENSIONS: - logger.debug(f"Skipping (not RAW): {file_path.name}") + logger.debug("Skipping (not RAW): %s", file_path.name) continue target_file = raws_dir / file_path.name if target_file.exists(): skipped_existing_count += 1 - logger.warning(f"Skipping {file_path.name}: already exists in {OUTPUT_DIR}") + report( + "warning", + f"Skipping {file_path.name}: already exists in {OUTPUT_DIR}", + ) continue if dry_run: dry_run_count += 1 - if verbose: - logger.info(f"[DRY RUN] Would move {file_path.name} -> {raws_dir}") + report( + "info", + f"[DRY RUN] Would move {file_path.name} -> {raws_dir}", + ) continue raws_dir.mkdir(parents=True, exist_ok=True) shutil.move(str(file_path), str(target_file)) moved_count += 1 - if verbose: - logger.info(f"Moved {file_path.name} -> {raws_dir}") + report("info", f"Moved {file_path.name} -> {raws_dir}") - # Summary (always) + # Summary if dry_run: - logger.info(f"Dry run complete: would move {dry_run_count} file(s)") + report("summary", f"Dry run complete: would move {dry_run_count} file(s)") else: - logger.info(f"Moved {moved_count} file(s)") + report("summary", f"Moved {moved_count} file(s)") if skipped_existing_count: - logger.warning( - f"Skipped {skipped_existing_count} file(s): already exist in raws" - ) \ No newline at end of file + report( + "warning", + f"Skipped {skipped_existing_count} file(s): already exist in raws", + ) diff --git a/tests/test_clean_unpaired_raws.py b/tests/test_clean_unpaired_raws.py index e51b8d4..3ee68a4 100644 --- a/tests/test_clean_unpaired_raws.py +++ b/tests/test_clean_unpaired_raws.py @@ -1,6 +1,10 @@ from photo_tools.clean_unpaired_raws import clean_unpaired_raws +def noop_report(level: str, message: str) -> None: + pass + + def test_dry_run_does_not_move_unpaired_raw_files(tmp_path): raw_dir = tmp_path / "raws" jpg_dir = tmp_path / "jpgs" @@ -11,7 +15,12 @@ def test_dry_run_does_not_move_unpaired_raw_files(tmp_path): raw_file = raw_dir / "photo.raf" raw_file.write_text("fake raw content") - clean_unpaired_raws(str(raw_dir), str(jpg_dir), dry_run=True) + clean_unpaired_raws( + str(raw_dir), + str(jpg_dir), + report=noop_report, + dry_run=True, + ) assert raw_file.exists() assert not (raw_dir / "raws-to-delete" / "photo.raf").exists() @@ -27,7 +36,12 @@ def test_moves_unpaired_raw_file_into_raws_to_delete_folder(tmp_path): raw_file = raw_dir / "photo.raf" raw_file.write_text("fake raw content") - clean_unpaired_raws(str(raw_dir), str(jpg_dir), dry_run=False) + clean_unpaired_raws( + str(raw_dir), + str(jpg_dir), + report=noop_report, + dry_run=False, + ) moved_file = raw_dir / "raws-to-delete" / "photo.raf" @@ -48,7 +62,12 @@ def test_keeps_raw_file_when_matching_jpg_exists(tmp_path): raw_file.write_text("fake raw content") jpg_file.write_text("fake jpg content") - clean_unpaired_raws(str(raw_dir), str(jpg_dir), dry_run=False) + clean_unpaired_raws( + str(raw_dir), + str(jpg_dir), + report=noop_report, + dry_run=False, + ) assert raw_file.exists() assert jpg_file.exists() @@ -68,7 +87,12 @@ def test_keeps_raw_file_when_matching_jpg_starts_with_same_stem(tmp_path): raw_file.write_text("fake raw content") jpg_file.write_text("fake jpg content") - clean_unpaired_raws(str(raw_dir), str(jpg_dir), dry_run=False) + clean_unpaired_raws( + str(raw_dir), + str(jpg_dir), + report=noop_report, + dry_run=False, + ) assert raw_file.exists() assert not (raw_dir / "raws-to-delete" / "photo.raf").exists() @@ -89,7 +113,12 @@ def test_moves_only_unpaired_raw_files(tmp_path): unmatched_raw.write_text("unmatched raw") matching_jpg.write_text("matching jpg") - clean_unpaired_raws(str(raw_dir), str(jpg_dir), dry_run=False) + clean_unpaired_raws( + str(raw_dir), + str(jpg_dir), + report=noop_report, + dry_run=False, + ) assert matched_raw.exists() assert not unmatched_raw.exists() @@ -109,7 +138,12 @@ def test_ignores_non_raw_files_in_raw_directory(tmp_path): jpg_file.write_text("fake jpg content") txt_file.write_text("notes") - clean_unpaired_raws(str(raw_dir), str(jpg_dir), dry_run=False) + clean_unpaired_raws( + str(raw_dir), + str(jpg_dir), + report=noop_report, + dry_run=False, + ) assert jpg_file.exists() assert txt_file.exists() @@ -132,7 +166,12 @@ def test_skips_file_when_destination_already_exists(tmp_path): source_file.write_text("new raw") existing_file.write_text("existing raw") - clean_unpaired_raws(str(raw_dir), str(jpg_dir), dry_run=False) + clean_unpaired_raws( + str(raw_dir), + str(jpg_dir), + report=noop_report, + dry_run=False, + ) assert source_file.exists() assert existing_file.exists() @@ -146,7 +185,12 @@ def test_handles_empty_directories(tmp_path): raw_dir.mkdir() jpg_dir.mkdir() - clean_unpaired_raws(str(raw_dir), str(jpg_dir), dry_run=False) + clean_unpaired_raws( + str(raw_dir), + str(jpg_dir), + report=noop_report, + dry_run=False, + ) assert not any(raw_dir.iterdir()) @@ -163,7 +207,12 @@ def test_ignores_nested_directories(tmp_path): nested_raw = nested_dir / "photo.raf" nested_raw.write_text("fake raw content") - clean_unpaired_raws(str(raw_dir), str(jpg_dir), dry_run=False) + clean_unpaired_raws( + str(raw_dir), + str(jpg_dir), + report=noop_report, + dry_run=False, + ) assert nested_raw.exists() assert not (raw_dir / "raws-to-delete" / "photo.raf").exists() diff --git a/tests/test_optimise.py b/tests/test_optimise.py index ef89e25..69e4da9 100644 --- a/tests/test_optimise.py +++ b/tests/test_optimise.py @@ -3,6 +3,10 @@ from photo_tools.optimise import MAX_WIDTH, optimise +def noop_report(level: str, message: str) -> None: + pass + + def test_dry_run_does_not_create_output(tmp_path): input_dir = tmp_path / "input" input_dir.mkdir() @@ -10,7 +14,11 @@ def test_dry_run_does_not_create_output(tmp_path): img_path = input_dir / "photo.jpg" Image.new("RGB", (1000, 1000)).save(img_path) - optimise(str(input_dir), dry_run=True) + optimise( + str(input_dir), + report=noop_report, + dry_run=True, + ) assert img_path.exists() assert not (input_dir / "lq_photo.jpg").exists() @@ -23,7 +31,11 @@ def test_creates_optimised_file(tmp_path): img_path = input_dir / "photo.jpg" Image.new("RGB", (1000, 1000)).save(img_path) - optimise(str(input_dir), dry_run=False) + optimise( + str(input_dir), + report=noop_report, + dry_run=False, + ) output = input_dir / "lq_photo.jpg" @@ -38,7 +50,11 @@ def test_skips_unsupported_files(tmp_path): txt_file = input_dir / "notes.txt" txt_file.write_text("hello") - optimise(str(input_dir), dry_run=False) + optimise( + str(input_dir), + report=noop_report, + dry_run=False, + ) assert txt_file.exists() assert not (input_dir / "lq_notes.txt").exists() @@ -51,7 +67,11 @@ def test_skips_already_optimised_files(tmp_path): img_path = input_dir / "lq_photo.jpg" Image.new("RGB", (1000, 1000)).save(img_path) - optimise(str(input_dir), dry_run=False) + optimise( + str(input_dir), + report=noop_report, + dry_run=False, + ) assert img_path.exists() assert not (input_dir / "lq_lq_photo.jpg").exists() @@ -64,7 +84,11 @@ def test_output_image_width_is_capped(tmp_path): img_path = input_dir / "photo.jpg" Image.new("RGB", (4000, 2000)).save(img_path) - optimise(str(input_dir), dry_run=False) + optimise( + str(input_dir), + report=noop_report, + dry_run=False, + ) output = input_dir / "lq_photo.jpg" diff --git a/tests/test_organise_by_date.py b/tests/test_organise_by_date.py index 460c095..141f164 100644 --- a/tests/test_organise_by_date.py +++ b/tests/test_organise_by_date.py @@ -3,6 +3,10 @@ from photo_tools.organise_by_date import organise_by_date +def noop_report(level: str, message: str) -> None: + pass + + def test_dry_run_does_not_move_files(tmp_path, monkeypatch): input_dir = tmp_path / "input" output_dir = tmp_path / "output" @@ -14,10 +18,15 @@ def test_dry_run_does_not_move_files(tmp_path, monkeypatch): monkeypatch.setattr( "photo_tools.organise_by_date.get_image_date", - lambda _: __import__("datetime").datetime(2024, 5, 17), + lambda _: datetime(2024, 5, 17), ) - organise_by_date(str(input_dir), str(output_dir), dry_run=True) + organise_by_date( + str(input_dir), + str(output_dir), + report=noop_report, + dry_run=True, + ) assert image_file.exists() assert not (output_dir / "2024-05-17" / "photo.jpg").exists() @@ -37,7 +46,12 @@ def test_moves_file_into_date_folder(tmp_path, monkeypatch): lambda _: datetime(2024, 5, 17), ) - organise_by_date(str(input_dir), str(output_dir), dry_run=False) + organise_by_date( + str(input_dir), + str(output_dir), + report=noop_report, + dry_run=False, + ) moved_file = output_dir / "2024-05-17" / "photo.jpg" @@ -62,7 +76,12 @@ def test_skips_unsupported_files(tmp_path, monkeypatch): lambda _: datetime(2024, 5, 17), ) - organise_by_date(str(input_dir), str(output_dir), dry_run=False) + organise_by_date( + str(input_dir), + str(output_dir), + report=noop_report, + dry_run=False, + ) moved_file = output_dir / "2024-05-17" / "photo.jpg" @@ -85,7 +104,12 @@ def test_skips_files_with_missing_date_metadata(tmp_path, monkeypatch): lambda _: (_ for _ in ()).throw(ValueError("No DateTimeOriginal")), ) - organise_by_date(str(input_dir), str(output_dir), dry_run=False) + organise_by_date( + str(input_dir), + str(output_dir), + report=noop_report, + dry_run=False, + ) assert image_file.exists() assert not any(output_dir.rglob("*")) @@ -113,7 +137,12 @@ def test_moves_files_into_separate_date_folders(tmp_path, monkeypatch): lambda file_path: dates[file_path.name], ) - organise_by_date(str(input_dir), str(output_dir), dry_run=False) + organise_by_date( + str(input_dir), + str(output_dir), + report=noop_report, + dry_run=False, + ) assert (output_dir / "2024-05-17" / "first.jpg").exists() assert (output_dir / "2024-05-18" / "second.jpg").exists() @@ -140,7 +169,12 @@ def test_skips_file_when_destination_already_exists(tmp_path, monkeypatch): lambda _: datetime(2024, 5, 17), ) - organise_by_date(str(input_dir), str(output_dir), dry_run=False) + organise_by_date( + str(input_dir), + str(output_dir), + report=noop_report, + dry_run=False, + ) assert source_file.exists() assert existing_file.exists() @@ -154,6 +188,11 @@ def test_handles_empty_input_directory(tmp_path): input_dir.mkdir() output_dir.mkdir() - organise_by_date(str(input_dir), str(output_dir), dry_run=False) + organise_by_date( + str(input_dir), + str(output_dir), + report=noop_report, + dry_run=False, + ) assert not any(output_dir.iterdir()) diff --git a/tests/test_separate_raws.py b/tests/test_separate_raws.py index 68f0a94..f4dd521 100644 --- a/tests/test_separate_raws.py +++ b/tests/test_separate_raws.py @@ -1,6 +1,10 @@ from photo_tools.separate_raws import separate_raws +def noop_report(level: str, message: str) -> None: + pass + + def test_dry_run_does_not_move_raw_files(tmp_path): input_dir = tmp_path / "input" input_dir.mkdir() @@ -8,7 +12,11 @@ def test_dry_run_does_not_move_raw_files(tmp_path): raw_file = input_dir / "photo.raf" raw_file.write_text("fake raw content") - separate_raws(str(input_dir), dry_run=True) + separate_raws( + str(input_dir), + report=noop_report, + dry_run=True, + ) assert raw_file.exists() assert not (input_dir / "raws" / "photo.raf").exists() @@ -21,7 +29,11 @@ def test_moves_raw_file_into_raws_folder(tmp_path): raw_file = input_dir / "photo.raf" raw_file.write_text("fake raw content") - separate_raws(str(input_dir), dry_run=False) + separate_raws( + str(input_dir), + report=noop_report, + dry_run=False, + ) moved_file = input_dir / "raws" / "photo.raf" @@ -39,7 +51,11 @@ def test_leaves_non_raw_files_in_place(tmp_path): jpg_file.write_text("fake jpg content") txt_file.write_text("notes") - separate_raws(str(input_dir), dry_run=False) + separate_raws( + str(input_dir), + report=noop_report, + dry_run=False, + ) assert jpg_file.exists() assert txt_file.exists() @@ -57,7 +73,11 @@ def test_moves_only_raw_files(tmp_path): raw_file.write_text("fake raw content") jpg_file.write_text("fake jpg content") - separate_raws(str(input_dir), dry_run=False) + separate_raws( + str(input_dir), + report=noop_report, + dry_run=False, + ) assert (input_dir / "raws" / "photo.raf").exists() assert jpg_file.exists() @@ -77,7 +97,11 @@ def test_skips_file_when_destination_already_exists(tmp_path): source_file.write_text("new raw") existing_file.write_text("existing raw") - separate_raws(str(input_dir), dry_run=False) + separate_raws( + str(input_dir), + report=noop_report, + dry_run=False, + ) assert source_file.exists() assert existing_file.exists() @@ -88,7 +112,11 @@ def test_handles_empty_input_directory(tmp_path): input_dir = tmp_path / "input" input_dir.mkdir() - separate_raws(str(input_dir), dry_run=False) + separate_raws( + str(input_dir), + report=noop_report, + dry_run=False, + ) assert not any(input_dir.iterdir()) @@ -103,7 +131,11 @@ def test_ignores_nested_directories(tmp_path): nested_raw = nested_dir / "photo.raf" nested_raw.write_text("fake raw content") - separate_raws(str(input_dir), dry_run=False) + separate_raws( + str(input_dir), + report=noop_report, + dry_run=False, + ) assert nested_raw.exists() assert not (input_dir / "raws" / "photo.raf").exists()