photo-tools-cli 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- photo_tools_cli-0.1.0/PKG-INFO +169 -0
- photo_tools_cli-0.1.0/README.md +159 -0
- photo_tools_cli-0.1.0/pyproject.toml +42 -0
- photo_tools_cli-0.1.0/setup.cfg +4 -0
- photo_tools_cli-0.1.0/src/photo_tools/__init__.py +0 -0
- photo_tools_cli-0.1.0/src/photo_tools/cli.py +164 -0
- photo_tools_cli-0.1.0/src/photo_tools/cli_support/__init__.py +0 -0
- photo_tools_cli-0.1.0/src/photo_tools/cli_support/cli_errors.py +22 -0
- photo_tools_cli-0.1.0/src/photo_tools/cli_support/cli_reporter.py +17 -0
- photo_tools_cli-0.1.0/src/photo_tools/commands/__init__.py +0 -0
- photo_tools_cli-0.1.0/src/photo_tools/commands/clean_unpaired_raws.py +89 -0
- photo_tools_cli-0.1.0/src/photo_tools/commands/optimise.py +90 -0
- photo_tools_cli-0.1.0/src/photo_tools/commands/organise_by_date.py +105 -0
- photo_tools_cli-0.1.0/src/photo_tools/commands/separate_raws.py +74 -0
- photo_tools_cli-0.1.0/src/photo_tools/core/__init__.py +0 -0
- photo_tools_cli-0.1.0/src/photo_tools/core/dependencies.py +19 -0
- photo_tools_cli-0.1.0/src/photo_tools/core/validation.py +9 -0
- photo_tools_cli-0.1.0/src/photo_tools/exceptions.py +26 -0
- photo_tools_cli-0.1.0/src/photo_tools/image/__init__.py +0 -0
- photo_tools_cli-0.1.0/src/photo_tools/image/metadata.py +32 -0
- photo_tools_cli-0.1.0/src/photo_tools/image/optimisation.py +62 -0
- photo_tools_cli-0.1.0/src/photo_tools/logging_config.py +15 -0
- photo_tools_cli-0.1.0/src/photo_tools_cli.egg-info/PKG-INFO +169 -0
- photo_tools_cli-0.1.0/src/photo_tools_cli.egg-info/SOURCES.txt +32 -0
- photo_tools_cli-0.1.0/src/photo_tools_cli.egg-info/dependency_links.txt +1 -0
- photo_tools_cli-0.1.0/src/photo_tools_cli.egg-info/entry_points.txt +2 -0
- photo_tools_cli-0.1.0/src/photo_tools_cli.egg-info/requires.txt +3 -0
- photo_tools_cli-0.1.0/src/photo_tools_cli.egg-info/top_level.txt +1 -0
- photo_tools_cli-0.1.0/tests/test_clean_unpaired_raws.py +218 -0
- photo_tools_cli-0.1.0/tests/test_cli.py +82 -0
- photo_tools_cli-0.1.0/tests/test_optimise.py +96 -0
- photo_tools_cli-0.1.0/tests/test_organise_by_date.py +198 -0
- photo_tools_cli-0.1.0/tests/test_separate_raws.py +141 -0
- photo_tools_cli-0.1.0/tests/test_validation.py +18 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: photo-tools-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python CLI tools for photography workflows
|
|
5
|
+
Requires-Python: >=3.13
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: typer<1.0,>=0.24
|
|
8
|
+
Requires-Dist: pillow<13.0,>=12.1
|
|
9
|
+
Requires-Dist: python-dotenv<2.0,>=1.2
|
|
10
|
+
|
|
11
|
+
[](https://github.com/aga87/python-photo-tools/actions)
|
|
12
|
+
|
|
13
|
+
# Python Photo Tools
|
|
14
|
+
|
|
15
|
+
Command-line tools for organising photos by date, managing RAW/JPG pairs, and optimising images.
|
|
16
|
+
|
|
17
|
+
## Supported formats
|
|
18
|
+
|
|
19
|
+
- RAW: .raf
|
|
20
|
+
- JPG: .jpg, .jpeg
|
|
21
|
+
|
|
22
|
+
## Prerequisites - System Tools
|
|
23
|
+
|
|
24
|
+
This project depends on external system tools in addition to Python.
|
|
25
|
+
|
|
26
|
+
### ExifTool
|
|
27
|
+
|
|
28
|
+
Used for extracting image metadata (including RAW formats).
|
|
29
|
+
- Supports all major image formats, including RAW
|
|
30
|
+
- Provides consistent metadata fields across formats
|
|
31
|
+
- More reliable than Python-only EXIF libraries
|
|
32
|
+
|
|
33
|
+
If `exiftool` is not installed, the CLI will fail with a clear error message and exit code `1`.
|
|
34
|
+
|
|
35
|
+
Install (macOS)
|
|
36
|
+
|
|
37
|
+
```shell
|
|
38
|
+
brew install exiftool
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
On Linux, install via your package manager (e.g. `apt install exiftool`).
|
|
42
|
+
|
|
43
|
+
## Installation
|
|
44
|
+
|
|
45
|
+
### Using pipx (recommended)
|
|
46
|
+
|
|
47
|
+
```shell
|
|
48
|
+
pipx install git+https://github.com/aga87/python-photo-tools.git
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Local development
|
|
52
|
+
|
|
53
|
+
Clone the repository and install:
|
|
54
|
+
|
|
55
|
+
```shell
|
|
56
|
+
pip install -e .
|
|
57
|
+
pip install --group dev -e .
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Usage
|
|
61
|
+
|
|
62
|
+
List available commands:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
photo-tools --help
|
|
66
|
+
```
|
|
67
|
+
Each command provides detailed usage, including arguments and options:
|
|
68
|
+
|
|
69
|
+
```shell
|
|
70
|
+
photo-tools <command> --help
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**Note: All commands currently process files in the top-level input directory only.**
|
|
74
|
+
|
|
75
|
+
### Global flags
|
|
76
|
+
|
|
77
|
+
All commands support:
|
|
78
|
+
|
|
79
|
+
- `--dry-run` — preview changes without modifying files
|
|
80
|
+
- `--verbose` / `-v` — show per-file output
|
|
81
|
+
|
|
82
|
+
Flags can be combined:
|
|
83
|
+
|
|
84
|
+
```shell
|
|
85
|
+
photo-tools <command> ... --dry-run --verbose
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### `by-date`
|
|
89
|
+
|
|
90
|
+
- Organise images into date-based folders (`YYYY-MM-DD`, optional suffix)
|
|
91
|
+
- Files are moved (not copied) into the output directory
|
|
92
|
+
- If a destination file already exists, it is skipped (no overwrite)
|
|
93
|
+
|
|
94
|
+
```shell
|
|
95
|
+
photo-tools by-date <INPUT_DIR> <OUTPUT_DIR>
|
|
96
|
+
```
|
|
97
|
+
```shell
|
|
98
|
+
photo-tools by-date <INPUT_DIR> <OUTPUT_DIR> --suffix <SUFFIX>
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
### `raws`
|
|
103
|
+
|
|
104
|
+
- Move RAW images into a `raws/` subfolder within the input directory
|
|
105
|
+
- Non-RAW files are left unchanged
|
|
106
|
+
- Files are moved (not copied) in place
|
|
107
|
+
- If a destination file already exists, it is skipped (no overwrite)
|
|
108
|
+
|
|
109
|
+
```shell
|
|
110
|
+
photo-tools raws <INPUT_DIR>
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### `clean-raws`
|
|
114
|
+
|
|
115
|
+
- Move RAW files to `raws-to-delete/` if no matching JPG (same prefix) exists
|
|
116
|
+
- Matching is based on filename prefix (e.g. `abcd.RAF` matches `abcd_edit.jpg`)
|
|
117
|
+
- Files are moved (not deleted), making the operation reversible
|
|
118
|
+
|
|
119
|
+
```shell
|
|
120
|
+
photo-tools clean-raws <RAW_DIR> <JPG_DIR>
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### `optimise`
|
|
124
|
+
|
|
125
|
+
- Resize images to a maximum width of `2500px`
|
|
126
|
+
- Choose the highest quality that results in a file size ≤ `500 KB` (never below `70%`)
|
|
127
|
+
- Saves optimised images with prefix `lq_` in the same directory (overwrites existing files)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
```shell
|
|
131
|
+
photo-tools optimise <INPUT_DIR>
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
## Local Development Setup
|
|
136
|
+
|
|
137
|
+
### Data
|
|
138
|
+
|
|
139
|
+
For convenience during development, you can create a local structure:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
mkdir -p data/input data/output
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Place test photos in:
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
data/input/
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Running the CLI
|
|
152
|
+
|
|
153
|
+
You can run the CLI module directly for testing:
|
|
154
|
+
|
|
155
|
+
```shell
|
|
156
|
+
python -m photo_tools.cli by-date ./data/input ./data/output
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Running tests
|
|
160
|
+
|
|
161
|
+
This project uses `pytest`:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
pytest
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Makefile
|
|
168
|
+
|
|
169
|
+
Common development tasks are available via the Makefile.
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
[](https://github.com/aga87/python-photo-tools/actions)
|
|
2
|
+
|
|
3
|
+
# Python Photo Tools
|
|
4
|
+
|
|
5
|
+
Command-line tools for organising photos by date, managing RAW/JPG pairs, and optimising images.
|
|
6
|
+
|
|
7
|
+
## Supported formats
|
|
8
|
+
|
|
9
|
+
- RAW: .raf
|
|
10
|
+
- JPG: .jpg, .jpeg
|
|
11
|
+
|
|
12
|
+
## Prerequisites - System Tools
|
|
13
|
+
|
|
14
|
+
This project depends on external system tools in addition to Python.
|
|
15
|
+
|
|
16
|
+
### ExifTool
|
|
17
|
+
|
|
18
|
+
Used for extracting image metadata (including RAW formats).
|
|
19
|
+
- Supports all major image formats, including RAW
|
|
20
|
+
- Provides consistent metadata fields across formats
|
|
21
|
+
- More reliable than Python-only EXIF libraries
|
|
22
|
+
|
|
23
|
+
If `exiftool` is not installed, the CLI will fail with a clear error message and exit code `1`.
|
|
24
|
+
|
|
25
|
+
Install (macOS)
|
|
26
|
+
|
|
27
|
+
```shell
|
|
28
|
+
brew install exiftool
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
On Linux, install via your package manager (e.g. `apt install exiftool`).
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
### Using pipx (recommended)
|
|
36
|
+
|
|
37
|
+
```shell
|
|
38
|
+
pipx install git+https://github.com/aga87/python-photo-tools.git
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Local development
|
|
42
|
+
|
|
43
|
+
Clone the repository and install:
|
|
44
|
+
|
|
45
|
+
```shell
|
|
46
|
+
pip install -e .
|
|
47
|
+
pip install --group dev -e .
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
List available commands:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
photo-tools --help
|
|
56
|
+
```
|
|
57
|
+
Each command provides detailed usage, including arguments and options:
|
|
58
|
+
|
|
59
|
+
```shell
|
|
60
|
+
photo-tools <command> --help
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**Note: All commands currently process files in the top-level input directory only.**
|
|
64
|
+
|
|
65
|
+
### Global flags
|
|
66
|
+
|
|
67
|
+
All commands support:
|
|
68
|
+
|
|
69
|
+
- `--dry-run` — preview changes without modifying files
|
|
70
|
+
- `--verbose` / `-v` — show per-file output
|
|
71
|
+
|
|
72
|
+
Flags can be combined:
|
|
73
|
+
|
|
74
|
+
```shell
|
|
75
|
+
photo-tools <command> ... --dry-run --verbose
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### `by-date`
|
|
79
|
+
|
|
80
|
+
- Organise images into date-based folders (`YYYY-MM-DD`, optional suffix)
|
|
81
|
+
- Files are moved (not copied) into the output directory
|
|
82
|
+
- If a destination file already exists, it is skipped (no overwrite)
|
|
83
|
+
|
|
84
|
+
```shell
|
|
85
|
+
photo-tools by-date <INPUT_DIR> <OUTPUT_DIR>
|
|
86
|
+
```
|
|
87
|
+
```shell
|
|
88
|
+
photo-tools by-date <INPUT_DIR> <OUTPUT_DIR> --suffix <SUFFIX>
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
### `raws`
|
|
93
|
+
|
|
94
|
+
- Move RAW images into a `raws/` subfolder within the input directory
|
|
95
|
+
- Non-RAW files are left unchanged
|
|
96
|
+
- Files are moved (not copied) in place
|
|
97
|
+
- If a destination file already exists, it is skipped (no overwrite)
|
|
98
|
+
|
|
99
|
+
```shell
|
|
100
|
+
photo-tools raws <INPUT_DIR>
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### `clean-raws`
|
|
104
|
+
|
|
105
|
+
- Move RAW files to `raws-to-delete/` if no matching JPG (same prefix) exists
|
|
106
|
+
- Matching is based on filename prefix (e.g. `abcd.RAF` matches `abcd_edit.jpg`)
|
|
107
|
+
- Files are moved (not deleted), making the operation reversible
|
|
108
|
+
|
|
109
|
+
```shell
|
|
110
|
+
photo-tools clean-raws <RAW_DIR> <JPG_DIR>
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### `optimise`
|
|
114
|
+
|
|
115
|
+
- Resize images to a maximum width of `2500px`
|
|
116
|
+
- Choose the highest quality that results in a file size ≤ `500 KB` (never below `70%`)
|
|
117
|
+
- Saves optimised images with prefix `lq_` in the same directory (overwrites existing files)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
```shell
|
|
121
|
+
photo-tools optimise <INPUT_DIR>
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
## Local Development Setup
|
|
126
|
+
|
|
127
|
+
### Data
|
|
128
|
+
|
|
129
|
+
For convenience during development, you can create a local structure:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
mkdir -p data/input data/output
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Place test photos in:
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
data/input/
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Running the CLI
|
|
142
|
+
|
|
143
|
+
You can run the CLI module directly for testing:
|
|
144
|
+
|
|
145
|
+
```shell
|
|
146
|
+
python -m photo_tools.cli by-date ./data/input ./data/output
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Running tests
|
|
150
|
+
|
|
151
|
+
This project uses `pytest`:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
pytest
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Makefile
|
|
158
|
+
|
|
159
|
+
Common development tasks are available via the Makefile.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "photo-tools-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python CLI tools for photography workflows"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.13"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"typer>=0.24,<1.0",
|
|
13
|
+
"pillow>=12.1,<13.0",
|
|
14
|
+
"python-dotenv>=1.2,<2.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[dependency-groups]
|
|
18
|
+
dev = [
|
|
19
|
+
"pytest>=9,<10",
|
|
20
|
+
"ruff>=0.15,<1.0",
|
|
21
|
+
"mypy>=1.19,<2.0",
|
|
22
|
+
"build>=1.4,<2.0",
|
|
23
|
+
"twine>=6.2,<7.0",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
photo-tools = "photo_tools.cli:app"
|
|
28
|
+
|
|
29
|
+
[tool.setuptools]
|
|
30
|
+
package-dir = { "" = "src" }
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.packages.find]
|
|
33
|
+
where = ["src"]
|
|
34
|
+
|
|
35
|
+
[tool.ruff.lint]
|
|
36
|
+
select = ["E", "F", "I"]
|
|
37
|
+
|
|
38
|
+
[tool.mypy]
|
|
39
|
+
python_version = "3.13"
|
|
40
|
+
strict = true
|
|
41
|
+
mypy_path = "src"
|
|
42
|
+
explicit_package_bases = true
|
|
File without changes
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from dotenv import load_dotenv
|
|
3
|
+
|
|
4
|
+
from photo_tools.cli_support.cli_errors import handle_cli_errors
|
|
5
|
+
from photo_tools.cli_support.cli_reporter import make_reporter
|
|
6
|
+
from photo_tools.commands.clean_unpaired_raws import clean_unpaired_raws
|
|
7
|
+
from photo_tools.commands.optimise import optimise
|
|
8
|
+
from photo_tools.commands.organise_by_date import organise_by_date
|
|
9
|
+
from photo_tools.commands.separate_raws import separate_raws
|
|
10
|
+
from photo_tools.core.dependencies import validate_feature
|
|
11
|
+
from photo_tools.exceptions import MissingDependencyError
|
|
12
|
+
from photo_tools.logging_config import setup_logging
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(help="CLI tools for organising and optimising photography workflows.")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
load_dotenv()
|
|
18
|
+
setup_logging()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.callback()
|
|
22
|
+
def main() -> None:
|
|
23
|
+
try:
|
|
24
|
+
# validate dependencies needed globally (if any)
|
|
25
|
+
validate_feature("exif")
|
|
26
|
+
except MissingDependencyError as e:
|
|
27
|
+
typer.secho(f"Error: {e}", fg=typer.colors.RED, err=True)
|
|
28
|
+
raise typer.Exit(code=1)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.command("by-date")
|
|
32
|
+
@handle_cli_errors
|
|
33
|
+
def organise_by_date_cmd(
|
|
34
|
+
input_dir: str = typer.Argument(
|
|
35
|
+
...,
|
|
36
|
+
help="Directory containing input images.",
|
|
37
|
+
),
|
|
38
|
+
output_dir: str = typer.Argument(
|
|
39
|
+
...,
|
|
40
|
+
help="Directory where organised images will be saved.",
|
|
41
|
+
),
|
|
42
|
+
suffix: str | None = typer.Option(
|
|
43
|
+
None,
|
|
44
|
+
"--suffix",
|
|
45
|
+
help="Optional suffix appended to folder names (e.g. location).",
|
|
46
|
+
),
|
|
47
|
+
dry_run: bool = typer.Option(
|
|
48
|
+
False,
|
|
49
|
+
"--dry-run",
|
|
50
|
+
help="Preview changes without moving files.",
|
|
51
|
+
),
|
|
52
|
+
verbose: bool = typer.Option(
|
|
53
|
+
False,
|
|
54
|
+
"--verbose",
|
|
55
|
+
"-v",
|
|
56
|
+
help="Show per-file output.",
|
|
57
|
+
),
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Organise images into folders based on capture date."""
|
|
60
|
+
organise_by_date(
|
|
61
|
+
input_dir=input_dir,
|
|
62
|
+
output_dir=output_dir,
|
|
63
|
+
report=make_reporter(verbose),
|
|
64
|
+
suffix=suffix,
|
|
65
|
+
dry_run=dry_run,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.command(
|
|
70
|
+
"raws",
|
|
71
|
+
help="Move RAW images into a 'raws' folder",
|
|
72
|
+
)
|
|
73
|
+
@handle_cli_errors
|
|
74
|
+
def separate_raws_cmd(
|
|
75
|
+
input_dir: str = typer.Argument(
|
|
76
|
+
...,
|
|
77
|
+
help="Directory containing images from which RAW files should be separated.",
|
|
78
|
+
),
|
|
79
|
+
dry_run: bool = typer.Option(
|
|
80
|
+
False,
|
|
81
|
+
"--dry-run",
|
|
82
|
+
help="Preview changes without moving files.",
|
|
83
|
+
),
|
|
84
|
+
verbose: bool = typer.Option(
|
|
85
|
+
False,
|
|
86
|
+
"--verbose",
|
|
87
|
+
"-v",
|
|
88
|
+
help="Show per-file output.",
|
|
89
|
+
),
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Move RAW images into a 'raws' folder."""
|
|
92
|
+
separate_raws(
|
|
93
|
+
input_dir=input_dir,
|
|
94
|
+
report=make_reporter(verbose),
|
|
95
|
+
dry_run=dry_run,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@app.command(
|
|
100
|
+
"clean-raws",
|
|
101
|
+
help="Move RAW files without matching JPGs to 'raws-to-delete'.",
|
|
102
|
+
)
|
|
103
|
+
@handle_cli_errors
|
|
104
|
+
def clean_unpaired_raws_cmd(
|
|
105
|
+
raw_dir: str = typer.Argument(
|
|
106
|
+
...,
|
|
107
|
+
help="Directory containing RAW files.",
|
|
108
|
+
),
|
|
109
|
+
jpg_dir: str = typer.Argument(
|
|
110
|
+
...,
|
|
111
|
+
help="Directory containing JPG files used for matching.",
|
|
112
|
+
),
|
|
113
|
+
dry_run: bool = typer.Option(
|
|
114
|
+
False,
|
|
115
|
+
"--dry-run",
|
|
116
|
+
help="Preview changes without moving files.",
|
|
117
|
+
),
|
|
118
|
+
verbose: bool = typer.Option(
|
|
119
|
+
False,
|
|
120
|
+
"--verbose",
|
|
121
|
+
"-v",
|
|
122
|
+
help="Show per-file output.",
|
|
123
|
+
),
|
|
124
|
+
) -> None:
|
|
125
|
+
clean_unpaired_raws(
|
|
126
|
+
raw_dir=raw_dir,
|
|
127
|
+
jpg_dir=jpg_dir,
|
|
128
|
+
report=make_reporter(verbose),
|
|
129
|
+
dry_run=dry_run,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@app.command(
|
|
134
|
+
"optimise",
|
|
135
|
+
help="Resize JPG images to max 2500px width and compress to ≤500KB using quality "
|
|
136
|
+
"70-100, saving as prefixed copies.",
|
|
137
|
+
)
|
|
138
|
+
@handle_cli_errors
|
|
139
|
+
def optimise_cmd(
|
|
140
|
+
input_dir: str = typer.Argument(
|
|
141
|
+
...,
|
|
142
|
+
help="Directory containing JPG images to optimise.",
|
|
143
|
+
),
|
|
144
|
+
dry_run: bool = typer.Option(
|
|
145
|
+
False,
|
|
146
|
+
"--dry-run",
|
|
147
|
+
help="Show resulting size and quality without writing files.",
|
|
148
|
+
),
|
|
149
|
+
verbose: bool = typer.Option(
|
|
150
|
+
False,
|
|
151
|
+
"--verbose",
|
|
152
|
+
"-v",
|
|
153
|
+
help="Show per-file output.",
|
|
154
|
+
),
|
|
155
|
+
) -> None:
|
|
156
|
+
optimise(
|
|
157
|
+
input_dir=input_dir,
|
|
158
|
+
report=make_reporter(verbose),
|
|
159
|
+
dry_run=dry_run,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
if __name__ == "__main__":
|
|
164
|
+
app()
|
|
File without changes
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
from typing import Callable, ParamSpec, TypeVar
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
P = ParamSpec("P")
|
|
7
|
+
R = TypeVar("R")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# Decorator to handle common CLI errors and present clean messages to the user.
|
|
11
|
+
# Keeps core logic free of CLI concerns
|
|
12
|
+
def handle_cli_errors(func: Callable[P, R]) -> Callable[P, R]:
|
|
13
|
+
# Preserve original function metadata so Typer can correctly parse arguments
|
|
14
|
+
@functools.wraps(func)
|
|
15
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
16
|
+
try:
|
|
17
|
+
return func(*args, **kwargs)
|
|
18
|
+
except (FileNotFoundError, NotADirectoryError) as e:
|
|
19
|
+
typer.echo(f"Error: {e}")
|
|
20
|
+
raise typer.Exit(code=1)
|
|
21
|
+
|
|
22
|
+
return wrapper
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
Reporter = Callable[[str, str], None]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def make_reporter(verbose: bool) -> Reporter:
|
|
9
|
+
def report(level: str, message: str) -> None:
|
|
10
|
+
if level == "warning":
|
|
11
|
+
typer.secho(message, fg=typer.colors.YELLOW, err=True)
|
|
12
|
+
elif level == "summary":
|
|
13
|
+
typer.echo(message)
|
|
14
|
+
elif level == "info" and verbose:
|
|
15
|
+
typer.echo(message)
|
|
16
|
+
|
|
17
|
+
return report
|
|
File without changes
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import shutil
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from photo_tools.core.validation import validate_input_dir
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
RAW_EXTENSIONS = {".raf"}
|
|
11
|
+
JPG_EXTENSIONS = {".jpg", ".jpeg"}
|
|
12
|
+
|
|
13
|
+
Reporter = Callable[[str, str], None]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def clean_unpaired_raws(
|
|
17
|
+
raw_dir: str,
|
|
18
|
+
jpg_dir: str,
|
|
19
|
+
report: Reporter,
|
|
20
|
+
dry_run: bool = False,
|
|
21
|
+
) -> None:
|
|
22
|
+
raw_path = Path(raw_dir)
|
|
23
|
+
jpg_path = Path(jpg_dir)
|
|
24
|
+
trash_dir = raw_path / "raws-to-delete"
|
|
25
|
+
|
|
26
|
+
validate_input_dir(raw_path)
|
|
27
|
+
validate_input_dir(jpg_path)
|
|
28
|
+
|
|
29
|
+
moved_count = 0
|
|
30
|
+
dry_run_count = 0
|
|
31
|
+
skipped_existing_count = 0
|
|
32
|
+
|
|
33
|
+
jpg_files = [
|
|
34
|
+
f
|
|
35
|
+
for f in jpg_path.iterdir()
|
|
36
|
+
if f.is_file() and f.suffix.lower() in JPG_EXTENSIONS
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
for raw_file in raw_path.iterdir():
|
|
40
|
+
if not raw_file.is_file():
|
|
41
|
+
continue
|
|
42
|
+
|
|
43
|
+
if raw_file.suffix.lower() not in RAW_EXTENSIONS:
|
|
44
|
+
continue
|
|
45
|
+
|
|
46
|
+
raw_stem = raw_file.stem.lower()
|
|
47
|
+
has_match = any(jpg.name.lower().startswith(raw_stem) for jpg in jpg_files)
|
|
48
|
+
|
|
49
|
+
if has_match:
|
|
50
|
+
logger.debug("Keeping %s (matched JPG)", raw_file.name)
|
|
51
|
+
continue
|
|
52
|
+
|
|
53
|
+
target_file = trash_dir / raw_file.name
|
|
54
|
+
|
|
55
|
+
if target_file.exists():
|
|
56
|
+
skipped_existing_count += 1
|
|
57
|
+
report(
|
|
58
|
+
"warning",
|
|
59
|
+
f"Skipping {raw_file.name}: already in raws-to-delete",
|
|
60
|
+
)
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
if dry_run:
|
|
64
|
+
dry_run_count += 1
|
|
65
|
+
report(
|
|
66
|
+
"info",
|
|
67
|
+
f"[DRY RUN] Would move {raw_file.name} -> {trash_dir}",
|
|
68
|
+
)
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
trash_dir.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
shutil.move(str(raw_file), str(target_file))
|
|
73
|
+
moved_count += 1
|
|
74
|
+
|
|
75
|
+
report("info", f"Moved {raw_file.name} -> {trash_dir}")
|
|
76
|
+
|
|
77
|
+
# Summary
|
|
78
|
+
|
|
79
|
+
if dry_run:
|
|
80
|
+
report("summary", f"Dry run complete: would move {dry_run_count} file(s)")
|
|
81
|
+
else:
|
|
82
|
+
report("summary", f"Moved {moved_count} file(s)")
|
|
83
|
+
|
|
84
|
+
if skipped_existing_count:
|
|
85
|
+
report(
|
|
86
|
+
"warning",
|
|
87
|
+
f"Skipped {skipped_existing_count} file(s): "
|
|
88
|
+
"already exist in raws-to-delete",
|
|
89
|
+
)
|