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/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 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/clean_unpaired_raws.py b/src/photo_tools/clean_unpaired_raws.py index e315718..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,10 +10,13 @@ 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, ) -> None: raw_path = Path(raw_dir) @@ -22,6 +26,10 @@ def clean_unpaired_raws( validate_input_dir(raw_path) validate_input_dir(jpg_path) + moved_count = 0 + dry_run_count = 0 + skipped_existing_count = 0 + jpg_files = [ f for f in jpg_path.iterdir() @@ -36,23 +44,46 @@ 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 + report( + "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 + 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)) - logger.info(f"Moved {raw_file.name} → {trash_dir}") + moved_count += 1 + + report("info", f"Moved {raw_file.name} -> {trash_dir}") + + # Summary + + if dry_run: + report("summary", f"Dry run complete: would move {dry_run_count} file(s)") + else: + report("summary", f"Moved {moved_count} file(s)") + + if skipped_existing_count: + 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 ee01e35..b332e75 100644 --- a/src/photo_tools/cli.py +++ b/src/photo_tools/cli.py @@ -1,7 +1,9 @@ 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 +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 @@ -12,6 +14,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 +28,6 @@ def main() -> None: raise typer.Exit(code=1) -setup_logging() - - @app.command("by-date") @handle_cli_errors def organise_by_date_cmd( @@ -46,9 +49,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, + report=make_reporter(verbose), + suffix=suffix, + dry_run=dry_run, + ) @app.command( @@ -66,9 +81,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, + report=make_reporter(verbose), + dry_run=dry_run, + ) @app.command( @@ -90,8 +115,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, + report=make_reporter(verbose), + dry_run=dry_run, + ) @app.command( @@ -110,8 +146,18 @@ 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, + report=make_reporter(verbose), + dry_run=dry_run, + ) if __name__ == "__main__": 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/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) diff --git a/src/photo_tools/optimise.py b/src/photo_tools/optimise.py index 9cd621f..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,43 +17,74 @@ MAX_QUALITY = 100 OUTPUT_PREFIX = "lq_" +Reporter = Callable[[str, str], None] -def optimise(input_dir: str, dry_run: bool = False) -> None: + +def optimise( + input_dir: str, + report: Reporter, + dry_run: 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 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}") - 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 + 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: - logger.info( - f"[DRY RUN] Would optimise {file_path.name} → {output_path.name} " - f"(quality={quality}, size={size_kb} KB)" + dry_run_count += 1 + 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 - 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: + report("summary", f"Dry run complete: would optimise {dry_run_count} file(s)") + else: + report("summary", f"Optimised {optimised_count} file(s)") + + if failed_count: + 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 d0a22bc..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,10 +14,13 @@ ".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, ) -> None: @@ -25,47 +29,77 @@ def organise_by_date( validate_input_dir(input_path) - output_path.mkdir(parents=True, exist_ok=True) + 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("Skipping unsupported file: %s", file_path.name) continue try: date = get_image_date(file_path) + except Exception as e: + skipped_missing_date_count += 1 + report( + "warning", + f"Skipping {file_path.name}: could not read capture date", + ) + logger.debug("Reason: %s", e) + continue - # 1. Build folder: YYYY-MM-DD suffix - folder_name = date.strftime("%Y-%m-%d") + folder_name = date.strftime("%Y-%m-%d") + if cleaned_suffix: + folder_name = f"{folder_name} {cleaned_suffix}" - if suffix: - cleaned_suffix = suffix.strip() - if cleaned_suffix: - folder_name = f"{folder_name} {cleaned_suffix}" + target_dir = output_path / folder_name + target_file = target_dir / file_path.name - target_dir = output_path / folder_name + if target_file.exists(): + skipped_existing_count += 1 + report( + "warning", + f"Skipping {file_path.name}: destination already exists", + ) + continue - # 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 dry_run: + dry_run_count += 1 + report( + "info", + f"[DRY RUN] Would move {file_path.name} -> {target_dir}", + ) + continue - # 3. Move file - target_file = target_dir / file_path.name + target_dir.mkdir(parents=True, exist_ok=True) + shutil.move(str(file_path), str(target_file)) + moved_count += 1 - if target_file.exists(): - logger.info(f"Skipping (already exists): {target_file.name}") - continue + report("info", f"Moved {file_path.name} -> {target_dir}") - 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}") + # Summary - except Exception as e: - logger.debug(f"Skipping {file_path.name}: {e}") + if dry_run: + report("summary", f"Dry run complete: would move {dry_run_count} file(s)") + else: + report("summary", f"Moved {moved_count} file(s)") + + if skipped_missing_date_count: + report( + "warning", + f"Skipped {skipped_missing_date_count} file(s): " + "could not read capture date", + ) + + if skipped_existing_count: + 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 27d4684..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,33 +8,67 @@ logger = logging.getLogger(__name__) RAW_EXTENSIONS = {".raf"} +OUTPUT_DIR = "raws" +Reporter = Callable[[str, str], None] -def separate_raws(input_dir: str, dry_run: bool = False) -> None: + +def separate_raws( + input_dir: str, + report: Reporter, + dry_run: bool = False, +) -> None: input_path = Path(input_dir) validate_input_dir(input_path) - raws_dir = input_path / "raws" + raws_dir = input_path / OUTPUT_DIR + + moved_count = 0 + dry_run_count = 0 + skipped_existing_count = 0 for file_path in input_path.iterdir(): if not file_path.is_file(): 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(): - logger.info(f"Skipping (already exists): {target_file.name}") + skipped_existing_count += 1 + report( + "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 + 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)) - logger.info(f"Moved {file_path.name} → {raws_dir}") + moved_count += 1 + + report("info", f"Moved {file_path.name} -> {raws_dir}") + + # Summary + + if dry_run: + report("summary", f"Dry run complete: would move {dry_run_count} file(s)") + else: + report("summary", f"Moved {moved_count} file(s)") + + if skipped_existing_count: + 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()