diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c100f37..f6cfb9d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,11 +30,11 @@ jobs: with: changed-files: ${{ needs.get-changed-files.outputs.changed-files }} - # test: - # name: Test - # needs: - # - pre-commit - # uses: ./.github/workflows/test-action.yml + tests: + name: Tests + needs: + - pre-commit + uses: ./.github/workflows/test-action.yml docs: name: Docs @@ -52,7 +52,7 @@ jobs: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) needs: - docs - # - test + - tests build-python-package: name: Python Package @@ -68,6 +68,7 @@ jobs: uses: ./.github/workflows/deploy-package-action.yml if: ${{ inputs.release && success() }} needs: + - tests - docs - build-python-package with: @@ -81,7 +82,7 @@ jobs: runs-on: ubuntu-24.04 if: always() needs: - # - test + - tests - docs - deploy-docs - build-python-package diff --git a/.github/workflows/test-action.yml b/.github/workflows/test-action.yml new file mode 100644 index 0000000..5c7deb6 --- /dev/null +++ b/.github/workflows/test-action.yml @@ -0,0 +1,50 @@ +--- +name: Tests + +on: + workflow_call: + +jobs: + macOS: + strategy: + fail-fast: false + max-parallel: 4 + matrix: + os_version: [12, 13, 14, 15] + + runs-on: macos-${{ matrix.os_version }} + timeout-minutes: 10 + + steps: + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 + with: + fetch-depth: 2 + + - name: Install Nox + run: | + python -m pip install --upgrade pip + pip install 'nox[uv]>=2024.3' + + - name: Install Test Requirements + run: | + nox --force-color -e tests_all --install-only + + - name: Test + env: + SKIP_REQUIREMENTS_INSTALL: '1' + run: | + nox --force-color -e tests_all -- -vv --run-destructive + + - name: Set Exit Status + if: always() + run: | + mkdir exitstatus + echo "${{ job.status }}" > exitstatus/${{ github.job }}-macos${{ matrix.os_version }} + + - name: Upload Exit Status + if: always() + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + with: + name: exitstatus-${{ github.job }}-macos${{ matrix.os_version }} + path: exitstatus + if-no-files-found: error diff --git a/.gitignore b/.gitignore index cccad83..5786ff3 100644 --- a/.gitignore +++ b/.gitignore @@ -107,3 +107,4 @@ ENV/ src/dooti/version.py .nox/ +artifacts/ diff --git a/noxfile.py b/noxfile.py index 477cd3e..38b61db 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1,12 +1,22 @@ +# pylint: disable=missing-function-docstring,protected-access import os import shutil +import sys from importlib import metadata from pathlib import Path import nox +os.environ["PYTHONDONTWRITEBYTECODE"] = "1" + REPO_ROOT = Path(__file__).resolve().parent +ARTIFACTS_DIR = REPO_ROOT / "artifacts" +COVERAGE_REPORT_DB = REPO_ROOT / ".coverage" +COVERAGE_REPORT_PROJECT = "coverage-project.xml" +COVERAGE_REPORT_TESTS = "coverage-tests.xml" + SKIP_REQUIREMENTS_INSTALL = os.environ.get("SKIP_REQUIREMENTS_INSTALL", "0") == "1" +PYTHON_VERSIONS = ("3.10", "3.11", "3.12", "3.13") nox.options.reuse_existing_virtualenvs = True @@ -56,3 +66,84 @@ def docs_dev(session): shutil.rmtree(build_dir) session.run("sphinx-autobuild", *args) + + +@nox.session(python="3") +def tests(session): + return _tests(session) + + +@nox.session(python=PYTHON_VERSIONS) +def tests_all(session): + return _tests(session) + + +def _tests(session): # pylint: disable=too-many-branches + _install(session, "-e", ".[tests]") + + interpreter_version = session.python + if interpreter_version == "3": + interpreter_version += f".{sys.version_info.minor}" + + env = { + "COVERAGE_FILE": str(COVERAGE_REPORT_DB), + } + + args = [ + "--rootdir", + str(REPO_ROOT), + "--showlocals", + "-ra", + "-s", + ] + if session._runner.global_config.forcecolor: + args.append("--color=yes") + if not session.posargs: + args.append("tests/") + else: + for arg in session.posargs: + if arg.startswith("--color") and args[0].startswith("--color"): + args.pop(0) + args.append(arg) + for arg in session.posargs: + if arg.startswith("-"): + continue + if arg.startswith(f"tests{os.sep}"): + break + try: + Path(arg).resolve().relative_to(REPO_ROOT / "tests") + break + except ValueError: + continue + else: + args.append("tests/") + session.run("coverage", "erase") + try: + session.run("coverage", "run", "-m", "pytest", *args, env=env) + finally: + session.run( + "coverage", + "xml", + "-o", + str(ARTIFACTS_DIR / interpreter_version / COVERAGE_REPORT_PROJECT), + "--omit=tests/*", + "--include=src/dooti/*", + ) + session.run( + "coverage", + "xml", + "-o", + str(ARTIFACTS_DIR / interpreter_version / COVERAGE_REPORT_TESTS), + "--omit=src/dooti/*", + "--include=tests/*", + ) + try: + session.run( + "coverage", "report", "--show-missing", "--include=src/dooti/*,tests/*" + ) + finally: + if COVERAGE_REPORT_DB.exists(): + shutil.move( + str(COVERAGE_REPORT_DB), + str(ARTIFACTS_DIR / interpreter_version / COVERAGE_REPORT_DB.name), + ) diff --git a/pyproject.toml b/pyproject.toml index 1b7423a..4fac503 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,8 @@ lint = [ ] tests = [ "pytest>=7.2.0", + "coverage", + "pytest-skip-markers", ] [project.scripts] diff --git a/src/dooti/dooti.py b/src/dooti/dooti.py index 5345d65..da45f9a 100644 --- a/src/dooti/dooti.py +++ b/src/dooti/dooti.py @@ -131,8 +131,6 @@ def get_default_uti(self, uti: str | UTType) -> str | None: if not handler: return None - # name = handler.lastPathComponent()[:-4] - return handler.fileSystemRepresentation().decode() def get_default_ext(self, ext: str) -> str | None: @@ -152,8 +150,6 @@ def get_default_ext(self, ext: str) -> str | None: if not handler: return None - # name = handler.lastPathComponent()[:-4] - return handler.fileSystemRepresentation().decode() def get_default_scheme(self, scheme: str) -> str | None: @@ -166,15 +162,13 @@ def get_default_scheme(self, scheme: str) -> str | None: if "file" == scheme: raise ValueError("The file:// scheme cannot be looked up.") - url = NSURL.URLWithString_(scheme + "://nonexistant") + url = NSURL.URLWithString_(scheme + "://nonexistent") handler = self.workspace.URLForApplicationToOpenURL_(url) if not handler: return None - # name = handler.lastPathComponent()[:-4] - return handler.fileSystemRepresentation().decode() def get_app_path(self, app: str) -> NSURL: @@ -187,7 +181,7 @@ def get_app_path(self, app: str) -> NSURL: :raises: ApplicationNotFound: when no matching application was found """ - if "/" == app[0]: + if app[0] == "/": return NSURL.fileURLWithPath_(app) try: diff --git a/tests/functional/test_dooti.py b/tests/functional/test_dooti.py new file mode 100644 index 0000000..de8f449 --- /dev/null +++ b/tests/functional/test_dooti.py @@ -0,0 +1,257 @@ +import contextlib + +import pytest +from UniformTypeIdentifiers import UTType + +from dooti.dooti import ( + ApplicationNotFound, + BundleURLNotFound, + Dooti, + ExtHasNoRegisteredUTI, +) +from tests.helpers import get_ext_handler, get_scheme_handler + + +@pytest.fixture(scope="module") +def dooti(): + return Dooti() + + +@pytest.mark.parametrize( + "ext,expected", + ( + ("pdf", {"com.adobe.pdf"}), + ("fooobaar", {"dyn.age80q55tr7vgc2pw"}), + ("fooo.baar", False), + ), +) +def test_ext_to_utis(ext, expected): + utis = {str(uti) for uti in Dooti.ext_to_utis(ext)} + if expected: + assert expected <= utis + else: + assert not utis + + +@pytest.mark.destructive_test +@pytest.mark.parametrize( + "ext,uti,new", + ( + ("txt", "public.plain-text", "Script Editor"), + ( + "txt", + UTType.importedTypeWithIdentifier_("public.plain-text"), + "Script Editor", + ), + ), +) +def test_set_default_uti(dooti, ext, uti, new): + curr = get_ext_handler(ext) + new = dooti.get_app_path(new).path() + assert curr != new + try: + res = dooti.set_default_uti(uti, new) + assert res is None + assert get_ext_handler(ext) == new + finally: + dooti.set_default_uti(uti, curr) + assert get_ext_handler(ext) == curr + + +@pytest.mark.destructive_test +@pytest.mark.parametrize( + "scheme,new", + (("ftp", "Safari"),), +) +def test_set_default_scheme(dooti, scheme, new): + curr = get_scheme_handler(scheme) + new = dooti.get_app_path(new).path() + assert curr != new + try: + res = dooti.set_default_scheme(scheme, new) + assert res is None + assert get_scheme_handler(scheme) == new + finally: + dooti.set_default_scheme(scheme, curr) + assert get_scheme_handler(scheme) == curr + + +@pytest.mark.destructive_test +@pytest.mark.parametrize( + "ext,new", + (("txt", "Script Editor"),), +) +def test_set_default_ext(dooti, ext, new): + curr = get_ext_handler(ext) + new = dooti.get_app_path(new).path() + assert curr != new + try: + res = dooti.set_default_ext(ext, new) + assert res is None + assert get_ext_handler(ext) == new + finally: + dooti.set_default_ext(ext, curr) + assert get_ext_handler(ext) == curr + + +@pytest.mark.destructive_test +def test_set_default_scheme_dynamic(dooti): + with pytest.raises(ExtHasNoRegisteredUTI): + dooti.set_default_ext("fooobaaarr", "Preview") + + +@pytest.mark.parametrize( + "ext_or_uti,expected", + ( + ("pdf", False), + ("baaaaaz", True), + (UTType.importedTypeWithIdentifier_("com.adobe.pdf"), False), + (UTType.importedTypeWithIdentifier_("dyn.age80q55tr7vgc2pw"), True), + ), +) +def test_is_dynamic_uti(dooti, ext_or_uti, expected): + assert dooti.is_dynamic_uti(ext_or_uti) is expected + + +@pytest.mark.parametrize( + "uti,ext", + ( + (UTType.importedTypeWithIdentifier_("com.adobe.pdf"), "pdf"), + ("com.adobe.pdf", "pdf"), + ("org.fooo.baar", None), + ), +) +def test_get_default_uti(dooti, uti, ext): + res = dooti.get_default_uti(uti) + if ext: + assert res == get_ext_handler(ext) + else: + assert res is None + + +@pytest.mark.parametrize("ext,expected", (("pdf", True), ("fooobaaar", False))) +def test_get_default_ext(dooti, ext, expected): + res = dooti.get_default_ext(ext) + if expected: + assert res == get_ext_handler(ext) + else: + assert res is None + + +@pytest.mark.parametrize( + "scheme,expected", + ( + ("https", True), + ("fooobaaar", False), + ("file", pytest.raises(ValueError, match=".*cannot be looked up")), + ), +) +def test_get_default_scheme(dooti, scheme, expected): + if isinstance(expected, bool): + ctx = contextlib.nullcontext() + else: + ctx = expected + with ctx: + res = dooti.get_default_scheme(scheme) + if expected: + assert res == get_scheme_handler(scheme) + else: + assert res is None + + +@pytest.mark.parametrize( + "app,expected", + ( + ("Preview", "/System/Applications/Preview.app"), + ("com.apple.preview", "/System/Applications/Preview.app"), + ("/System/Applications/Preview.app", "/System/Applications/Preview.app"), + ( + "org.foo.baaar", + pytest.raises( + ApplicationNotFound, + match="Could not find an application matching.*org\\.foo\\.baaar.*", + ), + ), + ), +) +def test_get_app_path(dooti, app, expected): + if isinstance(expected, str): + ctx = contextlib.nullcontext() + else: + ctx = expected + with ctx: + assert dooti.get_app_path(app).path() == expected + + +@pytest.mark.parametrize( + "bundle,expected", + ( + ("com.apple.preview", "/System/Applications/Preview.app"), + ( + "org.foo.baaar", + pytest.raises( + BundleURLNotFound, match="There is no bundle.*org\\.foo\\.baaar.*" + ), + ), + ), +) +def test_bundle_to_url(dooti, bundle, expected): + if isinstance(expected, str): + ctx = contextlib.nullcontext() + else: + ctx = expected + with ctx: + assert dooti.bundle_to_url(bundle).path() == expected + + +@pytest.mark.parametrize( + "name,expected", + ( + ("Preview", "/System/Applications/Preview.app"), + ( + "this should not exist", + pytest.raises( + ApplicationNotFound, + match="Could not find an application named 'this should not exist'.*", + ), + ), + ), +) +def test_name_to_url(dooti, name, expected): + if isinstance(expected, str): + ctx = contextlib.nullcontext() + else: + ctx = expected + with ctx: + assert dooti.name_to_url(name).path() == expected + + +@pytest.mark.parametrize( + "path,skip_check,expected", + ( + ( + "/System/Applications/Preview.app", + False, + "/System/Applications/Preview.app", + ), + ( + "/System/Applications/foo bar.app", + False, + pytest.raises( + ApplicationNotFound, match="Could not find an application in '/System.*" + ), + ), + ( + "/System/Applications/foo bar.app", + True, + "/System/Applications/foo bar.app", + ), + ), +) +def test_path_to_url(dooti, path, skip_check, expected): + if isinstance(expected, str): + ctx = contextlib.nullcontext() + else: + ctx = expected + with ctx: + assert dooti.path_to_url(path, skip_check=skip_check).path() == expected diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..559d2bb --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,28 @@ +import subprocess +import tempfile + + +def get_scheme_handler(scheme): + return subprocess.check_output( + [ + "osascript", + "-l", + "JavaScript", + "-e", + f"ObjC.import('AppKit'); $.NSWorkspace.sharedWorkspace.URLForApplicationToOpenURL($.NSURL.URLWithString('{scheme}:')).path", + ], + text=True, + ).strip() + + +def get_ext_handler(ext): + with tempfile.NamedTemporaryFile(suffix=f".{ext}") as tmp: + alias = subprocess.check_output( + [ + "osascript", + "-e", + f'tell application "System Events" to get the default application of the file "{tmp.name}"', + ], + text=True, + ).strip() + return "/" + "/".join(alias.split(":")[1:-1])