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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
PHOTO_TOOLS_DEBUG=1
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ __pycache__/
*.egg-info/
data
dist/
build/
build/
.env
39 changes: 16 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,17 +65,22 @@ Each command provides detailed usage, including arguments and options:
photo-tools <command> --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 <command> ... --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
Expand All @@ -87,11 +92,9 @@ photo-tools by-date <INPUT_DIR> <OUTPUT_DIR>
```shell
photo-tools by-date <INPUT_DIR> <OUTPUT_DIR> --suffix <SUFFIX>
```
```shell
photo-tools by-date <INPUT_DIR> <OUTPUT_DIR> --dry-run
```

#### raws

### `raws`

- Move RAW images into a `raws/` subfolder within the input directory
- Non-RAW files are left unchanged
Expand All @@ -101,11 +104,8 @@ photo-tools by-date <INPUT_DIR> <OUTPUT_DIR> --dry-run
```shell
photo-tools raws <INPUT_DIR>
```
```shell
photo-tools raws <INPUT_DIR> --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`)
Expand All @@ -115,11 +115,7 @@ photo-tools raws <INPUT_DIR> --dry-run
photo-tools clean-raws <RAW_DIR> <JPG_DIR>
```

```shell
photo-tools clean-raws <RAW_DIR> <JPG_DIR> --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%`)
Expand All @@ -130,9 +126,6 @@ photo-tools clean-raws <RAW_DIR> <JPG_DIR> --dry-run
photo-tools optimise <INPUT_DIR>
```

```shell
photo-tools optimise <INPUT_DIR> --dry-run
```

## Local Development Setup

Expand Down
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
41 changes: 36 additions & 5 deletions src/photo_tools/clean_unpaired_raws.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
Expand All @@ -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()
Expand All @@ -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",
)
60 changes: 53 additions & 7 deletions src/photo_tools/cli.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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__":
Expand Down
17 changes: 17 additions & 0 deletions src/photo_tools/cli_reporter.py
Original file line number Diff line number Diff line change
@@ -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
10 changes: 7 additions & 3 deletions src/photo_tools/logging_config.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading