From 91b5e391656b9fba53a6a5b60def52b208f36c86 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 14 Sep 2025 13:57:39 +0000 Subject: [PATCH 1/3] Initial plan From bb20cc23d3c33af55668dfce0a08e7fbce83805a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 14 Sep 2025 14:00:56 +0000 Subject: [PATCH 2/3] Initial exploration and environment setup Co-authored-by: fedem-p <56798675+fedem-p@users.noreply.github.com> --- lint.sh | 0 poetry.lock | 50 ++++++++++++++++++++++++++++++++++++++++++-------- pyproject.toml | 2 +- test.sh | 0 4 files changed, 43 insertions(+), 9 deletions(-) mode change 100644 => 100755 lint.sh mode change 100644 => 100755 test.sh diff --git a/lint.sh b/lint.sh old mode 100644 new mode 100755 diff --git a/poetry.lock b/poetry.lock index c3189d6..55f9924 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.0 and should not be changed by hand. [[package]] name = "astroid" @@ -6,6 +6,7 @@ version = "3.3.11" description = "An abstract syntax tree for Python with inference support." optional = false python-versions = ">=3.9.0" +groups = ["dev"] files = [ {file = "astroid-3.3.11-py3-none-any.whl", hash = "sha256:54c760ae8322ece1abd213057c4b5bba7c49818853fc901ef09719a60dbf9dec"}, {file = "astroid-3.3.11.tar.gz", hash = "sha256:1e5a5011af2920c7c67a53f65d536d65bfa7116feeaf2354d8b94f29573bb0ce"}, @@ -20,6 +21,7 @@ version = "24.10.0" description = "The uncompromising code formatter." optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "black-24.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6668650ea4b685440857138e5fe40cde4d652633b1bdffc62933d0db4ed9812"}, {file = "black-24.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1c536fcf674217e87b8cc3657b81809d3c085d7bf3ef262ead700da345bfa6ea"}, @@ -66,6 +68,7 @@ version = "2025.8.3" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"}, {file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"}, @@ -77,6 +80,7 @@ version = "3.4.3" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72"}, {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe"}, @@ -165,6 +169,7 @@ version = "8.1.8" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, @@ -179,10 +184,12 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main", "dev"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {main = "platform_system == \"Windows\"", dev = "sys_platform == \"win32\" or platform_system == \"Windows\""} [[package]] name = "coverage" @@ -190,6 +197,7 @@ version = "7.10.6" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "coverage-7.10.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:70e7bfbd57126b5554aa482691145f798d7df77489a177a6bef80de78860a356"}, {file = "coverage-7.10.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e41be6f0f19da64af13403e52f2dec38bbc2937af54df8ecef10850ff8d35301"}, @@ -285,7 +293,7 @@ files = [ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} [package.extras] -toml = ["tomli"] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "dill" @@ -293,6 +301,7 @@ version = "0.4.0" description = "serialize all of Python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049"}, {file = "dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0"}, @@ -308,6 +317,8 @@ version = "1.3.0" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version < \"3.11\"" files = [ {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, @@ -325,6 +336,7 @@ version = "3.5.1" description = "Library to extract Exif information from digital camera image files." optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "exifread-3.5.1-py3-none-any.whl", hash = "sha256:e5426ce2857423ad401e575ea9d159dc97449dc041fb6e61b35109caea72c311"}, {file = "exifread-3.5.1.tar.gz", hash = "sha256:9f998f80d3062741c976dfc4fd033424bc40932937994e4d2181eb70c4b6aedd"}, @@ -340,6 +352,7 @@ version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" +groups = ["main"] files = [ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, @@ -354,6 +367,7 @@ version = "2.1.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, @@ -365,6 +379,7 @@ version = "6.0.1" description = "A Python utility / library to sort Python imports." optional = false python-versions = ">=3.9.0" +groups = ["dev"] files = [ {file = "isort-6.0.1-py3-none-any.whl", hash = "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615"}, {file = "isort-6.0.1.tar.gz", hash = "sha256:1cb5df28dfbc742e490c5e41bad6da41b805b0a8be7bc93cd0fb2a8a890ac450"}, @@ -380,6 +395,7 @@ version = "0.7.0" description = "McCabe checker, plugin for flake8" optional = false python-versions = ">=3.6" +groups = ["dev"] files = [ {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, @@ -391,6 +407,7 @@ version = "1.18.1" description = "Optional static typing for Python" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "mypy-1.18.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2761b6ae22a2b7d8e8607fb9b81ae90bc2e95ec033fd18fa35e807af6c657763"}, {file = "mypy-1.18.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5b10e3ea7f2eec23b4929a3fabf84505da21034a4f4b9613cda81217e92b74f3"}, @@ -451,6 +468,7 @@ version = "1.1.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, @@ -462,6 +480,7 @@ version = "25.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, @@ -473,6 +492,7 @@ version = "0.12.1" description = "Utility library for gitignore style pattern matching of file paths." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, @@ -484,6 +504,7 @@ version = "10.4.0" description = "Python Imaging Library (Fork)" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "pillow-10.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:4d9667937cfa347525b319ae34375c37b9ee6b525440f3ef48542fcf66f2731e"}, {file = "pillow-10.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:543f3dc61c18dafb755773efc89aae60d06b6596a63914107f75459cf984164d"}, @@ -572,7 +593,7 @@ docs = ["furo", "olefile", "sphinx (>=7.3)", "sphinx-copybutton", "sphinx-inline fpx = ["olefile"] mic = ["olefile"] tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] -typing = ["typing-extensions"] +typing = ["typing-extensions ; python_version < \"3.10\""] xmp = ["defusedxml"] [[package]] @@ -581,6 +602,7 @@ version = "4.4.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85"}, {file = "platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf"}, @@ -597,6 +619,7 @@ version = "1.6.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, @@ -612,6 +635,7 @@ version = "3.3.8" description = "python code static checker" optional = false python-versions = ">=3.9.0" +groups = ["dev"] files = [ {file = "pylint-3.3.8-py3-none-any.whl", hash = "sha256:7ef94aa692a600e82fabdd17102b73fc226758218c97473c7ad67bd4cb905d83"}, {file = "pylint-3.3.8.tar.gz", hash = "sha256:26698de19941363037e2937d3db9ed94fb3303fdadf7d98847875345a8bb6b05"}, @@ -622,7 +646,8 @@ astroid = ">=3.3.8,<=3.4.0.dev0" colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} dill = [ {version = ">=0.2", markers = "python_version < \"3.11\""}, - {version = ">=0.3.6", markers = "python_version >= \"3.11\""}, + {version = ">=0.3.7", markers = "python_version >= \"3.12\""}, + {version = ">=0.3.6", markers = "python_version == \"3.11\""}, ] isort = ">=4.2.5,<5.13 || >5.13,<7" mccabe = ">=0.6,<0.8" @@ -641,6 +666,7 @@ version = "7.4.4" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, @@ -663,6 +689,7 @@ version = "7.0.0" description = "Pytest plugin for measuring coverage." optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"}, {file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"}, @@ -682,6 +709,7 @@ version = "2.32.5" description = "Python HTTP for Humans." optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, @@ -703,6 +731,8 @@ version = "2.2.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" +groups = ["dev"] +markers = "python_version < \"3.11\"" files = [ {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, @@ -744,6 +774,7 @@ version = "0.13.3" description = "Style preserving TOML library" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0"}, {file = "tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1"}, @@ -755,6 +786,7 @@ version = "4.67.1" description = "Fast, Extensible Progress Meter" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2"}, {file = "tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2"}, @@ -776,6 +808,7 @@ version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, @@ -787,18 +820,19 @@ version = "2.5.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] [metadata] -lock-version = "2.0" -python-versions = ">=3.9,<3.12" -content-hash = "02b543a332fafa4de2dfa97f05fa108cf8dca9da328257119a9e16b729e4ff7e" +lock-version = "2.1" +python-versions = ">=3.9,<3.13" +content-hash = "6dab4a5eee12b7973df49bcf8e13e85b3a4237f05aa4ce7f535cbbce4d5f2aa0" diff --git a/pyproject.toml b/pyproject.toml index e040487..7cfe92c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "PhotoSort: Organize photos and videos by date and location." authors = ["Federico Puppo"] [tool.poetry.dependencies] -python = ">=3.9,<3.12" +python = ">=3.9,<3.13" exifread = "^3.0.0" pillow = "^10.0.0" tqdm = "^4.66.0" diff --git a/test.sh b/test.sh old mode 100644 new mode 100755 From f311f72916cb6a9b9133fcaf407d623111135f27 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 14 Sep 2025 14:05:51 +0000 Subject: [PATCH 3/3] Implement CLI entry point with argument parsing and comprehensive tests Co-authored-by: fedem-p <56798675+fedem-p@users.noreply.github.com> --- photosort/main.py | 170 ++++++++++++++++++++- photosort/tests/test_main.py | 285 +++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 3 files changed, 453 insertions(+), 4 deletions(-) create mode 100644 photosort/tests/test_main.py diff --git a/photosort/main.py b/photosort/main.py index f5ec023..2308a29 100644 --- a/photosort/main.py +++ b/photosort/main.py @@ -2,11 +2,175 @@ Entry point for PhotoSort CLI. """ +import argparse +import sys +from pathlib import Path +from typing import List, Optional -def main(): + +def create_parser() -> argparse.ArgumentParser: + """Create and configure the argument parser for PhotoSort CLI.""" + parser = argparse.ArgumentParser( + prog="photosort", + description="Organize photos and videos by date and location using metadata", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + photosort scan /path/to/photos # Scan and generate report + photosort organize /path/to/photos # Organize photos (default) + photosort /path/to/photos # Organize photos (default) + photosort organize /path/to/photos --dry-run # Preview changes + photosort organize /path/to/photos --output /sorted/photos + """, + ) + + # First, add the input path as a required positional argument + parser.add_argument( + "input_path", + type=Path, + help="Path to the input folder containing photos and videos", + ) + + # Add command as an option instead of positional with a default + parser.add_argument( + "--command", + "-c", + choices=["scan", "organize"], + default="organize", + help="Command to execute: 'scan' for report generation, " + "'organize' for sorting files (default: organize)", + ) + + parser.add_argument( + "--output", + "-o", + type=Path, + help="Output directory for organized files (default: input_path/sorted)", + ) + + parser.add_argument( + "--dry-run", + "-n", + action="store_true", + help="Preview changes without moving files", + ) + + parser.add_argument( + "--report-file", + "-r", + type=Path, + help="JSON file to save scan report " + "(default: input_path/photosort_report.json)", + ) + + parser.add_argument( + "--log-file", + "-l", + type=Path, + help="JSON file to save operation logs " + "(default: input_path/photosort_log.json)", + ) + + parser.add_argument( + "--verbose", + "-v", + action="store_true", + help="Enable verbose output", + ) + + # Add a scan subcommand parser for cleaner interface + parser.add_argument( + "--scan", + action="store_const", + const="scan", + dest="command", + help="Generate scan report (shorthand for --command scan)", + ) + + return parser + + +def validate_arguments(args: argparse.Namespace) -> List[str]: + """Validate parsed arguments and return list of error messages.""" + errors = [] + + # Validate input path + if not args.input_path.exists(): + errors.append(f"Input path does not exist: {args.input_path}") + elif not args.input_path.is_dir(): + errors.append(f"Input path is not a directory: {args.input_path}") + + # Validate output path for organize command + if args.command == "organize" and args.output: + if args.output.exists() and not args.output.is_dir(): + errors.append(f"Output path exists but is not a directory: {args.output}") + + # Set default values + if not args.output and args.command == "organize": + args.output = args.input_path / "sorted" + + if not args.report_file: + args.report_file = args.input_path / "photosort_report.json" + + if not args.log_file: + args.log_file = args.input_path / "photosort_log.json" + + return errors + + +def run_scan_command(args: argparse.Namespace) -> int: + """Execute the scan command to generate a report.""" + # Placeholder implementation - will connect to scanner module + print(f"Scanning files in: {args.input_path}") + print(f"Report will be saved to: {args.report_file}") + if args.verbose: + print("Verbose mode enabled") + + # Placeholder implementation + print("Scan command - Not yet implemented.") + return 0 + + +def run_organize_command(args: argparse.Namespace) -> int: + """Execute the organize command to sort files.""" + # Placeholder implementation - will connect to organizer module + print(f"Organizing files from: {args.input_path}") + print(f"Output directory: {args.output}") + if args.dry_run: + print("DRY RUN MODE - No files will be moved") + if args.verbose: + print("Verbose mode enabled") + + # Placeholder implementation + print("Organize command - Not yet implemented.") + return 0 + + +def main(argv: Optional[List[str]] = None) -> int: """Entry point for the PhotoSort command-line interface.""" - print("PhotoSort CLI - Not yet implemented.") + parser = create_parser() + args = parser.parse_args(argv) + + # Validate arguments + errors = validate_arguments(args) + if errors: + for error in errors: + print(f"Error: {error}", file=sys.stderr) + return 1 + + # Execute the appropriate command + try: + if args.command == "scan": + return run_scan_command(args) + + return run_organize_command(args) + except KeyboardInterrupt: + print("\nOperation cancelled by user.", file=sys.stderr) + return 130 + except Exception as e: # pylint: disable=broad-exception-caught + print(f"Unexpected error: {e}", file=sys.stderr) + return 1 if __name__ == "__main__": - main() + sys.exit(main()) diff --git a/photosort/tests/test_main.py b/photosort/tests/test_main.py new file mode 100644 index 0000000..5783cff --- /dev/null +++ b/photosort/tests/test_main.py @@ -0,0 +1,285 @@ +"""Test module for main CLI functionality.""" + +import argparse +import tempfile +from pathlib import Path +from unittest.mock import patch + +from photosort.main import create_parser, main, validate_arguments + + +class TestCreateParser: + """Test cases for create_parser function.""" + + def test_parser_creation(self): + """Test that parser is created successfully.""" + parser = create_parser() + assert isinstance(parser, argparse.ArgumentParser) + assert parser.prog == "photosort" + + def test_default_command(self): + """Test default command is organize.""" + parser = create_parser() + args = parser.parse_args(["/test/path"]) + assert args.command == "organize" + assert args.input_path == Path("/test/path") + + def test_scan_command(self): + """Test scan command parsing.""" + parser = create_parser() + args = parser.parse_args(["/test/path", "--command", "scan"]) + assert args.command == "scan" + assert args.input_path == Path("/test/path") + + # Test shorthand --scan + args = parser.parse_args(["/test/path", "--scan"]) + assert args.command == "scan" + assert args.input_path == Path("/test/path") + + def test_organize_command_explicit(self): + """Test explicit organize command parsing.""" + parser = create_parser() + args = parser.parse_args(["/test/path", "--command", "organize"]) + assert args.command == "organize" + assert args.input_path == Path("/test/path") + + def test_dry_run_flag(self): + """Test dry-run flag parsing.""" + parser = create_parser() + args = parser.parse_args(["--dry-run", "/test/path"]) + assert args.dry_run is True + + args = parser.parse_args(["-n", "/test/path"]) + assert args.dry_run is True + + def test_output_option(self): + """Test output option parsing.""" + parser = create_parser() + args = parser.parse_args(["--output", "/output/path", "/test/path"]) + assert args.output == Path("/output/path") + + args = parser.parse_args(["-o", "/output/path", "/test/path"]) + assert args.output == Path("/output/path") + + def test_verbose_flag(self): + """Test verbose flag parsing.""" + parser = create_parser() + args = parser.parse_args(["--verbose", "/test/path"]) + assert args.verbose is True + + args = parser.parse_args(["-v", "/test/path"]) + assert args.verbose is True + + def test_report_file_option(self): + """Test report file option parsing.""" + parser = create_parser() + args = parser.parse_args(["--report-file", "/report.json", "/test/path"]) + assert args.report_file == Path("/report.json") + + args = parser.parse_args(["-r", "/report.json", "/test/path"]) + assert args.report_file == Path("/report.json") + + def test_log_file_option(self): + """Test log file option parsing.""" + parser = create_parser() + args = parser.parse_args(["--log-file", "/log.json", "/test/path"]) + assert args.log_file == Path("/log.json") + + args = parser.parse_args(["-l", "/log.json", "/test/path"]) + assert args.log_file == Path("/log.json") + + +class TestValidateArguments: + """Test cases for validate_arguments function.""" + + def test_validate_nonexistent_input_path(self): + """Test validation fails for nonexistent input path.""" + args = argparse.Namespace( + command="scan", + input_path=Path("/nonexistent/path"), + output=None, + report_file=None, + log_file=None, + ) + errors = validate_arguments(args) + assert len(errors) == 1 + assert "Input path does not exist" in errors[0] + + def test_validate_input_path_not_directory(self): + """Test validation fails when input path is not a directory.""" + with tempfile.NamedTemporaryFile() as tmp_file: + args = argparse.Namespace( + command="scan", + input_path=Path(tmp_file.name), + output=None, + report_file=None, + log_file=None, + ) + errors = validate_arguments(args) + assert len(errors) == 1 + assert "Input path is not a directory" in errors[0] + + def test_validate_valid_input_path(self): + """Test validation passes for valid input path.""" + with tempfile.TemporaryDirectory() as tmp_dir: + args = argparse.Namespace( + command="scan", + input_path=Path(tmp_dir), + output=None, + report_file=None, + log_file=None, + ) + errors = validate_arguments(args) + assert len(errors) == 0 + + def test_validate_sets_default_output_for_organize(self): + """Test validation sets default output path for organize command.""" + with tempfile.TemporaryDirectory() as tmp_dir: + args = argparse.Namespace( + command="organize", + input_path=Path(tmp_dir), + output=None, + report_file=None, + log_file=None, + ) + errors = validate_arguments(args) + assert len(errors) == 0 + assert args.output == Path(tmp_dir) / "sorted" + + def test_validate_sets_default_report_file(self): + """Test validation sets default report file path.""" + with tempfile.TemporaryDirectory() as tmp_dir: + args = argparse.Namespace( + command="scan", + input_path=Path(tmp_dir), + output=None, + report_file=None, + log_file=None, + ) + errors = validate_arguments(args) + assert len(errors) == 0 + assert args.report_file == Path(tmp_dir) / "photosort_report.json" + + def test_validate_sets_default_log_file(self): + """Test validation sets default log file path.""" + with tempfile.TemporaryDirectory() as tmp_dir: + args = argparse.Namespace( + command="scan", + input_path=Path(tmp_dir), + output=None, + report_file=None, + log_file=None, + ) + errors = validate_arguments(args) + assert len(errors) == 0 + assert args.log_file == Path(tmp_dir) / "photosort_log.json" + + def test_validate_output_path_exists_but_not_directory(self): + """Test validation fails when output path exists but is not a directory.""" + with tempfile.TemporaryDirectory() as tmp_dir, tempfile.NamedTemporaryFile() as tmp_file: + args = argparse.Namespace( + command="organize", + input_path=Path(tmp_dir), + output=Path(tmp_file.name), + report_file=None, + log_file=None, + ) + errors = validate_arguments(args) + assert len(errors) == 1 + assert "Output path exists but is not a directory" in errors[0] + + +class TestMainFunction: + """Test cases for main function.""" + + def test_main_with_invalid_input_path(self, capsys): + """Test main function with invalid input path.""" + result = main(["/nonexistent/path", "--scan"]) + assert result == 1 + captured = capsys.readouterr() + assert "Error: Input path does not exist" in captured.err + + def test_main_scan_command(self, capsys): + """Test main function with scan command.""" + with tempfile.TemporaryDirectory() as tmp_dir: + result = main([tmp_dir, "--command", "scan"]) + assert result == 0 + captured = capsys.readouterr() + assert f"Scanning files in: {tmp_dir}" in captured.out + assert "Scan command - Not yet implemented." in captured.out + + def test_main_organize_command(self, capsys): + """Test main function with organize command.""" + with tempfile.TemporaryDirectory() as tmp_dir: + result = main([tmp_dir, "--command", "organize"]) + assert result == 0 + captured = capsys.readouterr() + assert f"Organizing files from: {tmp_dir}" in captured.out + assert "Organize command - Not yet implemented." in captured.out + + def test_main_organize_command_dry_run(self, capsys): + """Test main function with organize command and dry-run.""" + with tempfile.TemporaryDirectory() as tmp_dir: + result = main([tmp_dir, "--command", "organize", "--dry-run"]) + assert result == 0 + captured = capsys.readouterr() + assert "DRY RUN MODE - No files will be moved" in captured.out + + def test_main_with_verbose_flag(self, capsys): + """Test main function with verbose flag.""" + with tempfile.TemporaryDirectory() as tmp_dir: + result = main([tmp_dir, "--scan", "--verbose"]) + assert result == 0 + captured = capsys.readouterr() + assert "Verbose mode enabled" in captured.out + + def test_main_default_command(self, capsys): + """Test main function with default command (organize).""" + with tempfile.TemporaryDirectory() as tmp_dir: + result = main([tmp_dir]) + assert result == 0 + captured = capsys.readouterr() + assert f"Organizing files from: {tmp_dir}" in captured.out + + def test_main_keyboard_interrupt(self, capsys): + """Test main function handling keyboard interrupt.""" + with tempfile.TemporaryDirectory() as tmp_dir: + with patch( + "photosort.main.run_scan_command", side_effect=KeyboardInterrupt + ): + result = main([tmp_dir, "--scan"]) + assert result == 130 + captured = capsys.readouterr() + assert "Operation cancelled by user." in captured.err + + def test_main_unexpected_exception(self, capsys): + """Test main function handling unexpected exception.""" + with tempfile.TemporaryDirectory() as tmp_dir: + with patch( + "photosort.main.run_scan_command", + side_effect=RuntimeError("Test error"), + ): + result = main([tmp_dir, "--scan"]) + assert result == 1 + captured = capsys.readouterr() + assert "Unexpected error: Test error" in captured.err + + def test_main_with_custom_output_path(self, capsys): + """Test main function with custom output path.""" + with tempfile.TemporaryDirectory() as tmp_dir: + custom_output = Path(tmp_dir) / "custom_output" + result = main( + [tmp_dir, "--command", "organize", "--output", str(custom_output)] + ) + assert result == 0 + captured = capsys.readouterr() + assert f"Output directory: {custom_output}" in captured.out + + def test_main_with_custom_report_file(self, capsys): + """Test main function with custom report file.""" + with tempfile.TemporaryDirectory() as tmp_dir: + custom_report = Path(tmp_dir) / "custom_report.json" + result = main([tmp_dir, "--scan", "--report-file", str(custom_report)]) + assert result == 0 + captured = capsys.readouterr() + assert f"Report will be saved to: {custom_report}" in captured.out diff --git a/pyproject.toml b/pyproject.toml index 7cfe92c..e040487 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "PhotoSort: Organize photos and videos by date and location." authors = ["Federico Puppo"] [tool.poetry.dependencies] -python = ">=3.9,<3.13" +python = ">=3.9,<3.12" exifread = "^3.0.0" pillow = "^10.0.0" tqdm = "^4.66.0"