From d92bc2ef6ad6ce4a584c94395823f82c125758ac Mon Sep 17 00:00:00 2001 From: Philip Couling Date: Mon, 15 Jan 2024 17:02:11 +0000 Subject: [PATCH 1/2] First unit tests --- .github/workflows/pull_request.yaml | 8 +- .gitignore | 7 +- poetry.lock | 66 ++++++- pyproject.toml | 8 +- source/mkdocs_deploy/abstract.py | 30 ++- source/mkdocs_deploy/actions.py | 25 ++- .../mkdocs_deploy/plugins/local_filesystem.py | 20 +- source/mkdocs_deploy/py.typed | 0 source/mkdocs_deploy/versions.py | 2 +- source/tests/conftest.py | 10 + source/tests/mock_plugin.py | 117 +++++++++++ source/tests/mock_wrapper.py | 42 ++++ source/tests/test_modules/__init__.py | 0 source/tests/test_modules/test_actions.py | 186 ++++++++++++++++++ source/tests/test_stub.py | 2 - 15 files changed, 485 insertions(+), 38 deletions(-) create mode 100644 source/mkdocs_deploy/py.typed create mode 100644 source/tests/conftest.py create mode 100644 source/tests/mock_plugin.py create mode 100644 source/tests/mock_wrapper.py create mode 100644 source/tests/test_modules/__init__.py create mode 100644 source/tests/test_modules/test_actions.py delete mode 100644 source/tests/test_stub.py diff --git a/.github/workflows/pull_request.yaml b/.github/workflows/pull_request.yaml index a79237c..a8227ee 100644 --- a/.github/workflows/pull_request.yaml +++ b/.github/workflows/pull_request.yaml @@ -1,6 +1,9 @@ name: Pull Request on: - - pull_request + pull_request: {} + push: + branches: + - pytest jobs: test: @@ -26,3 +29,6 @@ jobs: - name: Pytest run: poetry run pytest + + #- name: MyPy + # run: mypy -m mkdocs_deploy diff --git a/.gitignore b/.gitignore index 1fab714..5032148 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,9 @@ venv .venv __pycache__ temp_example -dist \ No newline at end of file +dist +.gitignore +.mypy_cache +.pytest_cache +.coverage +htmlcov diff --git a/poetry.lock b/poetry.lock index 4ee34a9..9f5df8d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -213,6 +213,70 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "coverage" +version = "7.4.0" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "coverage-7.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:36b0ea8ab20d6a7564e89cb6135920bc9188fb5f1f7152e94e8300b7b189441a"}, + {file = "coverage-7.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0676cd0ba581e514b7f726495ea75aba3eb20899d824636c6f59b0ed2f88c471"}, + {file = "coverage-7.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0ca5c71a5a1765a0f8f88022c52b6b8be740e512980362f7fdbb03725a0d6b9"}, + {file = "coverage-7.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7c97726520f784239f6c62506bc70e48d01ae71e9da128259d61ca5e9788516"}, + {file = "coverage-7.4.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:815ac2d0f3398a14286dc2cea223a6f338109f9ecf39a71160cd1628786bc6f5"}, + {file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:80b5ee39b7f0131ebec7968baa9b2309eddb35b8403d1869e08f024efd883566"}, + {file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5b2ccb7548a0b65974860a78c9ffe1173cfb5877460e5a229238d985565574ae"}, + {file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:995ea5c48c4ebfd898eacb098164b3cc826ba273b3049e4a889658548e321b43"}, + {file = "coverage-7.4.0-cp310-cp310-win32.whl", hash = "sha256:79287fd95585ed36e83182794a57a46aeae0b64ca53929d1176db56aacc83451"}, + {file = "coverage-7.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:5b14b4f8760006bfdb6e08667af7bc2d8d9bfdb648351915315ea17645347137"}, + {file = "coverage-7.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:04387a4a6ecb330c1878907ce0dc04078ea72a869263e53c72a1ba5bbdf380ca"}, + {file = "coverage-7.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea81d8f9691bb53f4fb4db603203029643caffc82bf998ab5b59ca05560f4c06"}, + {file = "coverage-7.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74775198b702868ec2d058cb92720a3c5a9177296f75bd97317c787daf711505"}, + {file = "coverage-7.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76f03940f9973bfaee8cfba70ac991825611b9aac047e5c80d499a44079ec0bc"}, + {file = "coverage-7.4.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:485e9f897cf4856a65a57c7f6ea3dc0d4e6c076c87311d4bc003f82cfe199d25"}, + {file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6ae8c9d301207e6856865867d762a4b6fd379c714fcc0607a84b92ee63feff70"}, + {file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bf477c355274a72435ceb140dc42de0dc1e1e0bf6e97195be30487d8eaaf1a09"}, + {file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:83c2dda2666fe32332f8e87481eed056c8b4d163fe18ecc690b02802d36a4d26"}, + {file = "coverage-7.4.0-cp311-cp311-win32.whl", hash = "sha256:697d1317e5290a313ef0d369650cfee1a114abb6021fa239ca12b4849ebbd614"}, + {file = "coverage-7.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:26776ff6c711d9d835557ee453082025d871e30b3fd6c27fcef14733f67f0590"}, + {file = "coverage-7.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:13eaf476ec3e883fe3e5fe3707caeb88268a06284484a3daf8250259ef1ba143"}, + {file = "coverage-7.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846f52f46e212affb5bcf131c952fb4075b55aae6b61adc9856222df89cbe3e2"}, + {file = "coverage-7.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26f66da8695719ccf90e794ed567a1549bb2644a706b41e9f6eae6816b398c4a"}, + {file = "coverage-7.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:164fdcc3246c69a6526a59b744b62e303039a81e42cfbbdc171c91a8cc2f9446"}, + {file = "coverage-7.4.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:316543f71025a6565677d84bc4df2114e9b6a615aa39fb165d697dba06a54af9"}, + {file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bb1de682da0b824411e00a0d4da5a784ec6496b6850fdf8c865c1d68c0e318dd"}, + {file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:0e8d06778e8fbffccfe96331a3946237f87b1e1d359d7fbe8b06b96c95a5407a"}, + {file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a56de34db7b7ff77056a37aedded01b2b98b508227d2d0979d373a9b5d353daa"}, + {file = "coverage-7.4.0-cp312-cp312-win32.whl", hash = "sha256:51456e6fa099a8d9d91497202d9563a320513fcf59f33991b0661a4a6f2ad450"}, + {file = "coverage-7.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd3c1e4cb2ff0083758f09be0f77402e1bdf704adb7f89108007300a6da587d0"}, + {file = "coverage-7.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e9d1bf53c4c8de58d22e0e956a79a5b37f754ed1ffdbf1a260d9dcfa2d8a325e"}, + {file = "coverage-7.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:109f5985182b6b81fe33323ab4707011875198c41964f014579cf82cebf2bb85"}, + {file = "coverage-7.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cc9d4bc55de8003663ec94c2f215d12d42ceea128da8f0f4036235a119c88ac"}, + {file = "coverage-7.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cc6d65b21c219ec2072c1293c505cf36e4e913a3f936d80028993dd73c7906b1"}, + {file = "coverage-7.4.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a10a4920def78bbfff4eff8a05c51be03e42f1c3735be42d851f199144897ba"}, + {file = "coverage-7.4.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b8e99f06160602bc64da35158bb76c73522a4010f0649be44a4e167ff8555952"}, + {file = "coverage-7.4.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7d360587e64d006402b7116623cebf9d48893329ef035278969fa3bbf75b697e"}, + {file = "coverage-7.4.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:29f3abe810930311c0b5d1a7140f6395369c3db1be68345638c33eec07535105"}, + {file = "coverage-7.4.0-cp38-cp38-win32.whl", hash = "sha256:5040148f4ec43644702e7b16ca864c5314ccb8ee0751ef617d49aa0e2d6bf4f2"}, + {file = "coverage-7.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:9864463c1c2f9cb3b5db2cf1ff475eed2f0b4285c2aaf4d357b69959941aa555"}, + {file = "coverage-7.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:936d38794044b26c99d3dd004d8af0035ac535b92090f7f2bb5aa9c8e2f5cd42"}, + {file = "coverage-7.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:799c8f873794a08cdf216aa5d0531c6a3747793b70c53f70e98259720a6fe2d7"}, + {file = "coverage-7.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e7defbb9737274023e2d7af02cac77043c86ce88a907c58f42b580a97d5bcca9"}, + {file = "coverage-7.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a1526d265743fb49363974b7aa8d5899ff64ee07df47dd8d3e37dcc0818f09ed"}, + {file = "coverage-7.4.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf635a52fc1ea401baf88843ae8708591aa4adff875e5c23220de43b1ccf575c"}, + {file = "coverage-7.4.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:756ded44f47f330666843b5781be126ab57bb57c22adbb07d83f6b519783b870"}, + {file = "coverage-7.4.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:0eb3c2f32dabe3a4aaf6441dde94f35687224dfd7eb2a7f47f3fd9428e421058"}, + {file = "coverage-7.4.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bfd5db349d15c08311702611f3dccbef4b4e2ec148fcc636cf8739519b4a5c0f"}, + {file = "coverage-7.4.0-cp39-cp39-win32.whl", hash = "sha256:53d7d9158ee03956e0eadac38dfa1ec8068431ef8058fe6447043db1fb40d932"}, + {file = "coverage-7.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:cfd2a8b6b0d8e66e944d47cdec2f47c48fef2ba2f2dff5a9a75757f64172857e"}, + {file = "coverage-7.4.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:c530833afc4707fe48524a44844493f36d8727f04dcce91fb978c414a8556cc6"}, + {file = "coverage-7.4.0.tar.gz", hash = "sha256:707c0f58cb1712b8809ece32b68996ee1e609f71bd14615bd8f87a1293cb610e"}, +] + +[package.extras] +toml = ["tomli"] + [[package]] name = "exceptiongroup" version = "1.2.0" @@ -1162,4 +1226,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "467ce5291375ce920c4534c8cd92ea7d759c68ed9e292183562524935fef789e" +content-hash = "c71a51a2bc179484fade33e0e2a2ba83bbf5f871a5d31393637581eff653ce06" diff --git a/pyproject.toml b/pyproject.toml index 2a3a9bd..dddb7fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ mypy = "^1.8.0" [tool.poetry.group.dev.dependencies] build = "^0.10.0" +coverage = "^7.4.0" [tool.poetry-dynamic-versioning] enable = true @@ -60,4 +61,9 @@ built_site = "dist/mkdocs_site" [tool.pytest.ini_options] testpaths = [ "source/tests", -] \ No newline at end of file +] + +[tool.coverage.run] +source = [ + "source/mkdocs_deploy", +] diff --git a/source/mkdocs_deploy/abstract.py b/source/mkdocs_deploy/abstract.py index 310d63b..39962a8 100644 --- a/source/mkdocs_deploy/abstract.py +++ b/source/mkdocs_deploy/abstract.py @@ -1,6 +1,7 @@ import urllib.parse from abc import abstractmethod -from typing import Callable, IO, Iterable, Optional, Protocol, Union +from enum import Enum +from typing import Callable, IO, Iterable, Optional, Protocol from .versions import DeploymentAlias, DeploymentSpec @@ -13,6 +14,16 @@ class RedirectMechanismNotFound(Exception): pass +class _DefaultVersionType(Enum): + DEFAULT_VERSION = 0 + + +DEFAULT_VERSION = _DefaultVersionType.DEFAULT_VERSION + + +Version = str | _DefaultVersionType + + class Source(Protocol): """ Source is where a site is loaded from. @@ -39,6 +50,7 @@ def close(self) -> None: """ Close any underlying resource handles """ + return None def __enter__(self): """ @@ -78,7 +90,7 @@ def delete_version(self, version_id: str) -> None: """ @abstractmethod - def upload_file(self, version_id: Union[str, type(...)], filename: str, file_obj: IO[bytes]) -> None: + def upload_file(self, version_id: Version, filename: str, file_obj: IO[bytes]) -> None: """ Upload a file to the target @@ -89,7 +101,7 @@ def upload_file(self, version_id: Union[str, type(...)], filename: str, file_obj """ @abstractmethod - def download_file(self, version_id: Union[str, type(...)], filename: str) -> IO[bytes]: + def download_file(self, version_id: Version, filename: str) -> IO[bytes]: """ Open a file handle to read content of a file @@ -104,7 +116,7 @@ def download_file(self, version_id: Union[str, type(...)], filename: str) -> IO[ @abstractmethod - def delete_file(self, version_id: Union[str, type(...)], filename: str) -> None: + def delete_file(self, version_id: Version, filename: str) -> None: """ Delete a file, or mark it for deletion on close. :param version_id: The version to delete from @@ -132,7 +144,7 @@ def close(self, success: bool = False) -> None: """ @abstractmethod - def set_alias(self, alias_id: Union[str, type(...)], alias: Optional[DeploymentAlias]) -> None: + def set_alias(self, alias_id: Version, alias: Optional[DeploymentAlias]) -> None: """ Create or delete an alias. @@ -184,18 +196,18 @@ class RedirectMechanism(Protocol): """ @abstractmethod - def create_redirect(self, session: TargetSession, alias: Union[str, type(...)], version_id: str) -> None: + def create_redirect(self, session: TargetSession, alias: Version, version_id: str) -> None: """ Create a redirect :param session: The TargetSession to apply changes to - :param alias: The new alias to create. If ``...`` is passed, then a redirect from the root is created. IE: "" + :param alias: The new alias to create. If ``None`` is passed, then a redirect from the root is created. IE: "" defines what the default version is. :param version_id: The version to redirect to. """ @abstractmethod - def delete_redirect(self, session: TargetSession, alias: Union[str, type(...)]) -> None: + def delete_redirect(self, session: TargetSession, alias:Version) -> None: """ Delete the named redirect. @@ -203,7 +215,7 @@ def delete_redirect(self, session: TargetSession, alias: Union[str, type(...)]) :param alias: The alias to delete. ``...`` is the default redirect. """ - def refresh_redirect(self, session: TargetSession, alias: Union[str, type(...)], version_id: str) -> None: + def refresh_redirect(self, session: TargetSession, alias: Version, version_id: str) -> None: """ Called to ensure all redirects still work after a version has been altered. diff --git a/source/mkdocs_deploy/actions.py b/source/mkdocs_deploy/actions.py index 32a1099..c65378d 100644 --- a/source/mkdocs_deploy/actions.py +++ b/source/mkdocs_deploy/actions.py @@ -8,9 +8,9 @@ """ import importlib.metadata import logging -from typing import Iterable, Optional, Union +from typing import Iterable, Optional -from .abstract import Source, TargetSession, VersionNotFound +from .abstract import DEFAULT_VERSION, Source, TargetSession, Version, VersionNotFound from .versions import DeploymentAlias _logger = logging.getLogger(__name__) @@ -33,7 +33,7 @@ def load_plugins() -> None: -def upload(source: Source, target: TargetSession, version_id: str, title: Optional[str]) -> None: +def upload(source: Source, target: TargetSession, version_id: str, title: str | None) -> None: """ Upload a file (to s3) :param source: The site to upload. This may be a directory, or it may be zipped @@ -82,7 +82,7 @@ def delete_version(target: TargetSession, version_id: str) -> None: def create_alias( - target: TargetSession, alias_id: Union[str, type(...)], version: str, mechanisms: Optional[Iterable[str]] = None + target: TargetSession, alias_id: Version, version: str, mechanisms: Iterable[str] | None = None ) -> None: """ Create a new alias for a version. @@ -103,7 +103,7 @@ def create_alias( raise ValueError(f"LocalFileTreeTarget does not support redirect mechanism: {mechanism}") # Check if the alias already exists ... - # If mechanisms wasn't spefied use whatever is on the existing one. + # If mechanisms wasn't specified use whatever is on the existing one. deployment_spec = target.deployment_spec if alias_id in deployment_spec.versions: raise ValueError(f"Cannot create an alias with the same name as an existing version. " @@ -152,9 +152,7 @@ def create_alias( target.set_alias(alias_id, alias) -def delete_alias( - target: TargetSession, alias_id: Union[str, type(...)], mechanisms: Optional[Iterable[str]] = None -) -> None: +def delete_alias(target: TargetSession, alias_id: Version, mechanisms: Iterable[str] | None = None) -> None: """ Delete an alias. @@ -166,7 +164,7 @@ def delete_alias( :param mechanisms: Optional iterable of mechanisms to remove. """ _logger.info("Deleting alias %s mechanism %s", alias_id, "default" if mechanisms is None else list(mechanisms)) - if alias_id is ...: + if alias_id is DEFAULT_VERSION: alias = target.deployment_spec.default_version if alias is None: _logger.debug("Default alias not set") @@ -198,9 +196,7 @@ def delete_alias( target.set_alias(alias_id, None) -def refresh_alias( - target: TargetSession, alias_id: Union[str, type(...)], mechanisms: Optional[Iterable[str]] = None -) -> None: +def refresh_alias(target: TargetSession, alias_id: Version, mechanisms: Iterable[str] | None = None) -> None: """ Refresh redirects. @@ -211,14 +207,15 @@ def refresh_alias( :param mechanisms: Optional list of mechanisms to refresh. If None (default) all will be refreshed. """ _logger.info("Refreshing alias %s mechanisms %s", alias_id, "all" if mechanisms is None else list(mechanisms)) - if alias_id is ...: + if alias_id is DEFAULT_VERSION: alias = target.deployment_spec.default_version else: alias = target.deployment_spec.aliases.get(alias_id, None) if alias is None: _logger.warning("Cannot refresh alias %s, it doesn't exist", alias_id) + return if mechanisms is not None: - to_refresh = [mechanism for mechanism in mechanisms if mechanism in alias.redirect_mechanisms] + to_refresh = {mechanism for mechanism in mechanisms if mechanism in alias.redirect_mechanisms} else: to_refresh = alias.redirect_mechanisms available_mechanisms = target.available_redirect_mechanisms diff --git a/source/mkdocs_deploy/plugins/local_filesystem.py b/source/mkdocs_deploy/plugins/local_filesystem.py index 44639ea..3c972c3 100644 --- a/source/mkdocs_deploy/plugins/local_filesystem.py +++ b/source/mkdocs_deploy/plugins/local_filesystem.py @@ -55,6 +55,7 @@ def __init__(self, file_path: Union[Path, IO[bytes]], prefix: str = "site/"): self._tar_file = tarfile.open(name=file_path, mode="r") else: self._tar_file = tarfile.open(fileobj=file_path, mode="r") + self._file_path = file_path def iter_files(self) -> Iterable[str]: for file in self._tar_file.getmembers(): @@ -62,7 +63,9 @@ def iter_files(self) -> Iterable[str]: yield file.name[len(self._prefix):] def open_file_for_read(self, filename: str) -> IO[bytes]: - return self._tar_file.extractfile(self._prefix + filename) + result = self._tar_file.extractfile(self._prefix + filename) + if result is None: + raise RuntimeError(f"Requested file is not a regular file: {filename} in {self._file_path}") def close(self): self._tar_file.close() @@ -131,10 +134,9 @@ def open_file_obj_source(file: IO[bytes]) -> abstract.Source: def _path_from_url(url: str) -> Path: - url = urllib.parse.urlparse(url) - if url.path.startswith("/"): - return Path(url.path[1:]) - return Path(url.path) + if "://" in url: + return Path((url.path or "")[1:]) + return Path(url) class LocalFileTreeTargetSession(abstract.TargetSession): @@ -179,7 +181,8 @@ def close(self, success: bool = False) -> None: for file_name, content in shared_implementations.generate_meta_data(self._deployment_spec).items(): with open(self._path_for_file(..., file_name), "wb") as file: file.write(content) - +# PosixPath('/Users/philip/Documents/Development/MkdocsDeploy/private/var/folders/nb/9f9993hs2yg3gjs966_ltd8c0000gn/T/pytest-of-philip/pytest-16/test_upload0/mock_target') +# PosixPath('/Users/philip/Documents/Development/MkdocsDeploy/private/var/folders/nb/9f9993hs2yg3gjs966_ltd8c0000gn/T/pytest-of-philip/pytest-16/test_upload0/mock_target/deployments.json') def iter_files(self, version_id: str) -> Iterable[str]: def _iter_files(file_path: Path): try: @@ -257,8 +260,9 @@ def _path_for_file(self, version_id: Union[str, type(...)], filename: str = "") elif version_id not in self._deployment_spec.versions and version_id not in self._deployment_spec.aliases: raise abstract.VersionNotFound(version_id) result = Path(self._target_path, *filename.split("/")) - if result.relative_to(self._target_path).parts[0] == "..": - raise ValueError(f"Refusing to operate on the site: {result} not in {self._target_path}") + # Raise a ValueError if the result is above the base path + result.relative_to(self._target_path) + return result def _check_version_exists(self, version_id: Union[str, type(...)]) -> None: if version_id is ...: diff --git a/source/mkdocs_deploy/py.typed b/source/mkdocs_deploy/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/source/mkdocs_deploy/versions.py b/source/mkdocs_deploy/versions.py index 1355ba7..f1114fc 100644 --- a/source/mkdocs_deploy/versions.py +++ b/source/mkdocs_deploy/versions.py @@ -28,7 +28,7 @@ class DeploymentVersion(pydantic.BaseModel): @pydantic.root_validator() def _default_title(cls, values: dict): - if values["title"] is None: + if values.get("title", None) is None: values["title"] = values["version_id"] return values diff --git a/source/tests/conftest.py b/source/tests/conftest.py new file mode 100644 index 0000000..7e9eaf4 --- /dev/null +++ b/source/tests/conftest.py @@ -0,0 +1,10 @@ +import pytest + +from mkdocs_deploy import abstract + + +@pytest.fixture(autouse=True) +def _clean_plugins(monkeypatch: pytest.MonkeyPatch): + """Ensure that all tests run with uninitialized plugins""" + monkeypatch.setattr(abstract, "_SOURCES", {}) + monkeypatch.setattr(abstract, "_TARGETS", {}) diff --git a/source/tests/mock_plugin.py b/source/tests/mock_plugin.py new file mode 100644 index 0000000..56d98b1 --- /dev/null +++ b/source/tests/mock_plugin.py @@ -0,0 +1,117 @@ +import io +from copy import deepcopy +from io import BytesIO +from typing import IO, Iterable + +from mkdocs_deploy import abstract, versions +from mkdocs_deploy.abstract import TargetSession, Version +from mkdocs_deploy.versions import DeploymentAlias, DeploymentSpec + + +class BaseMockPlugin: + + files: dict[str, bytes] + + def __init__(self): + self.files = {} + + +class MockSource(abstract.Source): + + def __init__(self, files: dict[str, bytes] | None = None): + self.files = files.copy() if files is not None else {} + + def iter_files(self) -> Iterable[str]: + yield from self.files.keys() + + def open_file_for_read(self, filename: str) -> IO[bytes]: + return io.BytesIO(initial_bytes=self.files[filename]) + + +class MockRedirectMechanism(abstract.RedirectMechanism): + + def create_redirect(self, session: TargetSession, alias: Version, version_id: str) -> None: + pass + + def delete_redirect(self, session: TargetSession, alias: Version) -> None: + pass + + +class MockTargetSession(abstract.TargetSession): + files: dict[tuple[Version, str], bytes] + internal_deployment_spec: abstract.DeploymentSpec + closed: bool = False + close_success: bool = False + aliases: dict[Version, versions.DeploymentAlias] + redirect_mechanisms: dict[str, abstract.RedirectMechanism] = {'mock': MockRedirectMechanism()} + + def __init__(self): + self.files = {} + self.deleted_files = set() + self.internal_deployment_spec = abstract.DeploymentSpec() + self.aliases = {} + self.redirect_mechanisms = self.redirect_mechanisms.copy() + + def start_version(self, version_id: str, title: str) -> None: + self.internal_deployment_spec.versions[version_id] = versions.DeploymentVersion(title=title) + + def delete_version(self, version_id: str) -> None: + existing_files = [f for v, f in self.files.keys() if v == version_id] + del self.internal_deployment_spec.versions[version_id] + for file in existing_files: + del self.files[(version_id, file)] + + def upload_file(self, version_id: Version, filename: str, file_obj: IO[bytes]) -> None: + if version_id not in self.internal_deployment_spec.versions: + raise abstract.VersionNotFound(version_id) + self.files[(version_id, filename)] = file_obj.read() + + def download_file(self, version_id: Version, filename: str) -> IO[bytes]: + if version_id not in self.internal_deployment_spec.versions: + raise abstract.VersionNotFound(version_id) + return BytesIO(self.files[(version_id, filename)]) + + def delete_file(self, version_id: Version, filename: str) -> None: + if version_id not in self.internal_deployment_spec.versions: + raise abstract.VersionNotFound(version_id) + del self.files[(version_id, filename)] + + def iter_files(self, version_id: str) -> Iterable[str]: + for version, file in self.files: + if version_id == version: + yield file + + def close(self, success: bool = False) -> None: + self.closed = True + self.close_success = success + + def set_alias(self, alias_id: Version, alias: DeploymentAlias | None) -> None: + if alias is None: + del self.aliases[alias_id] + return + self.aliases[alias_id] = deepcopy(alias) + if alias_id is abstract.DEFAULT_VERSION: + self.internal_deployment_spec.default_version = self.aliases[alias_id] + else: + self.internal_deployment_spec.aliases[alias_id] = self.aliases[alias_id] + + @property + def available_redirect_mechanisms(self) -> dict[str, abstract.RedirectMechanism]: + return self.redirect_mechanisms + + @property + def deployment_spec(self) -> DeploymentSpec: + return deepcopy(self.internal_deployment_spec) + + +class MockTarget(abstract.Target): + + files: dict[tuple[str, str], bytes] + internal_deployment_spec: versions.DeploymentSpec + + def __init__(self): + self.files = {} + self.internal_deployment_spec = versions.DeploymentSpec() + + def start_session(self) -> MockTargetSession: + return MockTargetSession() diff --git a/source/tests/mock_wrapper.py b/source/tests/mock_wrapper.py new file mode 100644 index 0000000..19ecdb1 --- /dev/null +++ b/source/tests/mock_wrapper.py @@ -0,0 +1,42 @@ +from typing import Any, NamedTuple, TypeVar + +_T = TypeVar("_T") + + +class MethodCall(NamedTuple): + name: str | None + args: tuple[Any, ...] + kwargs: dict[str, Any] + + +def mock_wrapper(to_wrap: _T, name: str | None = None) -> tuple[_T, list[MethodCall]]: + method_calls: list[MethodCall] = [] + return _MockWrapper(to_wrap, method_calls, name), method_calls # type: ignore + + +class _MockWrapper: + + _wrapped = None + _name = None + _method_calls = None + + def __init__(self, wrapped: Any, method_calls: list[MethodCall], name: str | None = None): + self._name = name if name is not None else type(wrapped).__name__ + self._wrapped = wrapped + self._method_calls = method_calls + + def __getattr__(self, item): + result = getattr(self._wrapped, item) + if hasattr(result, "__call__"): + name = f"{self._name}.{item}" if self._name is not None else item + return type(self)(wrapped=result, method_calls=self._method_calls, name=name) + return result + + def __setattr__(self, key, value): + if key in ("_wrapped", "_method_calls", "_name"): + return super().__setattr__(key, value) + return setattr(self._wrapped, key, value) + + def __call__(self, *args, **kwargs): + self._method_calls.append(MethodCall(self._name, args, kwargs)) + return self._wrapped(*args, **kwargs) diff --git a/source/tests/test_modules/__init__.py b/source/tests/test_modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/source/tests/test_modules/test_actions.py b/source/tests/test_modules/test_actions.py new file mode 100644 index 0000000..74bad84 --- /dev/null +++ b/source/tests/test_modules/test_actions.py @@ -0,0 +1,186 @@ +import logging +import uuid + +import pytest + +from mkdocs_deploy import abstract, actions, versions +from ..mock_plugin import MockSource, MockTargetSession +from ..mock_wrapper import mock_wrapper + +MOCK_SOURCE_FILES = { + "index.html": str(uuid.uuid4()).encode(), + "subdir/foo.txt": str(uuid.uuid4()).encode(), +} + + +@pytest.mark.parametrize("title", ["Version 1.1", None], ids=["Explicit Title", "Implicit Title"]) +def test_upload(title: str | None): + VERSION = "1.1" + source = MockSource(MOCK_SOURCE_FILES) + session, session_method_calls = mock_wrapper(MockTargetSession()) + + try: + actions.upload(source=source, target=session, version_id=VERSION, title=title) + + assert session_method_calls[0].name == "MockTargetSession.start_version" + + assert session.files == {(VERSION, file): content for file, content in source.files.items()} + + assert session.deployment_spec.versions == { + VERSION: versions.DeploymentVersion(title=title or VERSION), + } + except: + session.close(success=False) + raise + else: + session.close(success=True) + + +def test_upload_implicit_title_does_not_override_existing_one(): + VERSION = "1.1" + source = MockSource(MOCK_SOURCE_FILES) + session, session_method_calls = mock_wrapper(MockTargetSession()) + session.start_version(VERSION, "foo bar") + + actions.upload(source=source, target=session, version_id=VERSION, title=None) + + assert session.deployment_spec.versions == { + VERSION: versions.DeploymentVersion(title="foo bar"), + } + + +def test_upload_explicit_title_overrides_existing_one(): + VERSION = "1.1" + VERSION_TITLE = "Version 1.1" + source = MockSource(MOCK_SOURCE_FILES) + session, session_method_calls = mock_wrapper(MockTargetSession()) + session.start_version(VERSION, "foo bar") + + actions.upload(source=source, target=session, version_id=VERSION, title=VERSION_TITLE) + + assert session.deployment_spec.versions == { + VERSION: versions.DeploymentVersion(title=VERSION_TITLE), + } + + +@pytest.mark.parametrize("alias", ["latest", abstract.DEFAULT_VERSION], ids=["named", "default"]) +def test_refresh_all_redirects_on_alias(alias: abstract.Version): + session = MockTargetSession() + for version in ("1.0", "1.1", "2.0"): + session.start_version(version, version) + session.available_redirect_mechanisms['mock'].create_redirect(session, abstract.DEFAULT_VERSION, "1.1") + session.set_alias(alias, abstract.DeploymentAlias(version_id="1.1", redirect_mechanisms={'mock'})) + + method_1, method_calls_1 = mock_wrapper(session.redirect_mechanisms['mock']) + method_2, method_calls_2 = mock_wrapper(session.redirect_mechanisms['mock']) + session.redirect_mechanisms['mock'] = method_1 + session.redirect_mechanisms['mock_2'] = method_2 + + actions.refresh_alias(session, alias) + assert not method_calls_2 # Only existing aliases are refreshed + assert len(method_calls_1) == 1 + assert method_calls_1[0].name == 'MockRedirectMechanism.refresh_redirect' + assert method_calls_1[0].kwargs["alias"] == alias + assert method_calls_1[0].kwargs["version_id"] == "1.1" + + +@pytest.mark.parametrize("alias", ["latest", abstract.DEFAULT_VERSION], ids=["Named", "Default"]) +def test_refresh_specific_redirect_on_alias(alias: abstract.Version): + session = MockTargetSession() + for version in ("1.0", "1.1", "2.0"): + session.start_version(version, version) + session.set_alias(alias, abstract.DeploymentAlias(version_id="1.1", redirect_mechanisms={'mock_2'})) + session.available_redirect_mechanisms['mock'].create_redirect(session, abstract.DEFAULT_VERSION, "1.1") + + method_1, method_calls_1 = mock_wrapper(session.redirect_mechanisms['mock']) + method_2, method_calls_2 = mock_wrapper(session.redirect_mechanisms['mock']) + session.redirect_mechanisms['mock'] = method_1 + session.redirect_mechanisms['mock_2'] = method_2 + + actions.refresh_alias(session, alias, {"mock_2"}) + assert not method_calls_1 # Only named aliases are refreshed + assert len(method_calls_2) == 1 + assert method_calls_2[0].name == 'MockRedirectMechanism.refresh_redirect' + assert method_calls_2[0].kwargs["alias"] == alias + assert method_calls_2[0].kwargs["version_id"] == "1.1" + + + +@pytest.mark.parametrize("alias", ["latest", abstract.DEFAULT_VERSION], ids=["named", "default"]) +def test_delete_all_redirects_on_alias(alias: abstract.Version): + session = MockTargetSession() + for version in ("1.0", "1.1", "2.0"): + session.start_version(version, version) + session.available_redirect_mechanisms['mock'].create_redirect(session, abstract.DEFAULT_VERSION, "1.1") + session.set_alias(alias, abstract.DeploymentAlias(version_id="1.1", redirect_mechanisms={'mock'})) + + method_1, method_calls_1 = mock_wrapper(session.redirect_mechanisms['mock']) + method_2, method_calls_2 = mock_wrapper(session.redirect_mechanisms['mock']) + session.redirect_mechanisms['mock'] = method_1 + session.redirect_mechanisms['mock_2'] = method_2 + + actions.delete_alias(session, alias) + assert not method_calls_2 # Only existing aliases are refreshed + assert len(method_calls_1) == 1 + assert method_calls_1[0].name == 'MockRedirectMechanism.delete_redirect' + assert method_calls_1[0].kwargs["alias"] == alias + + assert alias not in session.aliases + + +def test_refresh_missing_alias_logs_warning(caplog: pytest.LogCaptureFixture): + session = MockTargetSession() + for version in ("1.0", "1.1", "2.0"): + session.start_version(version, version) + + session.redirect_mechanisms['mock'], method_calls = mock_wrapper(session.redirect_mechanisms['mock']) + with caplog.at_level(level=logging.WARNING): + actions.refresh_alias(session, "not_an_alias", {"mock"}) + + assert len(caplog.records) == 1 + assert caplog.records[0].levelno == logging.WARNING + assert not method_calls + + +@pytest.mark.parametrize("alias", ["latest", abstract.DEFAULT_VERSION], ids=["Named", "Default"]) +def test_delete_last_redirect_on_alias(alias: abstract.Version): + session = MockTargetSession() + for version in ("1.0", "1.1", "2.0"): + session.start_version(version, version) + session.set_alias(alias, abstract.DeploymentAlias(version_id="1.1", redirect_mechanisms={'mock_2'})) + session.available_redirect_mechanisms['mock'].create_redirect(session, abstract.DEFAULT_VERSION, "1.1") + + method_1, method_calls_1 = mock_wrapper(session.redirect_mechanisms['mock']) + method_2, method_calls_2 = mock_wrapper(session.redirect_mechanisms['mock']) + session.redirect_mechanisms['mock'] = method_1 + session.redirect_mechanisms['mock_2'] = method_2 + + actions.delete_alias(session, alias, {"mock_2"}) + assert not method_calls_1 # Only named aliases are refreshed + assert len(method_calls_2) == 1 + assert method_calls_2[0].name == 'MockRedirectMechanism.delete_redirect' + assert method_calls_2[0].kwargs["alias"] == alias + + assert alias not in session.aliases + + +@pytest.mark.parametrize("alias", ["latest", abstract.DEFAULT_VERSION], ids=["Named", "Default"]) +def test_delete_one_of_two_redirects_on_alias(alias: abstract.Version): + session = MockTargetSession() + for version in ("1.0", "1.1", "2.0"): + session.start_version(version, version) + session.set_alias(alias, abstract.DeploymentAlias(version_id="1.1", redirect_mechanisms={'mock' ,'mock_2'})) + session.available_redirect_mechanisms['mock'].create_redirect(session, abstract.DEFAULT_VERSION, "1.1") + + method_1, method_calls_1 = mock_wrapper(session.redirect_mechanisms['mock']) + method_2, method_calls_2 = mock_wrapper(session.redirect_mechanisms['mock']) + session.redirect_mechanisms['mock'] = method_1 + session.redirect_mechanisms['mock_2'] = method_2 + + actions.delete_alias(session, alias, {"mock_2"}) + assert not method_calls_1 # Only named aliases are refreshed + assert len(method_calls_2) == 1 + assert method_calls_2[0].name == 'MockRedirectMechanism.delete_redirect' + assert method_calls_2[0].kwargs["alias"] == alias + + assert alias in session.aliases diff --git a/source/tests/test_stub.py b/source/tests/test_stub.py deleted file mode 100644 index 141cc7b..0000000 --- a/source/tests/test_stub.py +++ /dev/null @@ -1,2 +0,0 @@ -def test_stub(): - ... From af037a4acd0ca636b118dce4a31cadfa40b4596c Mon Sep 17 00:00:00 2001 From: Philip Couling Date: Mon, 15 Jan 2024 22:49:06 +0000 Subject: [PATCH 2/2] Remove extra trigger --- .github/workflows/pull_request.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/pull_request.yaml b/.github/workflows/pull_request.yaml index a8227ee..b0f9a99 100644 --- a/.github/workflows/pull_request.yaml +++ b/.github/workflows/pull_request.yaml @@ -1,9 +1,6 @@ name: Pull Request on: pull_request: {} - push: - branches: - - pytest jobs: test: