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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ Command-line tools for organising photos by date, managing RAW/JPG pairs, and op

## 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

Expand Down
3 changes: 0 additions & 3 deletions src/photo_tools/commands/clean_unpaired_raws.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
2 changes: 0 additions & 2 deletions src/photo_tools/commands/keep_five_star_raws.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
7 changes: 2 additions & 5 deletions src/photo_tools/commands/optimise.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
13 changes: 2 additions & 11 deletions src/photo_tools/commands/organise_by_date.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]


Expand All @@ -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

Expand Down Expand Up @@ -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:
Expand Down
7 changes: 2 additions & 5 deletions src/photo_tools/commands/separate_raws.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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

Expand Down
25 changes: 25 additions & 0 deletions src/photo_tools/image/file_types.py
Original file line number Diff line number Diff line change
@@ -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
14 changes: 3 additions & 11 deletions src/photo_tools/image/raw_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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):
Expand Down
118 changes: 118 additions & 0 deletions tests/image/test_file_types.py
Original file line number Diff line number Diff line change
@@ -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
Loading