diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..36947d4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,47 @@ +# Changelog + +## [0.3.0] - 2026-04-17 + +### Added +- Support for additional RAW formats (`.cr2`, `.cr3`, `.nef`, `.arw`, `.orf`, `.rw2`, `.dng`, `.pef`, `.srw`, `.x3f`) + +### Changed +- Centralised file type detection into shared utilities (`is_raw`, `is_jpg`) +- Improved consistency across commands by removing duplicated extension logic + +### Documentation +- Added CHANGELOG.md to track release history + +### Notes +- File type detection is based on file extensions only (case-insensitive) + +## [0.2.0] - 2026-04-16 + +### Added +- New command to keep only 5-star RAW files (`keep-5star-raws`) + + +## [0.1.1] - 2026-03-30 + +### Added +- GitHub Actions workflow for automated PyPI publishing +- Trusted Publishing (OIDC) configuration for secure releases + +### Changed +- Updated project configuration for automated releases + + +## [0.1.0] - 2026-03-30 + +### Added +- Initial release of Photo Tools CLI +- Command to organise images into date-based folders (based on EXIF metadata) (`by-date`) +- RAW/JPG workflow commands: + - separate RAW files (`raws`) + - clean unpaired RAW files (`clean-raws`) +- Image optimisation command (`optimise`) +- CLI interface with support for `--dry-run` and verbose output + +### Notes +- Focus on non-destructive operations (files are moved, not deleted) +- Relies on ExifTool for metadata extraction \ No newline at end of file diff --git a/README.md b/README.md index 64d342f..3e0e3d5 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,18 @@ Command-line tools for organising photos by date, managing RAW/JPG pairs, and optimising images. +## Changelog + +See [CHANGELOG.md](https://github.com/aga87/python-photo-tools/blob/main/CHANGELOG.md) for release history. + + ## Supported formats -- RAW: .raf -- JPG: .jpg, .jpeg +- **RAW**: `.cr2`, `.cr3`, `.nef`, `.arw`, `.raf`, `.orf`, `.rw2`, `.dng`, `.pef`, `.srw`, `.x3f` +- JPG: `.jpg`, `.jpeg` + + +> ⚠️ **Note:** Detection is based on file extensions only (case-insensitive). Files with incorrect or missing extensions may not be handled correctly. ## Prerequisites - System Tools diff --git a/pyproject.toml b/pyproject.toml index 7dfcea3..3bc36bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "photo-tools-cli" -version = "0.2.0" +version = "0.3.0" description = "Python CLI tools for photography workflows" readme = "README.md" requires-python = ">=3.13" diff --git a/src/photo_tools/commands/clean_unpaired_raws.py b/src/photo_tools/commands/clean_unpaired_raws.py index cc9cc1c..0d06a0e 100644 --- a/src/photo_tools/commands/clean_unpaired_raws.py +++ b/src/photo_tools/commands/clean_unpaired_raws.py @@ -6,9 +6,6 @@ logger = logging.getLogger(__name__) -RAW_EXTENSIONS = {".raf"} -JPG_EXTENSIONS = {".jpg", ".jpeg"} - Reporter = Callable[[str, str], None] RawMatcher = Callable[[Path, list[Path]], bool] diff --git a/src/photo_tools/commands/keep_five_star_raws.py b/src/photo_tools/commands/keep_five_star_raws.py index 383fcc0..319d54c 100644 --- a/src/photo_tools/commands/keep_five_star_raws.py +++ b/src/photo_tools/commands/keep_five_star_raws.py @@ -7,8 +7,6 @@ logger = logging.getLogger(__name__) -RAW_EXTENSIONS = {".raf"} -JPG_EXTENSIONS = {".jpg", ".jpeg"} Reporter = Callable[[str, str], None] RawMatcher = Callable[[Path, list[Path]], bool] diff --git a/src/photo_tools/commands/optimise.py b/src/photo_tools/commands/optimise.py index eed0616..a298ba7 100644 --- a/src/photo_tools/commands/optimise.py +++ b/src/photo_tools/commands/optimise.py @@ -5,11 +5,11 @@ from PIL import Image from photo_tools.core.validation import validate_input_dir +from photo_tools.image.file_types import is_jpg from photo_tools.image.optimisation import optimise_jpeg, resize_to_max_width logger = logging.getLogger(__name__) -IMAGE_EXTENSIONS = {".jpg", ".jpeg"} MAX_WIDTH = 2500 MAX_FILE_SIZE_BYTES = 500 * 1024 @@ -34,10 +34,7 @@ def optimise( 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: + if not is_jpg(file_path): logger.debug("Skipping (not a supported image): %s", file_path.name) continue diff --git a/src/photo_tools/commands/organise_by_date.py b/src/photo_tools/commands/organise_by_date.py index 06a5e68..83576d5 100644 --- a/src/photo_tools/commands/organise_by_date.py +++ b/src/photo_tools/commands/organise_by_date.py @@ -4,16 +4,11 @@ from pathlib import Path from photo_tools.core.validation import validate_input_dir +from photo_tools.image.file_types import is_jpg, is_raw from photo_tools.image.metadata import get_image_date logger = logging.getLogger(__name__) -IMAGE_EXTENSIONS = { - ".jpg", - ".jpeg", - ".raf", -} - Reporter = Callable[[str, str], None] @@ -37,10 +32,7 @@ def organise_by_date( 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: + if not (is_jpg(file_path) or is_raw(file_path)): logger.debug("Skipping unsupported file: %s", file_path.name) continue @@ -85,7 +77,6 @@ def organise_by_date( report("info", f"Moved {file_path.name} -> {target_dir}") # Summary - if dry_run: report("summary", f"Dry run complete: would move {dry_run_count} file(s)") else: diff --git a/src/photo_tools/commands/separate_raws.py b/src/photo_tools/commands/separate_raws.py index b900da2..37686ef 100644 --- a/src/photo_tools/commands/separate_raws.py +++ b/src/photo_tools/commands/separate_raws.py @@ -4,10 +4,10 @@ from pathlib import Path from photo_tools.core.validation import validate_input_dir +from photo_tools.image.file_types import is_raw logger = logging.getLogger(__name__) -RAW_EXTENSIONS = {".raf"} OUTPUT_DIR = "raws" Reporter = Callable[[str, str], None] @@ -29,10 +29,7 @@ def separate_raws( 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: + if not is_raw(file_path): logger.debug("Skipping (not RAW): %s", file_path.name) continue diff --git a/src/photo_tools/image/file_types.py b/src/photo_tools/image/file_types.py new file mode 100644 index 0000000..13d4e9d --- /dev/null +++ b/src/photo_tools/image/file_types.py @@ -0,0 +1,25 @@ +from pathlib import Path + +RAW_EXTENSIONS = { + ".cr2", + ".cr3", + ".nef", + ".arw", + ".raf", + ".orf", + ".rw2", + ".dng", + ".pef", + ".srw", + ".x3f", +} + +JPG_EXTENSIONS = {".jpg", ".jpeg"} + + +def is_raw(file_path: Path) -> bool: + return file_path.is_file() and file_path.suffix.lower() in RAW_EXTENSIONS + + +def is_jpg(file_path: Path) -> bool: + return file_path.is_file() and file_path.suffix.lower() in JPG_EXTENSIONS diff --git a/src/photo_tools/image/raw_utils.py b/src/photo_tools/image/raw_utils.py index 485daef..4774ec2 100644 --- a/src/photo_tools/image/raw_utils.py +++ b/src/photo_tools/image/raw_utils.py @@ -4,11 +4,10 @@ from pathlib import Path from photo_tools.core.validation import validate_input_dir +from photo_tools.image.file_types import is_jpg, is_raw logger = logging.getLogger(__name__) -RAW_EXTENSIONS = {".raf"} -JPG_EXTENSIONS = {".jpg", ".jpeg"} Reporter = Callable[[str, str], None] RawMatcher = Callable[[Path, list[Path]], bool] @@ -34,17 +33,10 @@ def move_raws_by_rule( dry_run_count = 0 skipped_existing_count = 0 - jpg_files = [ - f - for f in jpg_path.iterdir() - if f.is_file() and f.suffix.lower() in JPG_EXTENSIONS - ] + jpg_files = [f for f in jpg_path.iterdir() if is_jpg(f)] for raw_file in raw_path.iterdir(): - if not raw_file.is_file(): - continue - - if raw_file.suffix.lower() not in RAW_EXTENSIONS: + if not is_raw(raw_file): continue if not should_move(raw_file, jpg_files): diff --git a/tests/image/test_file_types.py b/tests/image/test_file_types.py new file mode 100644 index 0000000..bb7a456 --- /dev/null +++ b/tests/image/test_file_types.py @@ -0,0 +1,118 @@ +from pathlib import Path + +import pytest + +from photo_tools.image.raw_utils import is_jpg, is_raw + + +@pytest.mark.parametrize( + ("filename"), + [ + "image.cr2", + "image.CR2", + "image.cr3", + "image.nef", + "image.NeF", + "image.arw", + "image.raf", + "image.orf", + "image.rw2", + "image.dng", + "image.pef", + "image.srw", + "image.x3f", + ], +) +def test_is_raw_returns_true_for_supported_raw_files( + tmp_path: Path, + filename: str, +) -> None: + file_path = tmp_path / filename + file_path.touch() + + assert is_raw(file_path) is True + + +@pytest.mark.parametrize( + ("filename"), + [ + "image.jpg", + "image.jpeg", + "image.png", + "image.txt", + "image", + ], +) +def test_is_raw_returns_false_for_non_raw_files( + tmp_path: Path, + filename: str, +) -> None: + file_path = tmp_path / filename + file_path.touch() + + assert is_raw(file_path) is False + + +def test_is_raw_returns_false_for_directory(tmp_path: Path) -> None: + dir_path = tmp_path / "image.cr2" + dir_path.mkdir() + + assert is_raw(dir_path) is False + + +def test_is_raw_returns_false_for_non_existing_path(tmp_path: Path) -> None: + file_path = tmp_path / "missing.cr2" + + assert is_raw(file_path) is False + + +@pytest.mark.parametrize( + ("filename"), + [ + "image.jpg", + "image.jpeg", + "image.JPG", + "image.JPEG", + ], +) +def test_is_jpg_returns_true_for_supported_jpg_files( + tmp_path: Path, + filename: str, +) -> None: + file_path = tmp_path / filename + file_path.touch() + + assert is_jpg(file_path) is True + + +@pytest.mark.parametrize( + ("filename"), + [ + "image.cr2", + "image.nef", + "image.png", + "image.txt", + "image", + ], +) +def test_is_jpg_returns_false_for_non_jpg_files( + tmp_path: Path, + filename: str, +) -> None: + file_path = tmp_path / filename + file_path.touch() + + assert is_jpg(file_path) is False + + +def test_is_jpg_returns_false_for_directory(tmp_path: Path) -> None: + dir_path = tmp_path / "image.jpg" + dir_path.mkdir() + + assert is_jpg(dir_path) is False + + +def test_is_jpg_returns_false_for_non_existing_path(tmp_path: Path) -> None: + file_path = tmp_path / "missing.jpg" + + assert is_jpg(file_path) is False