Skip to content
Merged
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
18 changes: 5 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,17 +90,18 @@ photo-tools organise-by-date <INPUT_DIR> <OUTPUT_DIR> --suffix <SUFFIX>
photo-tools organise-by-date <INPUT_DIR> <OUTPUT_DIR> --dry-run
```

#### organise-by-type
#### separate-raws

- Organise images into `raws/` and `jpgs/` subfolders within the input directory
- Move RAW images into a `raws/` subfolder within the input directory
- Non-RAW files are left unchanged
- Files are moved (not copied) in place
- If a destination file already exists, it is skipped (no overwrite)

```shell
photo-tools organise-by-type <INPUT_DIR>
photo-tools separate-raws <INPUT_DIR>
```
```shell
photo-tools organise-by-type <INPUT_DIR> --dry-run
photo-tools separate-raws <INPUT_DIR> --dry-run
```

#### soft-delete-unpaired-raws
Expand Down Expand Up @@ -164,15 +165,6 @@ This project uses `pytest`:
pytest
```

### Test coverage

The testing pattern is fully implemented for organise-by-date, including:
- dry-run safety
- file movement
- edge cases (unsupported files, missing metadata, collisions)

Other commands follow the same structure but are not yet covered.

### Makefile

Common development tasks are available via the Makefile.
17 changes: 10 additions & 7 deletions src/photo_tools/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from photo_tools.logging_config import setup_logging
from photo_tools.optimise import optimise
from photo_tools.organise_by_date import organise_by_date
from photo_tools.organise_by_type import organise_by_type
from photo_tools.separate_raws import separate_raws
from photo_tools.soft_delete_unpaired_raws import soft_delete_unpaired_raws

app = typer.Typer(help="CLI tools for organising and optimising photography workflows.")
Expand Down Expand Up @@ -49,20 +49,23 @@ def organise_by_date_cmd(
organise_by_date(input_dir, output_dir, suffix, dry_run)


@app.command("organise-by-type")
def organise_by_type_cmd(
@app.command(
"separate-raws",
help="Move RAW images into a 'raws' folder",
)
def separate_raws_cmd(
input_dir: str = typer.Argument(
...,
help="Directory containing images to organise by type (JPG and RAF supported)",
help="Directory containing images from which RAW files should be separated.",
),
dry_run: bool = typer.Option(
False,
"--dry-run",
help="Preview changes without moving files",
help="Preview changes without moving files.",
),
) -> None:
"""Organise images into 'jpgs' and 'raws' folders (supports .jpg/.jpeg and .raf)."""
organise_by_type(input_dir, dry_run)
"""Move RAW images into a 'raws' folder."""
separate_raws(input_dir, dry_run)


@app.command(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,41 +7,33 @@
logger = logging.getLogger(__name__)

RAW_EXTENSIONS = {".raf"}
JPG_EXTENSIONS = {".jpg", ".jpeg"}


def organise_by_type(input_dir: str, dry_run: bool = False) -> None:
def separate_raws(input_dir: str, dry_run: bool = False) -> None:
input_path = Path(input_dir)

validate_input_dir(input_path)

raws_dir = input_path / "raws"
jpgs_dir = input_path / "jpgs"

for file_path in input_path.iterdir():
if not file_path.is_file():
continue

ext = file_path.suffix.lower()

if ext in RAW_EXTENSIONS:
target_dir = raws_dir
elif ext in JPG_EXTENSIONS:
target_dir = jpgs_dir
else:
logger.debug(f"Skipping (unsupported): {file_path.name}")
if file_path.suffix.lower() not in RAW_EXTENSIONS:
logger.debug(f"Skipping (not RAW): {file_path.name}")
continue

target_file = target_dir / file_path.name
target_file = raws_dir / file_path.name

if target_file.exists():
logger.info(f"Skipping (already exists): {target_file.name}")
continue

if dry_run:
logger.info(f"[DRY RUN] Would move {file_path.name} → {target_dir}")
logger.info(f"[DRY RUN] Would move {file_path.name} → {raws_dir}")
continue

target_dir.mkdir(parents=True, exist_ok=True)
raws_dir.mkdir(parents=True, exist_ok=True)
shutil.move(str(file_path), str(target_file))
logger.info(f"Moved {file_path.name} → {target_dir}")
logger.info(f"Moved {file_path.name} → {raws_dir}")
109 changes: 109 additions & 0 deletions tests/test_separate_raws.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
from photo_tools.separate_raws import separate_raws


def test_dry_run_does_not_move_raw_files(tmp_path):
input_dir = tmp_path / "input"
input_dir.mkdir()

raw_file = input_dir / "photo.raf"
raw_file.write_text("fake raw content")

separate_raws(str(input_dir), dry_run=True)

assert raw_file.exists()
assert not (input_dir / "raws" / "photo.raf").exists()


def test_moves_raw_file_into_raws_folder(tmp_path):
input_dir = tmp_path / "input"
input_dir.mkdir()

raw_file = input_dir / "photo.raf"
raw_file.write_text("fake raw content")

separate_raws(str(input_dir), dry_run=False)

moved_file = input_dir / "raws" / "photo.raf"

assert not raw_file.exists()
assert moved_file.exists()


def test_leaves_non_raw_files_in_place(tmp_path):
input_dir = tmp_path / "input"
input_dir.mkdir()

jpg_file = input_dir / "photo.jpg"
txt_file = input_dir / "notes.txt"

jpg_file.write_text("fake jpg content")
txt_file.write_text("notes")

separate_raws(str(input_dir), dry_run=False)

assert jpg_file.exists()
assert txt_file.exists()
assert not (input_dir / "raws" / "photo.jpg").exists()
assert not (input_dir / "raws" / "notes.txt").exists()


def test_moves_only_raw_files(tmp_path):
input_dir = tmp_path / "input"
input_dir.mkdir()

raw_file = input_dir / "photo.raf"
jpg_file = input_dir / "photo.jpg"

raw_file.write_text("fake raw content")
jpg_file.write_text("fake jpg content")

separate_raws(str(input_dir), dry_run=False)

assert (input_dir / "raws" / "photo.raf").exists()
assert jpg_file.exists()
assert not raw_file.exists()


def test_skips_file_when_destination_already_exists(tmp_path):
input_dir = tmp_path / "input"
raws_dir = input_dir / "raws"

input_dir.mkdir()
raws_dir.mkdir()

source_file = input_dir / "photo.raf"
existing_file = raws_dir / "photo.raf"

source_file.write_text("new raw")
existing_file.write_text("existing raw")

separate_raws(str(input_dir), dry_run=False)

assert source_file.exists()
assert existing_file.exists()
assert existing_file.read_text() == "existing raw"


def test_handles_empty_input_directory(tmp_path):
input_dir = tmp_path / "input"
input_dir.mkdir()

separate_raws(str(input_dir), dry_run=False)

assert not any(input_dir.iterdir())


def test_ignores_nested_directories(tmp_path):
input_dir = tmp_path / "input"
nested_dir = input_dir / "nested"

input_dir.mkdir()
nested_dir.mkdir()

nested_raw = nested_dir / "photo.raf"
nested_raw.write_text("fake raw content")

separate_raws(str(input_dir), dry_run=False)

assert nested_raw.exists()
assert not (input_dir / "raws" / "photo.raf").exists()
Loading