From 12114997684569abfa961d31ab72eea2105be85d Mon Sep 17 00:00:00 2001 From: Dzmitry Pryskoka Date: Tue, 30 Jul 2024 20:35:04 +0300 Subject: [PATCH] Lower minimum required python version to 3.8 --- .github/workflows/run-tests.yml | 3 +- README.md | 4 +- changelog.md | 4 +- pyproject.toml | 6 +- requirements.txt | 10 +- src/kataloger/catalog_updater.py | 16 +- src/kataloger/cli/configuration_provider.py | 6 +- src/kataloger/data/catalog.py | 5 +- src/kataloger/helpers/backport_helpers.py | 42 ++++ .../helpers/structural_matching_helpers.py | 43 ++++ src/kataloger/helpers/toml_parse_helpers.py | 199 +++++++++--------- .../universal/universal_update_resolver.py | 18 +- tests/helpers/test_backport_helpers.py | 51 +++++ .../test_structural_matching_helpers.py | 105 +++++++++ tests/helpers/test_toml_parse_helpers.py | 137 ++++++------ tests/test_catalog_updater.py | 6 +- 16 files changed, 451 insertions(+), 204 deletions(-) create mode 100644 src/kataloger/helpers/backport_helpers.py create mode 100644 src/kataloger/helpers/structural_matching_helpers.py create mode 100644 tests/helpers/test_backport_helpers.py create mode 100644 tests/helpers/test_structural_matching_helpers.py diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 57fccde..3fa5154 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -27,6 +27,7 @@ jobs: strategy: matrix: os: [ ubuntu-latest, macos-latest, windows-latest ] + python_version: [ 3.8, 3.9, 3.10, 3.11, 3.12, 3.13 ] runs-on: ${{ matrix.os }} @@ -37,7 +38,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v5 with: - python-version: 3.13 + python-version: ${{ matrix.python_version }} - name: Install dependencies run: | diff --git a/README.md b/README.md index 3367c67..1c2f25e 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ ## Kataloger +[![Python version](https://img.shields.io/badge/python-3.8-blue.svg)](https://pypi.python.org/pypi/kataloger) [![Latest version](https://img.shields.io/pypi/v/kataloger.svg?style=flat&label=Latest&color=%234B78E6&logo=&logoColor=white)](https://pypi.python.org/pypi/kataloger) [![Downloads](https://static.pepy.tech/badge/kataloger/month)](https://pepy.tech/project/kataloger) [![Tests](https://github.com/dzmpr/kataloger/actions/workflows/run-tests.yml/badge.svg?branch=main)](https://github.com/dzmpr/kataloger/actions/workflows/run-tests.yml) -[![Python version](https://img.shields.io/badge/python-3.11-blue.svg)](https://pypi.python.org/pypi/kataloger) Kataloger can help update your project dependencies with ease! All you need is point to `libs.versions.toml` file and supply it with repositories that you use in project. @@ -69,7 +69,7 @@ kataloger -p ~/ProjectDir/libs.versions.toml ### Installation -Kataloger available in Python Package Index (PyPI). You can install kataloger using pip: +Kataloger available in Python Package Index (PyPI). You can install kataloger using pip (requires python 3.8 and greater): ```commandline pip install kataloger ``` diff --git a/changelog.md b/changelog.md index 6d1d552..da9dd9c 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,5 @@ ### Changelog -* Repositories configuration file replaced with kataloger configuration file, where beside repositories can be specified catalog paths and parameters. +* Added support for more library and plugin notations in catalog. +* Repositories configuration file replaced with kataloger configuration file, where beside repositories can be specified catalog paths and parameters. +* Minimum required python version lowered to 3.8. diff --git a/pyproject.toml b/pyproject.toml index ed90f78..bd8fb70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,11 +4,13 @@ build-backend = "setuptools.build_meta" [project] name = "kataloger" -version = "0.2.0" +version = "0.3.0" dependencies = [ "aiohttp", "yarl", "xmltodict", + "tomli; python_version < \"3.11\"", + "importlib-resources; python_version < \"3.9\"" ] authors = [ { name = "Dzmitry Pryskoka", email = "mr.priskoka@yandex.com" }, @@ -16,7 +18,7 @@ authors = [ description = "CLI tool for projects that uses gradle version catalog to check dependency updates." readme = "README.md" license = { text = "Apache 2.0" } -requires-python = ">=3.11" +requires-python = ">=3.8" classifiers = [ "Development Status :: 4 - Beta", "Environment :: Console", diff --git a/requirements.txt b/requirements.txt index f2155cb..9b27a51 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,12 +1,14 @@ -aiohttp==3.9.3 +aiohttp==3.9.5 aiosignal==1.3.1 async-timeout==4.0.3 attrs==23.2.0 charset-normalizer==3.3.2 frozenlist==1.4.1 -pytest==8.1.1 -pytest-asyncio==0.23.6 -idna==3.6 +pytest==8.3.2 +pytest-asyncio==0.23.8 +idna==3.7 multidict==6.0.5 xmltodict==0.13.0 yarl==1.9.4 +tomli==2.0.1;python_version<"3.11" +importlib-resources==6.4.0;python_version<"3.9" diff --git a/src/kataloger/catalog_updater.py b/src/kataloger/catalog_updater.py index 4bcfbab..02ca8fb 100644 --- a/src/kataloger/catalog_updater.py +++ b/src/kataloger/catalog_updater.py @@ -100,12 +100,14 @@ def try_find_update( ) -> Optional[ArtifactUpdate]: for resolver in self.update_resolvers: (resolution, optional_update) = resolver.resolve(artifact, repositories_metadata) - match resolution: - case UpdateResolution.CANT_RESOLVE: - continue - case UpdateResolution.UPDATE_FOUND: - return optional_update - case UpdateResolution.NO_UPDATES: - return None + if resolution == UpdateResolution.CANT_RESOLVE: + continue + if resolution == UpdateResolution.UPDATE_FOUND: + return optional_update + if resolution == UpdateResolution.NO_UPDATES: + return None + + message: str = f'Unexpected update resolution: "{resolution}".' + raise ValueError(message) return None diff --git a/src/kataloger/cli/configuration_provider.py b/src/kataloger/cli/configuration_provider.py index 2ec423f..269d780 100644 --- a/src/kataloger/cli/configuration_provider.py +++ b/src/kataloger/cli/configuration_provider.py @@ -1,9 +1,7 @@ import sys -from importlib.resources import as_file, files from pathlib import Path from typing import List, Optional, Tuple, TypeVar -from kataloger import package_name from kataloger.cli.argument_parser import parse_arguments from kataloger.data.catalog import Catalog from kataloger.data.configuration_data import ConfigurationData @@ -11,6 +9,7 @@ from kataloger.data.kataloger_configuration import KatalogerConfiguration from kataloger.data.repository import Repository from kataloger.exceptions.kataloger_configuration_exception import KatalogerConfigurationException +from kataloger.helpers.backport_helpers import get_package_file from kataloger.helpers.path_helpers import file_exists from kataloger.helpers.toml_parse_helpers import load_configuration @@ -115,7 +114,6 @@ def load_configuration_data(configuration_path: Optional[Path]) -> Configuration if file_exists(configuration_candidate): configuration_path = configuration_candidate else: - with as_file(files(package_name).joinpath("default.configuration.toml")) as path: - configuration_path = path + configuration_path = get_package_file("default.configuration.toml") return load_configuration(configuration_path) diff --git a/src/kataloger/data/catalog.py b/src/kataloger/data/catalog.py index d1a34a4..1017e6a 100644 --- a/src/kataloger/data/catalog.py +++ b/src/kataloger/data/catalog.py @@ -2,6 +2,8 @@ from pathlib import Path from typing import Optional +from kataloger.helpers.backport_helpers import remove_suffix + @dataclass(frozen=True) class Catalog: @@ -10,8 +12,7 @@ class Catalog: @staticmethod def from_path(path: Path) -> "Catalog": - catalog_name = path.name.removesuffix(".versions.toml") if path.name.endswith(".versions.toml") else path.name return Catalog( - name=catalog_name, + name=remove_suffix(path.name, ".versions.toml"), path=path, ) diff --git a/src/kataloger/helpers/backport_helpers.py b/src/kataloger/helpers/backport_helpers.py new file mode 100644 index 0000000..22e3387 --- /dev/null +++ b/src/kataloger/helpers/backport_helpers.py @@ -0,0 +1,42 @@ +import sys +from pathlib import Path +from typing import Dict, Union + +from kataloger import package_name +from kataloger.exceptions.kataloger_parse_exception import KatalogerParseException + + +def remove_suffix(string: str, suffix: str) -> str: + if sys.version_info < (3, 9): + if suffix and string.endswith(suffix): + return string[:-len(suffix)] + + return string + + return string.removesuffix(suffix) + + +def get_package_file(filename: str) -> Path: + if sys.version_info < (3, 9): + from importlib_resources import as_file, files + else: + from importlib.resources import as_file, files + + with as_file(files(package_name).joinpath(filename)) as path: + return path + + +def load_toml(path: Path) -> Dict[str, Union[str, Dict]]: + if sys.version_info < (3, 11): + import tomli as tomllib + from tomli import TOMLDecodeError + else: + import tomllib + from tomllib import TOMLDecodeError + + with Path.open(path, mode="rb") as file: + try: + return tomllib.load(file) + except TOMLDecodeError as parse_error: + message = f"Can't parse TOML in \"{path.name}\"." + raise KatalogerParseException(message) from parse_error diff --git a/src/kataloger/helpers/structural_matching_helpers.py b/src/kataloger/helpers/structural_matching_helpers.py new file mode 100644 index 0000000..ddfa371 --- /dev/null +++ b/src/kataloger/helpers/structural_matching_helpers.py @@ -0,0 +1,43 @@ +from collections import namedtuple +from typing import Dict, Optional + + +def match(data: Dict, pattern: Dict) -> Optional[namedtuple]: # noqa PYI024 + """ + Check if a dictionary (`data`) matches a specified pattern (`pattern`). + + This function verifies if the structure and types of `data` conform to those + defined in `pattern`. If they match, a named tuple containing the matching values + is returned. If not, the function returns `None`. + + :param data: The dictionary to be checked against the pattern. + :param pattern: The dictionary defining the structure and expected types for matching. + :return: A named tuple with the matching values if `data` matches `pattern`. `None` if there is no match. + :raise ValueError: If a type is used as a key in the `pattern` dictionary. + """ + if not (isinstance(data, dict) or isinstance(pattern, dict)): + return None + + if len(data) != len(pattern): + return None + + result_data: Dict = {} + for pattern_key, pattern_value in pattern.items(): + if isinstance(pattern_key, type): + message: str = f"Can't use types as pattern keys: {pattern}. Key: {pattern_key}." + raise ValueError(message) + + if pattern_key not in data: + return None + + value = data[pattern_key] + if isinstance(pattern_value, type) and isinstance(value, pattern_value): + result_data[pattern_key] = value + elif isinstance(pattern_value, dict) and (mr := match(value, pattern_value)): + result_data[pattern_key] = mr + elif pattern_value == value: + result_data[pattern_key] = value + else: + return None + + return namedtuple(typename="MatchResult", field_names=result_data.keys())(*result_data.values()) # noqa PYI024 diff --git a/src/kataloger/helpers/toml_parse_helpers.py b/src/kataloger/helpers/toml_parse_helpers.py index 0bbf323..39ae4db 100644 --- a/src/kataloger/helpers/toml_parse_helpers.py +++ b/src/kataloger/helpers/toml_parse_helpers.py @@ -1,7 +1,6 @@ from pathlib import Path from typing import Dict, List, Optional, Tuple, Union -import tomllib from yarl import URL from kataloger.data.artifact.library import Library @@ -10,8 +9,10 @@ from kataloger.data.configuration_data import ConfigurationData from kataloger.data.repository import Repository from kataloger.exceptions.kataloger_parse_exception import KatalogerParseException +from kataloger.helpers.backport_helpers import load_toml from kataloger.helpers.log_helpers import log_warning from kataloger.helpers.path_helpers import str_to_path +from kataloger.helpers.structural_matching_helpers import match def load_configuration(configuration_path: Path) -> ConfigurationData: @@ -19,13 +20,13 @@ def load_configuration(configuration_path: Path) -> ConfigurationData: library_repositories: Optional[List[Repository]] = None plugin_repositories: Optional[List[Repository]] = None - configuration_data = load_toml_to_dict(path=configuration_path) + configuration_data = load_toml(path=configuration_path) if "catalogs" in configuration_data: catalogs = parse_catalogs(configuration_data["catalogs"], configuration_root_dir=configuration_path.parent) if "libraries" in configuration_data: - library_repositories = parse_repositories(list(configuration_data["libraries"].items())) + library_repositories = parse_repositories(configuration_data["libraries"]) if "plugins" in configuration_data: - plugin_repositories = parse_repositories(list(configuration_data["plugins"].items())) + plugin_repositories = parse_repositories(configuration_data["plugins"]) return ConfigurationData( catalogs=catalogs, @@ -38,7 +39,7 @@ def load_configuration(configuration_path: Path) -> ConfigurationData: def load_catalog(catalog_path: Path, verbose: bool) -> Tuple[List[Library], List[Plugin]]: - catalog = load_toml_to_dict(catalog_path) + catalog = load_toml(catalog_path) versions: Dict[str, str] = catalog.pop("versions", {}) libraries = parse_libraries(catalog, versions, verbose) plugins = parse_plugins(catalog, versions, verbose) @@ -46,24 +47,30 @@ def load_catalog(catalog_path: Path, verbose: bool) -> Tuple[List[Library], List return libraries, plugins -def parse_repositories(repositories_data: List[Tuple]) -> Optional[List[Repository]]: - if not repositories_data: +def parse_repositories(data: Dict) -> Optional[List[Repository]]: + if not data: return None + if not isinstance(data, dict): + raise KatalogerParseException(message="Unexpected repository data.") + repositories = [] - for repository_data in repositories_data: - match repository_data: - case (str(name), str(address)): - repository = Repository(name, URL(address)) - case (str(name), {"address": str(address), "user": str(user), "password": str(password)}): - repository = Repository( - name=name, - address=URL(address), - user=user, - password=password, - ) - case _: - raise KatalogerParseException(message="Unexpected repository data.") + for name, repository_data in data.items(): + if not isinstance(name, str): + raise KatalogerParseException(message=f'Unexpected repository name: "{name}".') + + repository: Repository + if isinstance(repository_data, str): + repository = Repository(name=name, address=URL(repository_data)) + elif mr := match(repository_data, pattern={"address": str, "user": str, "password": str}): + repository = Repository( + name=name, + address=URL(mr.address), + user=mr.user, + password=mr.password, + ) + else: + raise KatalogerParseException(message="Unexpected repository data.") repositories.append(repository) return repositories @@ -106,50 +113,47 @@ def parse_libraries(catalog: Dict[str, Union[Dict, str]], versions: Dict, verbos if "libraries" not in catalog: return libraries - for name, library in catalog["libraries"].items(): - match library: - case str(declaration): - (module, version) = __parse_declaration(declaration) - library = Library( - name=name, - coordinates=module, - version=version, - ) - libraries.append(library) - case {"group": str(group), "name": str(library_name), "version": str(version)}: - library = Library( - name=name, - coordinates=f"{group}:{library_name}", - version=version, - ) - libraries.append(library) - case {"group": str(group), "name": str(library_name), "version": {"ref": str(ref)}}: - library = Library( - name=name, - coordinates=f"{group}:{library_name}", - version=__get_version_by_reference(versions, ref, name), - ) - libraries.append(library) - case {"module": str(module), "version": str(version)}: - library = Library( - name=name, - coordinates=module, - version=version, - ) - libraries.append(library) - case {"module": str(module), "version": {"ref": str(ref)}}: - library = Library( - name=name, - coordinates=module, - version=__get_version_by_reference(versions, ref, name), - ) - libraries.append(library) - case {"module": str(module)}: - if verbose: - log_warning(f'Library "{module}" has no version in catalog.') - case _: - message = f"Unknown library notation: {library}" - raise KatalogerParseException(message) + for name, library_data in catalog["libraries"].items(): + if not isinstance(name, str): + raise KatalogerParseException(message=f'Unexpected library name: "{name}".') + + library: Library + if isinstance(library_data, str): + (module, version) = __parse_declaration(library_data) + library = Library(name=name, coordinates=module, version=version) + elif mr := match(library_data, pattern={"group": str, "name": str, "version": str}): + library = Library( + name=name, + coordinates=f"{mr.group}:{mr.name}", + version=mr.version, + ) + elif mr := match(library_data, pattern={"group": str, "name": str, "version": {"ref": str}}): + library = Library( + name=name, + coordinates=f"{mr.group}:{mr.name}", + version=__get_version_by_reference(versions, mr.version.ref, name), + ) + elif mr := match(library_data, pattern={"module": str, "version": str}): + library = Library( + name=name, + coordinates=mr.module, + version=mr.version, + ) + elif mr := match(library_data, pattern={"module": str, "version": {"ref": str}}): + library = Library( + name=name, + coordinates=mr.module, + version=__get_version_by_reference(versions, mr.version.ref, name), + ) + elif mr := match(library_data, pattern={"module": str}): + if verbose: + log_warning(f'Library "{mr.module}" has no version in catalog.') + continue + else: + message = f"Unknown library notation: {library_data}" + raise KatalogerParseException(message) + + libraries.append(library) return libraries @@ -159,36 +163,34 @@ def parse_plugins(catalog: Dict[str, Union[Dict, str]], versions: Dict, verbose: if "plugins" not in catalog: return plugins - for name, plugin in catalog["plugins"].items(): - match plugin: - case str(declaration): - (plugin_id, version) = __parse_declaration(declaration) - plugin = Plugin( - name=name, - coordinates=plugin_id, - version=version, - ) - plugins.append(plugin) - case {"id": str(plugin_id), "version": str(version)}: - plugin = Plugin( - name=name, - coordinates=plugin_id, - version=version, - ) - plugins.append(plugin) - case {"id": str(plugin_id), "version": {"ref": str(ref)}}: - plugin = Plugin( - name=name, - coordinates=plugin_id, - version=__get_version_by_reference(versions, ref, name), - ) - plugins.append(plugin) - case {"id": str(plugin_id)}: - if verbose: - log_warning(f'Plugin "{plugin_id}" has no version in catalog.') - case _: - message = f"Unknown plugin notation: {plugin}" - raise KatalogerParseException(message) + for name, plugin_data in catalog["plugins"].items(): + if not isinstance(name, str): + raise KatalogerParseException(message=f'Unexpected plugin name: "{name}".') + + if isinstance(plugin_data, str): + (plugin_id, version) = __parse_declaration(plugin_data) + plugin = Plugin(name=name, coordinates=plugin_id, version=version) + elif mr := match(plugin_data, pattern={"id": str, "version": str}): + plugin = Plugin( + name=name, + coordinates=mr.id, + version=mr.version, + ) + elif mr := match(plugin_data, pattern={"id": str, "version": {"ref": str}}): + plugin = Plugin( + name=name, + coordinates=mr.id, + version=__get_version_by_reference(versions, mr.version.ref, name), + ) + elif mr := match(plugin_data, pattern={"id": str}): + if verbose: + log_warning(f'Plugin "{mr.id}" has no version in catalog.') + continue + else: + message = f"Unknown plugin notation: {plugin_data}" + raise KatalogerParseException(message) + + plugins.append(plugin) return plugins @@ -220,12 +222,3 @@ def __extract_optional_boolean(data: Dict, key: str) -> Optional[bool]: message = f'Configuration field "{key}" has incorrect value "{value}", while expected boolean type.' raise KatalogerParseException(message) - - -def load_toml_to_dict(path: Path) -> Dict[str, Union[Dict, str]]: - with Path.open(path, "rb") as file: - try: - return tomllib.load(file) - except tomllib.TOMLDecodeError as parse_error: - message = f"Can't parse TOML in \"{path.name}\"." - raise KatalogerParseException(message) from parse_error diff --git a/src/kataloger/update_resolver/universal/universal_update_resolver.py b/src/kataloger/update_resolver/universal/universal_update_resolver.py index 550bebe..a3c97cd 100644 --- a/src/kataloger/update_resolver/universal/universal_update_resolver.py +++ b/src/kataloger/update_resolver/universal/universal_update_resolver.py @@ -32,15 +32,15 @@ def resolve( repositories_to_check.append(recently_updated_repository) for repository in repositories_to_check: - for version_factory in self.version_factories: - (result, optional_update) = self.__resolve_update_in_repository(artifact, version_factory, repository) - match result: - case UpdateResolution.CANT_RESOLVE: - continue - case UpdateResolution.NO_UPDATES: - return result, optional_update - case UpdateResolution.UPDATE_FOUND: - return result, optional_update + for factory in self.version_factories: + (resolution, optional_update) = self.__resolve_update_in_repository(artifact, factory, repository) + if resolution == UpdateResolution.CANT_RESOLVE: + continue + if resolution == UpdateResolution.NO_UPDATES or resolution == UpdateResolution.UPDATE_FOUND: + return resolution, optional_update + + message: str = f'Unexpected update resolution: "{resolution}".' + raise ValueError(message) return UpdateResolution.CANT_RESOLVE, None diff --git a/tests/helpers/test_backport_helpers.py b/tests/helpers/test_backport_helpers.py new file mode 100644 index 0000000..7a3b06a --- /dev/null +++ b/tests/helpers/test_backport_helpers.py @@ -0,0 +1,51 @@ +from pathlib import Path +from unittest.mock import Mock, mock_open, patch + +import pytest + +from kataloger.exceptions.kataloger_parse_exception import KatalogerParseException +from kataloger.helpers.backport_helpers import load_toml, remove_suffix + + +class TestBackportHelpers: + + def test_should_remove_string_suffix_when_string_ends_with_provided_suffix(self): + initial_string: str = "some string" + suffix: str = "string" + + assert remove_suffix(initial_string, suffix) == "some " + + def test_should_return_string_as_is_when_string_not_ends_with_provided_suffix(self): + initial_string: str = "some string" + suffix: str = "integer" + + assert remove_suffix(initial_string, suffix) == initial_string + + def test_should_read_toml_file_to_dictionary_when_toml_format_is_correct(self): + toml: bytes = b"""\ + [versions] + hilt = "2.50" + + [libraries] + kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version = "1.9.22" } + """ + expected_data: dict = { + "versions": { + "hilt": "2.50", + }, + "libraries": { + "kotlin-stdlib": { + "module": "org.jetbrains.kotlin:kotlin-stdlib", + "version": "1.9.22", + }, + }, + } + with patch.object(Path, "open", mock_open(read_data=toml)): + actual_data: dict = load_toml(path=Mock()) + + assert actual_data == expected_data + + def test_should_raise_exception_when_toml_format_is_incorrect(self): + toml: bytes = b"""""" + with patch.object(Path, "open", mock_open(read_data=toml)), pytest.raises(KatalogerParseException): + load_toml(path=Mock()) diff --git a/tests/helpers/test_structural_matching_helpers.py b/tests/helpers/test_structural_matching_helpers.py new file mode 100644 index 0000000..a8e73f2 --- /dev/null +++ b/tests/helpers/test_structural_matching_helpers.py @@ -0,0 +1,105 @@ +from typing import Dict, List + +import pytest + +from kataloger.helpers.structural_matching_helpers import match + + +class TestStructuralMatchingHelpers: + default_key: str = "password" + default_value: str = "12345678" + + def test_pattern_should_match_data_when_data_has_same_object_as_value(self): + data: Dict = {self.default_key: self.default_value} + pattern: Dict = {self.default_key: self.default_value} + + assert match(data, pattern) + + def test_pattern_should_not_match_data_when_data_has_different_object_as_value(self): + data: Dict = {self.default_key: self.default_value} + pattern: Dict = {self.default_key: "qwerty"} + + assert match(data, pattern) is None + + def test_pattern_should_match_data_when_data_has_value_of_type_specified_in_pattern(self): + data: Dict = {self.default_key: self.default_value} + pattern: Dict = {self.default_key: str} + + assert match(data, pattern) + + def test_pattern_should_not_match_data_when_data_has_value_of_different_type_than_specified_in_pattern(self): + data: Dict = {self.default_key: self.default_value} + pattern: Dict = {self.default_key: int} + + assert not match(data, pattern) + + def test_should_match_data_with_dictionary_values(self): + data: Dict = {"credentials": {self.default_key: self.default_value}} + pattern: Dict = {"credentials": {self.default_key: self.default_value}} + + assert match(data, pattern) + + def test_match_result_should_contain_value_from_data_accessible_with_key_from_pattern(self): + data: Dict = {self.default_key: self.default_value} + pattern: Dict = {self.default_key: self.default_value} + + mr = match(data, pattern) + assert mr is not None + assert getattr(mr, self.default_key) == self.default_value + + def test_match_result_should_contain_nested_match_result_for_dictionary_values(self): + top_level_key: str = "credentials" + data: Dict = {top_level_key: {self.default_key: self.default_value}} + pattern: Dict = {top_level_key: {self.default_key: self.default_value}} + + mr = match(data, pattern) + assert mr is not None + nested_mr = getattr(mr, top_level_key) + assert nested_mr == match(data[top_level_key], pattern[top_level_key]) + assert getattr(nested_mr, self.default_key) == self.default_value + + def test_match_should_raise_value_error_when_pattern_key_is_a_type(self): + data: Dict = {self.default_key: self.default_value} + pattern: Dict = {str: self.default_value} + + with pytest.raises(ValueError, match="Can't use types as pattern keys:.*"): + match(data, pattern) + + def test_should_return_none_when_pattern_is_not_dictionary(self): + data: Dict = {self.default_key: self.default_value} + pattern: List = [self.default_key, self.default_value] + + # noinspection PyTypeChecker + assert not match(data, pattern) + + def test_should_return_none_when_data_is_not_dictionary(self): + data: List = [self.default_key, self.default_value] + pattern: Dict = {self.default_key: self.default_value} + + # noinspection PyTypeChecker + assert not match(data, pattern) + + def test_should_return_none_when_data_has_more_items_than_pattern(self): + data: Dict = {self.default_key: self.default_value, "extra": 42} + pattern: Dict = {self.default_key: self.default_value} + + assert not match(data, pattern) + + def test_should_return_none_when_pattern_has_more_items_than_data(self): + data: Dict = {self.default_key: self.default_value} + pattern: Dict = {self.default_key: self.default_value, "extra": 42} + + assert not match(data, pattern) + + def test_should_return_none_when_one_of_keys_in_pattern_are_missing_in_data(self): + data: Dict = {self.default_key: self.default_value, "key": 42} + pattern: Dict = {self.default_key: self.default_value, "extra": 42} + + assert not match(data, pattern) + + def test_should_treat_lists_and_tuples_as_value_in_pattern(self): + assert match(data={"data": [42]}, pattern={"data": [42]}) + assert not match(data={"data": [42]}, pattern={"data": [int]}) + + assert match(data={"data": (42,)}, pattern={"data": (42,)}) + assert not match(data={"data": (42,)}, pattern={"data": (int,)}) diff --git a/tests/helpers/test_toml_parse_helpers.py b/tests/helpers/test_toml_parse_helpers.py index daa5e60..4df5e4a 100644 --- a/tests/helpers/test_toml_parse_helpers.py +++ b/tests/helpers/test_toml_parse_helpers.py @@ -1,6 +1,6 @@ from pathlib import Path from typing import Dict, List, Optional, Tuple -from unittest.mock import Mock, mock_open, patch +from unittest.mock import Mock import pytest from yarl import URL @@ -15,7 +15,6 @@ from kataloger.helpers.toml_parse_helpers import ( load_catalog, load_configuration, - load_toml_to_dict, parse_catalogs, parse_libraries, parse_plugins, @@ -32,35 +31,6 @@ class TestTomlParseHelpers: default_repository_address: str = "https://reposito.ry/" default_catalog_name: str = "catalog_name" - def test_should_read_toml_file_to_dictionary_when_toml_format_is_correct(self): - toml: bytes = b"""\ - [versions] - hilt = "2.50" - - [libraries] - kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version = "1.9.22" } - """ - expected_data: Dict = { - "versions": { - "hilt": "2.50", - }, - "libraries": { - "kotlin-stdlib": { - "module": "org.jetbrains.kotlin:kotlin-stdlib", - "version": "1.9.22", - }, - }, - } - with patch.object(Path, "open", mock_open(read_data=toml)): - actual_data: Dict = load_toml_to_dict(path=Mock()) - - assert actual_data == expected_data - - def test_should_raise_exception_when_toml_format_is_incorrect(self): - toml: bytes = b"""""" - with patch.object(Path, "open", mock_open(read_data=toml)), pytest.raises(KatalogerParseException): - load_toml_to_dict(path=Mock()) - def test_should_return_empty_plugins_list_when_catalog_has_no_plugins(self): catalog: Dict = {"libraries": {}} expected_plugins: List[Plugin] = [] @@ -187,6 +157,19 @@ def test_should_raise_exception_when_plugin_structure_is_incorrect(self): with pytest.raises(KatalogerParseException): parse_plugins(catalog, versions={}, verbose=False) + def test_should_raise_exception_when_plugin_name_is_not_string(self): + catalog: Dict = { + "plugins": { + 42: { + "id": self.default_plugin_id, + "version": self.default_version, + }, + }, + } + + with pytest.raises(KatalogerParseException): + parse_plugins(catalog, versions={}, verbose=False) + def test_should_return_empty_libraries_list_when_catalog_has_no_libraries(self): catalog: Dict = {"plugins": {}} expected_libraries: List[Library] = [] @@ -373,16 +356,26 @@ def test_should_raise_exception_when_library_structure_is_incorrect(self): with pytest.raises(KatalogerParseException): parse_libraries(catalog, versions={}, verbose=False) + def test_should_raise_exception_when_library_name_is_not_string(self): + catalog: Dict = { + "libraries": { + 42: "com.library:module:1.0.0", + }, + } + + with pytest.raises(KatalogerParseException): + parse_libraries(catalog, versions={}, verbose=False) + def test_should_return_none_when_there_is_no_repositories(self): expected_repositories: Optional[List[Repository]] = None - actual_repositories: Optional[List[Repository]] = parse_repositories(repositories_data=[]) + actual_repositories: Optional[List[Repository]] = parse_repositories(data={}) assert actual_repositories == expected_repositories def test_should_parse_repository_when_it_has_name_and_address(self): - data: List[Tuple[str, str]] = [ - (self.default_repository_name, self.default_repository_address), - ] + data: Dict[str, str] = { + self.default_repository_name: self.default_repository_address, + } expected_repository: Repository = Repository( name=self.default_repository_name, address=URL(self.default_repository_address), @@ -396,15 +389,13 @@ def test_should_parse_repository_when_it_has_name_and_address(self): def test_should_parse_repository_when_it_has_name_address_user_and_password(self): repository_user: str = "username" repository_password: str = "password" - data: List[Tuple] = [ - ( - self.default_repository_name, { - "address": self.default_repository_address, - "user": repository_user, - "password": repository_password, - }, - ), - ] + data: Dict[str, Dict] = { + self.default_repository_name: { + "address": self.default_repository_address, + "user": repository_user, + "password": repository_password, + }, + } expected_repository: Repository = Repository( name=self.default_repository_name, address=URL(self.default_repository_address), @@ -415,28 +406,43 @@ def test_should_parse_repository_when_it_has_name_address_user_and_password(self assert actual_repositories == [expected_repository] - def test_should_raise_exception_when_there_is_no_repository_user_but_password_present(self): - data: List[Tuple] = [ - ( - self.default_repository_name, { - "address": self.default_repository_address, - "user": "username", - }, - ), - ] + def test_should_raise_exception_when_repository_name_is_not_string(self): + data: Dict = { + 42: self.default_repository_address, + } with pytest.raises(KatalogerParseException): parse_repositories(data) - def test_should_raise_exception_when_there_is_no_repository_password_but_user_present(self): - data: List[Tuple] = [ - ( - self.default_repository_name, { - "address": self.default_repository_address, - "password": "password", - }, - ), - ] + def test_should_raise_exception_when_there_is_no_repository_password_but_address_and_user_present(self): + data: Dict[str, Dict] = { + self.default_repository_name: { + "address": self.default_repository_address, + "user": "username", + }, + } + + with pytest.raises(KatalogerParseException): + parse_repositories(data) + + def test_should_raise_exception_when_there_is_no_repository_user_but_address_and_password_present(self): + data: Dict[str, Dict] = { + self.default_repository_name: { + "address": self.default_repository_address, + "password": "password", + }, + } + + with pytest.raises(KatalogerParseException): + parse_repositories(data) + + def test_should_raise_exception_when_there_is_no_repository_address_but_user_and_password_present(self): + data: Dict[str, Dict] = { + self.default_repository_name: { + "user": "username", + "password": "password", + }, + } with pytest.raises(KatalogerParseException): parse_repositories(data) @@ -445,6 +451,7 @@ def test_should_raise_exception_when_repository_structure_is_incorrect(self): data: List[Tuple] = [("repository_name", "repository_address", "repository_port")] with pytest.raises(KatalogerParseException): + # noinspection PyTypeChecker parse_repositories(data) def test_should_return_none_when_there_is_no_catalogs(self): @@ -550,7 +557,7 @@ def test_should_load_catalog_from_path(self): version=self.default_version, ) - toml_parse_helpers.load_toml_to_dict = Mock(return_value=catalog) + toml_parse_helpers.load_toml = Mock(return_value=catalog) actual_libraries, actual_plugins = load_catalog(catalog_path=Mock(), verbose=False) assert actual_libraries == [expected_library] @@ -671,7 +678,7 @@ def test_should_raise_exception_when_boolean_flag_has_incorrect_type(self): configuration_data: Dict = { "verbose": 1, } - toml_parse_helpers.load_toml_to_dict = Mock(return_value=configuration_data) + toml_parse_helpers.load_toml = Mock(return_value=configuration_data) with pytest.raises(KatalogerParseException): load_configuration(configuration_path=Mock()) @@ -694,7 +701,7 @@ def __test_load_configuration( suggest_unstable_updates=expected_suggest_unstable_updates, fail_on_updates=expected_fail_on_updates, ) - toml_parse_helpers.load_toml_to_dict = Mock(return_value=configuration_data) + toml_parse_helpers.load_toml = Mock(return_value=configuration_data) actual_configuration: ConfigurationData = load_configuration(configuration_path=Mock()) assert actual_configuration == expected_configuration diff --git a/tests/test_catalog_updater.py b/tests/test_catalog_updater.py index 55585b8..49a1408 100644 --- a/tests/test_catalog_updater.py +++ b/tests/test_catalog_updater.py @@ -336,10 +336,8 @@ async def test_should_return_artifact_updates_when_there_are_libraries_and_plugi ) load_metadata_mock = AsyncMock(return_value={Mock(): Mock()}) - with ( - patch("kataloger.catalog_updater.load_catalog", Mock(return_value=([library], [plugin]))), - patch("kataloger.catalog_updater.get_all_artifact_metadata", new=load_metadata_mock), - ): + with patch(target="kataloger.catalog_updater.load_catalog", new=Mock(return_value=([library], [plugin]))), \ + patch(target="kataloger.catalog_updater.get_all_artifact_metadata", new=load_metadata_mock): artifact_updates: List[ArtifactUpdate] = await catalog_updater.get_catalog_updates(catalog_path=Mock()) assert artifact_updates == [library_update, plugin_update]