diff --git a/.github/actions/python-test/action.yml b/.github/actions/python-test/action.yml new file mode 100644 index 0000000..56f23d2 --- /dev/null +++ b/.github/actions/python-test/action.yml @@ -0,0 +1,55 @@ +name: 'Python Test Action' +description: 'A custom action to run tests, lint, format, and typecheck a Python project.' +inputs: + project-path: + description: 'The path to the Python project.' + required: true +runs: + using: "composite" + steps: + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.13' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r ${{ inputs.project-path }}/requirements.txt + pip install ruff black mypy pytest pytest-cov coverage + shell: bash + + - name: Lint with ruff + continue-on-error: true + run: ruff check ${{ inputs.project-path }}/src ${{ inputs.project-path }}/tests + shell: bash + + - name: Format with black + run: black --check ${{ inputs.project-path }}/src ${{ inputs.project-path }}/tests + shell: bash + + - name: Typecheck with mypy + run: mypy ${{ inputs.project-path }}/src + shell: bash + + - name: Test with pytest + run: pytest --cov=${{ inputs.project-path }}/src ${{ inputs.project-path }}/tests + shell: bash + + - name: Combine coverage reports + run: coverage combine + shell: bash + working-directory: ${{ inputs.project-path }} + + - name: Generate coverage summary + run: coverage report --show-missing > coverage_summary.md + shell: bash + working-directory: ${{ inputs.project-path }} + + - name: Display coverage summary in job summary + run: | + echo "## Code Coverage Summary for $(echo ${{ inputs.project-path }} | cut -d '/' -f 2)" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + cat ${{ inputs.project-path }}/coverage_summary.md >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + shell: bash diff --git a/.github/workflows/file-organiser-test.yml b/.github/workflows/file-organiser-test.yml new file mode 100644 index 0000000..e6ae0d0 --- /dev/null +++ b/.github/workflows/file-organiser-test.yml @@ -0,0 +1,17 @@ +name: file-organiser-test + +on: + push: + branches-ignore: + - main + paths: + - "Beginner/file_organiser/**" + +jobs: + file_organiser: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: ./.github/actions/python-test + with: + project-path: Beginner/file_organiser diff --git a/Beginner/file_organiser/.coveragerc b/Beginner/file_organiser/.coveragerc new file mode 100644 index 0000000..32ab02a --- /dev/null +++ b/Beginner/file_organiser/.coveragerc @@ -0,0 +1,6 @@ +[run] +source = src +parallel = true + +[report] +show_missing = true diff --git a/Beginner/file_organiser/pyproject.toml b/Beginner/file_organiser/pyproject.toml index 16da9e9..02bfbf2 100644 --- a/Beginner/file_organiser/pyproject.toml +++ b/Beginner/file_organiser/pyproject.toml @@ -26,4 +26,5 @@ dependencies = [ "pytest>=8.4.2", "python-magic>=0.4.27", "rich>=14.2.0", -] \ No newline at end of file + "ruff>=0.14.2", +] diff --git a/Beginner/file_organiser/src/file_organiser.py b/Beginner/file_organiser/src/file_organiser.py index 463ca9f..d2aa796 100644 --- a/Beginner/file_organiser/src/file_organiser.py +++ b/Beginner/file_organiser/src/file_organiser.py @@ -15,7 +15,7 @@ def get_type(file_path: Path | str) -> str: # Using a path, return all files, using full Path and filename from Path.Walk() -def get_files(path: Path | str, recursive=False): +def get_files(path: Path | str, recursive=True): p = Path(path) if not p.exists(): # If the path doesn't exist, throw a error @@ -28,13 +28,13 @@ def get_files(path: Path | str, recursive=False): for dirpath, subdirs, files in p.walk(): for f in files: yield dirpath / f - if recursive: + if not recursive: break # This fuction calls the above 2 fuctions, to grab a list of files, and then fetching the file types, saving within a Counter -def process_files(path: Path | str, recursive=False): - counts = Counter() +def process_files(path: Path | str, recursive=True): + counts: Counter[str] = Counter() for f in track(get_files(path, recursive), description="Working...."): try: file_type = get_type(f) @@ -60,14 +60,14 @@ def parse_args(argv=None): ap.add_argument( "-n", "--no-recursive", - action="store_true", + action="store_false", help="Disable recursion if you add non-recursive mode", ) ap.add_argument("--list", action="store_true", help="(future) list files with detected MIME") return ap.parse_args(argv) -def main(argv=None) -> int: +def main(argv=None): args = parse_args(argv) if args.file: file_type = get_type(args.file) diff --git a/Beginner/file_organiser/tests/unit/test_file_organiser.py b/Beginner/file_organiser/tests/unit/test_file_organiser.py index fd1f777..91355ec 100644 --- a/Beginner/file_organiser/tests/unit/test_file_organiser.py +++ b/Beginner/file_organiser/tests/unit/test_file_organiser.py @@ -1,43 +1,68 @@ #!/usr/bin/env python3 +import subprocess +from collections import Counter +from pathlib import Path + import pytest -from file_organiser import main, get_files, get_type, process_files +from file_organiser import get_files, get_type, main, process_files -# Test main with args -def test_path(): - amount = main(["/mnt/c/Users/Adam/Desktop/adhd/"]) - assert amount > 0 +# Test main +def test_path(tmp_path): + """Test main with a directory containing one file, expecting 1 file to be processed.""" + (tmp_path / "file1.txt").touch() + amount = main([str(tmp_path)]) + assert amount == 1 -def test_no_path(): - amount = main([]) - assert amount > 0 +def test_main_empty_dir(tmp_path): + """Test main with an empty directory, should process 0 files.""" + amount = main([str(tmp_path)]) + assert amount == 0 -def test_no_path_recursive(): - amount = main(["--no-recursive"]) - assert amount > 0 +def test_main_no_path(tmp_path, monkeypatch): + """Test main with no path, which should default to the current directory.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "file1.txt").touch() + amount = main([]) + assert amount == 1 -def test_file_not_found(): - with pytest.raises(FileNotFoundError, match="does not exist"): - list(get_files("/tmp/file-org/not-here")) + +def test_main_file_not_found(): + """Test main with a non-existent file path.""" + with pytest.raises(FileNotFoundError): + main(["/non/existent/path"]) -def test_not_dir(): +def test_not_dir(tmp_path): + """Test main with a non-directory path.""" + file = tmp_path / "text.txt" + file.touch() with pytest.raises(NotADirectoryError, match="is not a directory"): - list(main(["/tmp/file-org/text.txt"])) + list(main([str(file)])) -def test_with_file(): - file_type = main(["--file", "/mnt/c/Users/Adam/Desktop/adhd/20250820_160657.jpg"]) +def test_with_file(tmp_path): + """Test main with a file path.""" + fake_jpeg = tmp_path / "test.jpg" + fake_jpeg.write_bytes(b"\xff\xd8\xff\xe0") + file_type = main(["--file", str(fake_jpeg)]) assert file_type == "image/jpeg" # Test get_files +def test_file_not_found(tmp_path): + """Test get_files with a non-existent file path.""" + with pytest.raises(FileNotFoundError, match="does not exist"): + list(get_files(tmp_path / "not-here")) + + def test_get_files_recursive(tmp_path): + """Test get_files with recursive mode enabled.""" # Create a dummy directory structure (tmp_path / "subdir1").mkdir() (tmp_path / "subdir2").mkdir() @@ -54,7 +79,7 @@ def test_get_files_recursive(tmp_path): def test_get_files_non_recursive(tmp_path): - # Create a dummy directory structure + """Test get_files with non-recursive mode enabled.""" (tmp_path / "subdir1").mkdir() (tmp_path / "subdir2").mkdir() (tmp_path / "file1.txt").touch() @@ -62,12 +87,20 @@ def test_get_files_non_recursive(tmp_path): (tmp_path / "subdir2" / "file3.pdf").touch() # Test non-recursive behavior - files = list(get_files(tmp_path, recursive=True)) + files = list(get_files(tmp_path, recursive=False)) assert len(files) == 1 assert tmp_path / "file1.txt" in files +def test_get_files_empty_dir(tmp_path): + """Test get_files with an empty directory.""" + files = list(get_files(tmp_path)) + assert len(files) == 0 + + +# Test process_files def test_process_files_recursive(tmp_path): + """Test process_files with recursive mode enabled.""" # Create dummy files with different types (tmp_path / "file1.txt").write_text("hello") (tmp_path / "file2.jpg").touch() # python-magic will likely identify this as empty data @@ -79,18 +112,70 @@ def test_process_files_recursive(tmp_path): assert counts["text/plain"] == 1 +def test_process_files_non_recursive(tmp_path): + """Test process_files with non-recursive mode enabled.""" + # Create dummy files + (tmp_path / "file1.txt").write_text("hello") + (tmp_path / "subdir").mkdir() + (tmp_path / "subdir" / "file2.txt").write_text("world") + + counts = process_files(tmp_path, recursive=False) + assert counts == Counter({"text/plain": 1}) + + +def test_process_files_exception(tmp_path, monkeypatch): + """Test process_files with an exception during file type retrieval.""" + (tmp_path / "file1.txt").touch() + + def mock_get_type(path): + raise Exception("Test exception") + + monkeypatch.setattr("file_organiser.get_type", mock_get_type) + + counts = process_files(tmp_path) + assert counts.total() == 0 + + # Test get_type -def test_get_type_image(): - type = get_type("/mnt/c/Users/Adam/Desktop/adhd/20250820_160657.jpg") +def test_get_type_image(tmp_path): + """Test get_type with an image file.""" + fake_jpeg = tmp_path / "test.jpg" + fake_jpeg.write_bytes(b"\xff\xd8\xff\xe0") + type = get_type(str(fake_jpeg)) assert type == "image/jpeg" -def test_get_type_with_temp_file(): +def test_get_type_with_temp_file(tmp_path): + """Test get_type with a temporary file.""" # Create a dummy txt file txt_content = b"This is a dummy text file." - txt_file = "/tmp/dummy.txt" + txt_file = tmp_path / "dummy.txt" with open(txt_file, "wb") as f: f.write(txt_content) # test with get_type type = get_type(txt_file) assert type == "text/plain" + + +def test_get_type_empty_file(tmp_path): + """Test get_type with an empty file.""" + empty_file = tmp_path / "empty.dat" + empty_file.touch() + file_type = get_type(empty_file) + assert file_type == "inode/x-empty" + + +def test_main_as_script(tmp_path): + """Test running the script as the main program.""" + (tmp_path / "file1.txt").touch() + + # Get the path to the Beginner/file_organiser directory + file_organiser_dir = Path(__file__).parent.parent.parent + + result = subprocess.run( + ["coverage", "run", "--append", "src/file_organiser.py", str(tmp_path)], + capture_output=True, + text=True, + cwd=str(file_organiser_dir), + ) + assert result.returncode == 1 diff --git a/Beginner/file_organiser/uv.lock b/Beginner/file_organiser/uv.lock index eba1849..1560110 100644 --- a/Beginner/file_organiser/uv.lock +++ b/Beginner/file_organiser/uv.lock @@ -19,6 +19,7 @@ dependencies = [ { name = "pytest" }, { name = "python-magic" }, { name = "rich" }, + { name = "ruff" }, ] [package.metadata] @@ -26,6 +27,7 @@ requires-dist = [ { name = "pytest", specifier = ">=8.4.2" }, { name = "python-magic", specifier = ">=0.4.27" }, { name = "rich", specifier = ">=14.2.0" }, + { name = "ruff", specifier = ">=0.14.2" }, ] [[package]] @@ -122,3 +124,29 @@ sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7b wheels = [ { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, ] + +[[package]] +name = "ruff" +version = "0.14.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/34/8218a19b2055b80601e8fd201ec723c74c7fe1ca06d525a43ed07b6d8e85/ruff-0.14.2.tar.gz", hash = "sha256:98da787668f239313d9c902ca7c523fe11b8ec3f39345553a51b25abc4629c96", size = 5539663, upload-time = "2025-10-23T19:37:00.956Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/dd/23eb2db5ad9acae7c845700493b72d3ae214dce0b226f27df89216110f2b/ruff-0.14.2-py3-none-linux_armv6l.whl", hash = "sha256:7cbe4e593505bdec5884c2d0a4d791a90301bc23e49a6b1eb642dd85ef9c64f1", size = 12533390, upload-time = "2025-10-23T19:36:18.044Z" }, + { url = "https://files.pythonhosted.org/packages/5a/8c/5f9acff43ddcf3f85130d0146d0477e28ccecc495f9f684f8f7119b74c0d/ruff-0.14.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8d54b561729cee92f8d89c316ad7a3f9705533f5903b042399b6ae0ddfc62e11", size = 12887187, upload-time = "2025-10-23T19:36:22.664Z" }, + { url = "https://files.pythonhosted.org/packages/99/fa/047646491479074029665022e9f3dc6f0515797f40a4b6014ea8474c539d/ruff-0.14.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5c8753dfa44ebb2cde10ce5b4d2ef55a41fb9d9b16732a2c5df64620dbda44a3", size = 11925177, upload-time = "2025-10-23T19:36:24.778Z" }, + { url = "https://files.pythonhosted.org/packages/15/8b/c44cf7fe6e59ab24a9d939493a11030b503bdc2a16622cede8b7b1df0114/ruff-0.14.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d0bbeffb8d9f4fccf7b5198d566d0bad99a9cb622f1fc3467af96cb8773c9e3", size = 12358285, upload-time = "2025-10-23T19:36:26.979Z" }, + { url = "https://files.pythonhosted.org/packages/45/01/47701b26254267ef40369aea3acb62a7b23e921c27372d127e0f3af48092/ruff-0.14.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7047f0c5a713a401e43a88d36843d9c83a19c584e63d664474675620aaa634a8", size = 12303832, upload-time = "2025-10-23T19:36:29.192Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5c/ae7244ca4fbdf2bee9d6405dcd5bc6ae51ee1df66eb7a9884b77b8af856d/ruff-0.14.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bf8d2f9aa1602599217d82e8e0af7fd33e5878c4d98f37906b7c93f46f9a839", size = 13036995, upload-time = "2025-10-23T19:36:31.861Z" }, + { url = "https://files.pythonhosted.org/packages/27/4c/0860a79ce6fd4c709ac01173f76f929d53f59748d0dcdd662519835dae43/ruff-0.14.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1c505b389e19c57a317cf4b42db824e2fca96ffb3d86766c1c9f8b96d32048a7", size = 14512649, upload-time = "2025-10-23T19:36:33.915Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7f/d365de998069720a3abfc250ddd876fc4b81a403a766c74ff9bde15b5378/ruff-0.14.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a307fc45ebd887b3f26b36d9326bb70bf69b01561950cdcc6c0bdf7bb8e0f7cc", size = 14088182, upload-time = "2025-10-23T19:36:36.983Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ea/d8e3e6b209162000a7be1faa41b0a0c16a133010311edc3329753cc6596a/ruff-0.14.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:61ae91a32c853172f832c2f40bd05fd69f491db7289fb85a9b941ebdd549781a", size = 13599516, upload-time = "2025-10-23T19:36:39.208Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ea/c7810322086db68989fb20a8d5221dd3b79e49e396b01badca07b433ab45/ruff-0.14.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1967e40286f63ee23c615e8e7e98098dedc7301568bd88991f6e544d8ae096", size = 13272690, upload-time = "2025-10-23T19:36:41.453Z" }, + { url = "https://files.pythonhosted.org/packages/a9/39/10b05acf8c45786ef501d454e00937e1b97964f846bf28883d1f9619928a/ruff-0.14.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2877f02119cdebf52a632d743a2e302dea422bfae152ebe2f193d3285a3a65df", size = 13496497, upload-time = "2025-10-23T19:36:43.61Z" }, + { url = "https://files.pythonhosted.org/packages/59/a1/1f25f8301e13751c30895092485fada29076e5e14264bdacc37202e85d24/ruff-0.14.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e681c5bc777de5af898decdcb6ba3321d0d466f4cb43c3e7cc2c3b4e7b843a05", size = 12266116, upload-time = "2025-10-23T19:36:45.625Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/0029bfc9ce16ae78164e6923ef392e5f173b793b26cc39aa1d8b366cf9dc/ruff-0.14.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e21be42d72e224736f0c992cdb9959a2fa53c7e943b97ef5d081e13170e3ffc5", size = 12281345, upload-time = "2025-10-23T19:36:47.618Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ab/ece7baa3c0f29b7683be868c024f0838770c16607bea6852e46b202f1ff6/ruff-0.14.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b8264016f6f209fac16262882dbebf3f8be1629777cf0f37e7aff071b3e9b92e", size = 12629296, upload-time = "2025-10-23T19:36:49.789Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7f/638f54b43f3d4e48c6a68062794e5b367ddac778051806b9e235dfb7aa81/ruff-0.14.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5ca36b4cb4db3067a3b24444463ceea5565ea78b95fe9a07ca7cb7fd16948770", size = 13371610, upload-time = "2025-10-23T19:36:51.882Z" }, + { url = "https://files.pythonhosted.org/packages/8d/35/3654a973ebe5b32e1fd4a08ed2d46755af7267da7ac710d97420d7b8657d/ruff-0.14.2-py3-none-win32.whl", hash = "sha256:41775927d287685e08f48d8eb3f765625ab0b7042cc9377e20e64f4eb0056ee9", size = 12415318, upload-time = "2025-10-23T19:36:53.961Z" }, + { url = "https://files.pythonhosted.org/packages/71/30/3758bcf9e0b6a4193a6f51abf84254aba00887dfa8c20aba18aa366c5f57/ruff-0.14.2-py3-none-win_amd64.whl", hash = "sha256:0df3424aa5c3c08b34ed8ce099df1021e3adaca6e90229273496b839e5a7e1af", size = 13565279, upload-time = "2025-10-23T19:36:56.578Z" }, + { url = "https://files.pythonhosted.org/packages/2e/5d/aa883766f8ef9ffbe6aa24f7192fb71632f31a30e77eb39aa2b0dc4290ac/ruff-0.14.2-py3-none-win_arm64.whl", hash = "sha256:ea9d635e83ba21569fbacda7e78afbfeb94911c9434aff06192d9bc23fd5495a", size = 12554956, upload-time = "2025-10-23T19:36:58.714Z" }, +]