From 46b0bb4a641b5f401787398c98316eb47d2eccfc Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Thu, 15 Jan 2026 22:55:11 +0100 Subject: [PATCH 01/43] feat: add get_str_or_str_list function and update FileConfig with changelog_path - to make this work we will need a rework of the default handling in config base --- src/devops/config/base.py | 36 ++++++++++++++++++++++++++++++++ src/devops/config/config_file.py | 4 +++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/devops/config/base.py b/src/devops/config/base.py index e3e3634..6f1394a 100644 --- a/src/devops/config/base.py +++ b/src/devops/config/base.py @@ -139,6 +139,42 @@ def get_str( return _get_type(mapping, key, default, str) +def get_str_or_str_list( + mapping: dict[str, Any], key: str, default: str | list[str] | None = None +) -> str | list[str] | None: + """Get a string or list of strings from a mapping. + + Parameters + ---------- + mapping: dict[str, Any] + The mapping to extract the value from. + key: str + The key of the value. + default: str | list[str] | None + The default value to return if the key is not found. + + Returns + ------- + str | list[str] | None + The extracted string or list of strings value or None if the key is not found. + """ + value = mapping.get(key, default) + + if value is None: + return None + + if isinstance(value, str): + return get_str(mapping, key, default) + + if isinstance(value, list) and all(isinstance(item, str) for item in value): + return get_str_list( + mapping, key, default if isinstance(default, list) else None + ) + + msg = f"Expected str or list of str for key '{key}', got {type(value).__name__}" + raise ConfigError(msg) + + def get_str_enum( mapping: dict[str, Any], key: str, enum_type: type, default: str | None = None ) -> StrEnum | None: diff --git a/src/devops/config/config_file.py b/src/devops/config/config_file.py index 0b80bd5..ab7ae3c 100644 --- a/src/devops/config/config_file.py +++ b/src/devops/config/config_file.py @@ -1,6 +1,6 @@ """Module to parse file configuration values.""" -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from .base import ConfigError, get_str, get_table @@ -11,6 +11,7 @@ class FileConfig: """Dataclass to hold file configuration values.""" encoding: str = "utf-8" + changelog_path: list[Path] = field(default_factory=lambda: [Path("CHANGELOG.md")]) def to_toml_lines(self) -> list[str]: """Convert the FileConfig to TOML lines. @@ -48,6 +49,7 @@ def parse_file_config(raw_config: dict) -> FileConfig: encoding = get_str(table, "encoding", default=FileConfig.encoding) + ### Validate encoding try: Path(__file__).open("r", encoding=encoding).close() except LookupError as e: From 6a5e8c386f72976835348dafd1d30e2e5f64f876 Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Fri, 16 Jan 2026 20:31:53 +0100 Subject: [PATCH 02/43] feat: refactor _get_type function and enhance parsing for changelog paths in FileConfig --- src/devops/config/base.py | 36 ++++++++++++++-------- src/devops/config/config_file.py | 53 ++++++++++++++++++++++++++++++-- tests/config/test_config.py | 4 +-- 3 files changed, 76 insertions(+), 17 deletions(-) diff --git a/src/devops/config/base.py b/src/devops/config/base.py index 6f1394a..5400985 100644 --- a/src/devops/config/base.py +++ b/src/devops/config/base.py @@ -54,9 +54,7 @@ def get_table(mapping: dict[str, Any], key: str) -> dict[str, Any]: return value -def _get_type( - mapping: dict[str, Any], key: str, default: Any, expected_type: type -) -> Any: +def _get_type(mapping: dict[str, Any], key: str, expected_type: type) -> Any: """Get a value of expected type from a mapping. Parameters @@ -65,8 +63,6 @@ def _get_type( The mapping to extract the value from. key: str The key of the value. - default: Any - The default value to return if the key is not found. expected_type: type The expected type of the value. @@ -80,7 +76,7 @@ def _get_type( ConfigError If the value associated with the key is not of the expected type. """ - value = mapping.get(key, default) + value = mapping.get(key) if value is None: return None @@ -114,7 +110,12 @@ def get_bool( bool | None The extracted boolean value or None if the key is not found. """ - return _get_type(mapping, key, default, bool) + value = _get_type(mapping, key, bool) + + if value is None: + return default + + return value def get_str( @@ -136,7 +137,12 @@ def get_str( str | None The extracted string value or None if the key is not found. """ - return _get_type(mapping, key, default, str) + value = _get_type(mapping, key, str) + + if value is None: + return default + + return value def get_str_or_str_list( @@ -167,9 +173,7 @@ def get_str_or_str_list( return get_str(mapping, key, default) if isinstance(value, list) and all(isinstance(item, str) for item in value): - return get_str_list( - mapping, key, default if isinstance(default, list) else None - ) + return get_str_list(mapping, key, default) msg = f"Expected str or list of str for key '{key}', got {type(value).__name__}" raise ConfigError(msg) @@ -201,7 +205,10 @@ def get_str_enum( ConfigError If the value associated with the key is not a valid enum value. """ - value = _get_type(mapping, key, default, str) + value = _get_type(mapping, key, str) + + if value is None: + value = default if value is None: return None @@ -241,7 +248,10 @@ def get_str_list( If the value associated with the key is not a list of strings. """ - value = mapping.get(key, default) + value = _get_type(mapping, key, list) + + if value is None: + value = default if value is None: return [] diff --git a/src/devops/config/config_file.py b/src/devops/config/config_file.py index ab7ae3c..5e36059 100644 --- a/src/devops/config/config_file.py +++ b/src/devops/config/config_file.py @@ -3,7 +3,7 @@ from dataclasses import dataclass, field from pathlib import Path -from .base import ConfigError, get_str, get_table +from .base import ConfigError, get_str, get_str_or_str_list, get_table @dataclass(frozen=True) @@ -24,6 +24,8 @@ def to_toml_lines(self) -> list[str]: """ lines = ["[file]\n"] lines.append(f'#encoding = "{self.encoding}"\n') + paths_str = '", "'.join(str(p) for p in self.changelog_path) + lines.append(f'#changelog_path = ["{paths_str}"]\n') return lines @@ -47,6 +49,30 @@ def parse_file_config(raw_config: dict) -> FileConfig: """ table = get_table(raw_config, "file") + encoding = parse_encoding(table) + changelog_paths = parse_changelog_path(table) + + return FileConfig(encoding=encoding, changelog_path=changelog_paths) + + +def parse_encoding(table: dict) -> str: + """Parse the encoding from a configuration table. + + Parameters + ---------- + table: dict + The configuration table. + + Returns + ------- + str + The parsed encoding. + + Raises + ------ + ConfigError + If the specified encoding is invalid. + """ encoding = get_str(table, "encoding", default=FileConfig.encoding) ### Validate encoding @@ -56,4 +82,27 @@ def parse_file_config(raw_config: dict) -> FileConfig: msg = f"Invalid file encoding specified in configuration: {encoding}" raise ConfigError(msg) from e - return FileConfig(encoding=encoding) + return encoding + + +def parse_changelog_path(table: dict) -> list[Path]: + """Parse the changelog paths from a configuration table. + + Parameters + ---------- + table: dict + The configuration table. + + Returns + ------- + list[Path] + The parsed list of changelog paths. + """ + changelog_paths = get_str_or_str_list( + table, "changelog_path", FileConfig.changelog_path + ) + + if isinstance(changelog_paths, str): + changelog_paths = [changelog_paths] + + return [Path(p) for p in changelog_paths] diff --git a/tests/config/test_config.py b/tests/config/test_config.py index 5e41bac..a915789 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -92,7 +92,7 @@ def test_parse_config_buggy_cpp_macros_not_list() -> None: with pytest.raises(ConfigError) as exc_info: parse_config(raw_config) - assert "Expected list of strings for key" in str(exc_info.value) + assert "Expected list for key" in str(exc_info.value) assert "buggy_cpp_macros" in str(exc_info.value) @@ -122,7 +122,7 @@ def test_parse_config_buggy_cpp_macros_is_dict() -> None: with pytest.raises(ConfigError) as exc_info: parse_config(raw_config) - assert "Expected list of strings for key" in str(exc_info.value) + assert "Expected list for key" in str(exc_info.value) assert "buggy_cpp_macros" in str(exc_info.value) From a32d1a6896a90cdcc2efed1166c9cfb258b2704c Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Fri, 16 Jan 2026 23:22:14 +0100 Subject: [PATCH 03/43] feat: update FileConfig to support multiple changelog paths and default path handling --- src/devops/config/config_file.py | 50 +++++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/src/devops/config/config_file.py b/src/devops/config/config_file.py index 5e36059..19c337f 100644 --- a/src/devops/config/config_file.py +++ b/src/devops/config/config_file.py @@ -11,7 +11,22 @@ class FileConfig: """Dataclass to hold file configuration values.""" encoding: str = "utf-8" - changelog_path: list[Path] = field(default_factory=lambda: [Path("CHANGELOG.md")]) + changelog_paths: list[Path] = field(default_factory=lambda: [Path("CHANGELOG.md")]) + _default_changelog_path: Path | None = None + + @property + def default_changelog_path(self) -> Path: + """Get the default changelog path. + + Returns + ------- + Path + The default changelog path. + """ + if self._default_changelog_path is not None: + return self._default_changelog_path + + return self.changelog_paths[0] def to_toml_lines(self) -> list[str]: """Convert the FileConfig to TOML lines. @@ -24,8 +39,9 @@ def to_toml_lines(self) -> list[str]: """ lines = ["[file]\n"] lines.append(f'#encoding = "{self.encoding}"\n') - paths_str = '", "'.join(str(p) for p in self.changelog_path) - lines.append(f'#changelog_path = ["{paths_str}"]\n') + paths_str = '", "'.join(str(p) for p in self.changelog_paths) + lines.append(f'#changelog_paths = ["{paths_str}"]\n') + lines.append(f'#_default_changelog_path = "{self.default_changelog_path}"\n') return lines @@ -51,8 +67,13 @@ def parse_file_config(raw_config: dict) -> FileConfig: encoding = parse_encoding(table) changelog_paths = parse_changelog_path(table) + default_changelog_path = parse_default_changelog_path(table) - return FileConfig(encoding=encoding, changelog_path=changelog_paths) + return FileConfig( + encoding=encoding, + changelog_paths=changelog_paths, + _default_changelog_path=default_changelog_path, + ) def parse_encoding(table: dict) -> str: @@ -106,3 +127,24 @@ def parse_changelog_path(table: dict) -> list[Path]: changelog_paths = [changelog_paths] return [Path(p) for p in changelog_paths] + + +def parse_default_changelog_path(table: dict) -> Path | None: + """Parse the default changelog path from a configuration table. + + Parameters + ---------- + table: dict + The configuration table. + + Returns + ------- + Path | None + The parsed default changelog path, or None if not specified. + """ + default_path_str = get_str(table, "default_changelog_path", default=None) + + if default_path_str is not None: + return Path(default_path_str) + + return None From 88392539a51cb34fce5c973161632b854f88c033 Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Fri, 16 Jan 2026 23:25:06 +0100 Subject: [PATCH 04/43] docs: update changelog for changelog_paths config --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69a10d8..85530a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. ## Next Release +#### Config + +- Add `changelog_paths` to config toml approach +- Add `default_changelog_path` to config toml approach + ## [0.0.4](https://github.com/repo/owner/releases/tag/0.0.4) - 2025-12-20 From ea748116034f40d82a877a0f1032b3a6d4931f57 Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Sat, 17 Jan 2026 09:21:18 +0100 Subject: [PATCH 05/43] Update src/devops/config/config_file.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/devops/config/config_file.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/devops/config/config_file.py b/src/devops/config/config_file.py index 19c337f..096b060 100644 --- a/src/devops/config/config_file.py +++ b/src/devops/config/config_file.py @@ -120,7 +120,7 @@ def parse_changelog_path(table: dict) -> list[Path]: The parsed list of changelog paths. """ changelog_paths = get_str_or_str_list( - table, "changelog_path", FileConfig.changelog_path + table, "changelog_path", FileConfig.changelog_paths ) if isinstance(changelog_paths, str): From 7e9317f35e39aa7aaaa4e4f6ade67ff2e563007a Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Sat, 17 Jan 2026 09:22:48 +0100 Subject: [PATCH 06/43] Update src/devops/config/base.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/devops/config/base.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/devops/config/base.py b/src/devops/config/base.py index 5400985..72ce545 100644 --- a/src/devops/config/base.py +++ b/src/devops/config/base.py @@ -172,8 +172,14 @@ def get_str_or_str_list( if isinstance(value, str): return get_str(mapping, key, default) - if isinstance(value, list) and all(isinstance(item, str) for item in value): - return get_str_list(mapping, key, default) + if isinstance(value, list): + if all(isinstance(item, str) for item in value): + return get_str_list(mapping, key, default) + msg = ( + f"Expected str or list of str for key '{key}', " + "got list with non-string items" + ) + raise ConfigError(msg) msg = f"Expected str or list of str for key '{key}', got {type(value).__name__}" raise ConfigError(msg) From 860f600d3c077ba1ce41df32f10fe319d9b77d23 Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Sat, 17 Jan 2026 09:23:12 +0100 Subject: [PATCH 07/43] Update src/devops/config/config_file.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/devops/config/config_file.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/devops/config/config_file.py b/src/devops/config/config_file.py index 096b060..bc018bc 100644 --- a/src/devops/config/config_file.py +++ b/src/devops/config/config_file.py @@ -96,7 +96,7 @@ def parse_encoding(table: dict) -> str: """ encoding = get_str(table, "encoding", default=FileConfig.encoding) - ### Validate encoding + # Validate encoding try: Path(__file__).open("r", encoding=encoding).close() except LookupError as e: From 6f020e19b8ccb3cc0a3f2063764d78b82d3f88c2 Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Sat, 17 Jan 2026 09:23:45 +0100 Subject: [PATCH 08/43] Update src/devops/config/config_file.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/devops/config/config_file.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/devops/config/config_file.py b/src/devops/config/config_file.py index bc018bc..6b4d594 100644 --- a/src/devops/config/config_file.py +++ b/src/devops/config/config_file.py @@ -41,7 +41,7 @@ def to_toml_lines(self) -> list[str]: lines.append(f'#encoding = "{self.encoding}"\n') paths_str = '", "'.join(str(p) for p in self.changelog_paths) lines.append(f'#changelog_paths = ["{paths_str}"]\n') - lines.append(f'#_default_changelog_path = "{self.default_changelog_path}"\n') + lines.append(f'#default_changelog_path = "{self.default_changelog_path}"\n') return lines From f6434ac273e8949dcb898be27d755064ee1ada89 Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Sat, 17 Jan 2026 09:29:54 +0100 Subject: [PATCH 09/43] Update src/devops/config/config_file.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/devops/config/config_file.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/devops/config/config_file.py b/src/devops/config/config_file.py index 6b4d594..cb849a5 100644 --- a/src/devops/config/config_file.py +++ b/src/devops/config/config_file.py @@ -119,10 +119,17 @@ def parse_changelog_path(table: dict) -> list[Path]: list[Path] The parsed list of changelog paths. """ + # Use the same default as FileConfig, but expressed as a list of strings. + default_paths = [str(p) for p in FileConfig().changelog_paths] changelog_paths = get_str_or_str_list( - table, "changelog_path", FileConfig.changelog_paths + table, + "changelog_paths", + default=default_paths, ) + # If the configuration explicitly provides no value, fall back to the default. + if changelog_paths is None: + changelog_paths = default_paths if isinstance(changelog_paths, str): changelog_paths = [changelog_paths] From 51c0c57c150ded1a1b472668e5e1e72fa0a76393 Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Sat, 17 Jan 2026 09:30:24 +0100 Subject: [PATCH 10/43] Update src/devops/config/config_file.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/devops/config/config_file.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/devops/config/config_file.py b/src/devops/config/config_file.py index cb849a5..34d6724 100644 --- a/src/devops/config/config_file.py +++ b/src/devops/config/config_file.py @@ -26,6 +26,9 @@ def default_changelog_path(self) -> Path: if self._default_changelog_path is not None: return self._default_changelog_path + if not self.changelog_paths: + msg = "No changelog paths configured; cannot determine default changelog path." + raise ConfigError(msg) return self.changelog_paths[0] def to_toml_lines(self) -> list[str]: From 8dea888d965b402a7e4d4316aa187b49d50dd152 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 08:31:39 +0000 Subject: [PATCH 11/43] Initial plan From 6432e8994df175b33fb68a09dd53e50e77e0079f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 08:31:54 +0000 Subject: [PATCH 12/43] Initial plan From 6511ae96a179712b38a69e647a2e2b127f2522c0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 08:31:56 +0000 Subject: [PATCH 13/43] Initial plan From 26a70ba3519addf53cd69412d322c47ddb83bd17 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 08:32:19 +0000 Subject: [PATCH 14/43] Initial plan From 4549482a9f49cfd716f616512df3bead6eece529 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 08:32:20 +0000 Subject: [PATCH 15/43] Initial plan From db0aad08fe37c6a74690d35d4f331f2bbc22d41c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 08:34:11 +0000 Subject: [PATCH 16/43] Add comprehensive test coverage for parse_changelog_path function Co-authored-by: 97gamjak <77228802+97gamjak@users.noreply.github.com> --- tests/config/test_config_file.py | 116 +++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 tests/config/test_config_file.py diff --git a/tests/config/test_config_file.py b/tests/config/test_config_file.py new file mode 100644 index 0000000..4a6953f --- /dev/null +++ b/tests/config/test_config_file.py @@ -0,0 +1,116 @@ +"""Tests for devops.config.config_file module.""" + +from pathlib import Path + +import pytest + +from devops.config.base import ConfigError +from devops.config.config_file import parse_changelog_path + + +def test_parse_changelog_path_single_string() -> None: + """Test parsing a single string path.""" + table = {"changelog_paths": "CHANGELOG.md"} + result = parse_changelog_path(table) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0] == Path("CHANGELOG.md") + assert isinstance(result[0], Path) + + +def test_parse_changelog_path_list_of_paths() -> None: + """Test parsing a list of paths.""" + table = {"changelog_paths": ["CHANGELOG.md", "HISTORY.md", "docs/CHANGELOG.md"]} + result = parse_changelog_path(table) + + assert isinstance(result, list) + assert len(result) == 3 + assert result[0] == Path("CHANGELOG.md") + assert result[1] == Path("HISTORY.md") + assert result[2] == Path("docs/CHANGELOG.md") + assert all(isinstance(p, Path) for p in result) + + +def test_parse_changelog_path_missing_key_uses_default() -> None: + """Test that missing key uses default value.""" + table: dict[str, str] = {} + result = parse_changelog_path(table) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0] == Path("CHANGELOG.md") + assert isinstance(result[0], Path) + + +def test_parse_changelog_path_empty_table_uses_default() -> None: + """Test that empty table uses default value.""" + table: dict[str, str] = {} + result = parse_changelog_path(table) + + assert isinstance(result, list) + assert len(result) >= 1 + assert result[0] == Path("CHANGELOG.md") + + +def test_parse_changelog_path_invalid_type_integer() -> None: + """Test that invalid input type (integer) raises ConfigError.""" + table = {"changelog_paths": 123} + + with pytest.raises(ConfigError) as exc_info: + parse_changelog_path(table) + + assert "Expected str or list of str for key 'changelog_paths'" in str(exc_info.value) + assert "got int" in str(exc_info.value) + + +def test_parse_changelog_path_invalid_type_dict() -> None: + """Test that invalid input type (dict) raises ConfigError.""" + table = {"changelog_paths": {"path": "CHANGELOG.md"}} + + with pytest.raises(ConfigError) as exc_info: + parse_changelog_path(table) + + assert "Expected str or list of str for key 'changelog_paths'" in str(exc_info.value) + assert "got dict" in str(exc_info.value) + + +def test_parse_changelog_path_invalid_type_list_with_non_strings() -> None: + """Test that list with non-string elements raises ConfigError.""" + table = {"changelog_paths": ["CHANGELOG.md", 123, "HISTORY.md"]} + + with pytest.raises(ConfigError) as exc_info: + parse_changelog_path(table) + + assert "Expected str or list of str for key 'changelog_paths'" in str(exc_info.value) + assert "list with non-string items" in str(exc_info.value) + + +def test_parse_changelog_path_empty_list() -> None: + """Test parsing an empty list returns empty list.""" + table = {"changelog_paths": []} + result = parse_changelog_path(table) + + assert isinstance(result, list) + assert len(result) == 0 + + +def test_parse_changelog_path_single_item_list() -> None: + """Test parsing a single-item list.""" + table = {"changelog_paths": ["HISTORY.md"]} + result = parse_changelog_path(table) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0] == Path("HISTORY.md") + + +def test_parse_changelog_path_paths_with_subdirectories() -> None: + """Test parsing paths with subdirectories.""" + table = {"changelog_paths": ["docs/v1/CHANGELOG.md", "docs/v2/CHANGELOG.md"]} + result = parse_changelog_path(table) + + assert isinstance(result, list) + assert len(result) == 2 + assert result[0] == Path("docs/v1/CHANGELOG.md") + assert result[1] == Path("docs/v2/CHANGELOG.md") From fae9e5a3b97dd5afaca7f65167e960e3ab77f903 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 08:34:35 +0000 Subject: [PATCH 17/43] test: add comprehensive test coverage for parse_changelog_path function Co-authored-by: 97gamjak <77228802+97gamjak@users.noreply.github.com> --- tests/config/test_config_file.py | 92 ++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 tests/config/test_config_file.py diff --git a/tests/config/test_config_file.py b/tests/config/test_config_file.py new file mode 100644 index 0000000..48fbe0c --- /dev/null +++ b/tests/config/test_config_file.py @@ -0,0 +1,92 @@ +"""Tests for devops.config.config_file module.""" + +from pathlib import Path + +import pytest + +from devops.config.base import ConfigError +from devops.config.config_file import parse_changelog_path + + +def test_parse_changelog_path_with_single_string() -> None: + """Test parsing a single string path.""" + table = {"changelog_paths": "CHANGELOG.md"} + result = parse_changelog_path(table) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0] == Path("CHANGELOG.md") + + +def test_parse_changelog_path_with_list_of_paths() -> None: + """Test parsing a list of paths.""" + table = {"changelog_paths": ["CHANGELOG.md", "docs/HISTORY.md", "RELEASES.md"]} + result = parse_changelog_path(table) + + assert isinstance(result, list) + assert len(result) == 3 + assert result[0] == Path("CHANGELOG.md") + assert result[1] == Path("docs/HISTORY.md") + assert result[2] == Path("RELEASES.md") + + +def test_parse_changelog_path_with_missing_key() -> None: + """Test parsing with missing key returns default.""" + table: dict[str, str | list[str]] = {} + result = parse_changelog_path(table) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0] == Path("CHANGELOG.md") + + +def test_parse_changelog_path_with_none_value() -> None: + """Test parsing with None value returns default.""" + table = {"changelog_paths": None} + result = parse_changelog_path(table) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0] == Path("CHANGELOG.md") + + +def test_parse_changelog_path_with_invalid_type_int() -> None: + """Test parsing with invalid type (int) raises ConfigError.""" + table = {"changelog_paths": 42} + + with pytest.raises(ConfigError) as exc_info: + parse_changelog_path(table) + + assert "Expected str or list of str for key 'changelog_paths'" in str(exc_info.value) + assert "got int" in str(exc_info.value) + + +def test_parse_changelog_path_with_invalid_type_dict() -> None: + """Test parsing with invalid type (dict) raises ConfigError.""" + table = {"changelog_paths": {"path": "CHANGELOG.md"}} + + with pytest.raises(ConfigError) as exc_info: + parse_changelog_path(table) + + assert "Expected str or list of str for key 'changelog_paths'" in str(exc_info.value) + assert "got dict" in str(exc_info.value) + + +def test_parse_changelog_path_with_list_containing_non_strings() -> None: + """Test parsing with list containing non-string elements raises ConfigError.""" + table = {"changelog_paths": ["CHANGELOG.md", 42, "RELEASES.md"]} + + with pytest.raises(ConfigError) as exc_info: + parse_changelog_path(table) + + assert "Expected str or list of str for key 'changelog_paths'" in str(exc_info.value) + assert "got list with non-string items" in str(exc_info.value) + + +def test_parse_changelog_path_with_empty_list() -> None: + """Test parsing with empty list returns empty list.""" + table = {"changelog_paths": []} + result = parse_changelog_path(table) + + assert isinstance(result, list) + assert len(result) == 0 From 20be2047992cfbff54c9c12640148c83d3afec5c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 08:34:49 +0000 Subject: [PATCH 18/43] Add comprehensive tests for parse_default_changelog_path function Co-authored-by: 97gamjak <77228802+97gamjak@users.noreply.github.com> --- tests/config/test_config_file.py | 128 +++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 tests/config/test_config_file.py diff --git a/tests/config/test_config_file.py b/tests/config/test_config_file.py new file mode 100644 index 0000000..52f11af --- /dev/null +++ b/tests/config/test_config_file.py @@ -0,0 +1,128 @@ +"""Tests for devops.config.config_file module.""" + +from pathlib import Path + +import pytest + +from devops.config.base import ConfigError +from devops.config.config_file import ( + FileConfig, + parse_default_changelog_path, + parse_file_config, +) + + +class TestParseDefaultChangelogPath: + """Tests for parse_default_changelog_path function.""" + + def test_parse_default_changelog_path_with_valid_path(self) -> None: + """Test parsing a valid path string.""" + table = {"default_changelog_path": "docs/CHANGELOG.md"} + result = parse_default_changelog_path(table) + + assert result == Path("docs/CHANGELOG.md") + assert isinstance(result, Path) + + def test_parse_default_changelog_path_with_none(self) -> None: + """Test parsing when key is missing returns None.""" + table: dict[str, str] = {} + result = parse_default_changelog_path(table) + + assert result is None + + def test_parse_default_changelog_path_with_explicit_none(self) -> None: + """Test parsing when value is explicitly None.""" + table = {"default_changelog_path": None} + result = parse_default_changelog_path(table) + + assert result is None + + def test_parse_default_changelog_path_with_relative_path(self) -> None: + """Test parsing with a relative path.""" + table = {"default_changelog_path": "../CHANGES.md"} + result = parse_default_changelog_path(table) + + assert result == Path("../CHANGES.md") + + def test_parse_default_changelog_path_with_absolute_path(self) -> None: + """Test parsing with an absolute path.""" + table = {"default_changelog_path": "/usr/local/CHANGELOG.md"} + result = parse_default_changelog_path(table) + + assert result == Path("/usr/local/CHANGELOG.md") + + +class TestFileConfigDefaultChangelogPathInteraction: + """Tests for how default_changelog_path interacts with FileConfig.""" + + def test_file_config_uses_explicit_default_changelog_path(self) -> None: + """Test that FileConfig uses explicit default_changelog_path when provided.""" + raw_config = { + "file": { + "changelog_paths": ["CHANGELOG.md", "docs/CHANGELOG.md"], + "default_changelog_path": "docs/CHANGELOG.md", + } + } + result = parse_file_config(raw_config) + + assert result.default_changelog_path == Path("docs/CHANGELOG.md") + assert result.changelog_paths == [ + Path("CHANGELOG.md"), + Path("docs/CHANGELOG.md"), + ] + + def test_file_config_defaults_to_first_changelog_path(self) -> None: + """Test that FileConfig defaults to first changelog_path when no default specified.""" + raw_config = { + "file": { + "changelog_paths": ["CHANGELOG.md", "docs/CHANGELOG.md"], + } + } + result = parse_file_config(raw_config) + + assert result.default_changelog_path == Path("CHANGELOG.md") + + def test_file_config_default_changelog_path_with_single_path(self) -> None: + """Test default_changelog_path with single changelog path.""" + raw_config = { + "file": { + "changelog_paths": "CHANGELOG.md", + } + } + result = parse_file_config(raw_config) + + assert result.default_changelog_path == Path("CHANGELOG.md") + + def test_file_config_default_changelog_path_not_in_list(self) -> None: + """Test that default_changelog_path can be different from changelog_paths.""" + raw_config = { + "file": { + "changelog_paths": ["CHANGELOG.md"], + "default_changelog_path": "docs/CHANGES.md", + } + } + result = parse_file_config(raw_config) + + # The default_changelog_path doesn't need to be in changelog_paths + assert result.default_changelog_path == Path("docs/CHANGES.md") + assert result.changelog_paths == [Path("CHANGELOG.md")] + + def test_file_config_raises_error_when_no_paths_configured(self) -> None: + """Test that accessing default_changelog_path raises error when no paths configured.""" + # Create a FileConfig with empty changelog_paths and no default + config = FileConfig(changelog_paths=[], _default_changelog_path=None) + + with pytest.raises(ConfigError) as exc_info: + _ = config.default_changelog_path + + assert "No changelog paths configured" in str(exc_info.value) + assert "cannot determine default changelog path" in str(exc_info.value) + + def test_file_config_with_no_file_section(self) -> None: + """Test parsing config with no file section uses defaults.""" + raw_config: dict[str, dict[str, str]] = {} + result = parse_file_config(raw_config) + + # Should use defaults + assert result.default_changelog_path == Path("CHANGELOG.md") + assert result.changelog_paths == [Path("CHANGELOG.md")] From fc721034d9d8272cb52431901540d53aba84cb38 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 08:35:00 +0000 Subject: [PATCH 19/43] Remove duplicate test case Co-authored-by: 97gamjak <77228802+97gamjak@users.noreply.github.com> --- tests/config/test_config_file.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/tests/config/test_config_file.py b/tests/config/test_config_file.py index 4a6953f..8411fe4 100644 --- a/tests/config/test_config_file.py +++ b/tests/config/test_config_file.py @@ -43,16 +43,6 @@ def test_parse_changelog_path_missing_key_uses_default() -> None: assert isinstance(result[0], Path) -def test_parse_changelog_path_empty_table_uses_default() -> None: - """Test that empty table uses default value.""" - table: dict[str, str] = {} - result = parse_changelog_path(table) - - assert isinstance(result, list) - assert len(result) >= 1 - assert result[0] == Path("CHANGELOG.md") - - def test_parse_changelog_path_invalid_type_integer() -> None: """Test that invalid input type (integer) raises ConfigError.""" table = {"changelog_paths": 123} From e6ae2861d89cad3e090b25ab0246376fbb5a5e68 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 08:35:30 +0000 Subject: [PATCH 20/43] fix: simplify type annotation to match function signature Co-authored-by: 97gamjak <77228802+97gamjak@users.noreply.github.com> --- tests/config/test_config_file.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/config/test_config_file.py b/tests/config/test_config_file.py index 48fbe0c..60622a6 100644 --- a/tests/config/test_config_file.py +++ b/tests/config/test_config_file.py @@ -32,7 +32,7 @@ def test_parse_changelog_path_with_list_of_paths() -> None: def test_parse_changelog_path_with_missing_key() -> None: """Test parsing with missing key returns default.""" - table: dict[str, str | list[str]] = {} + table: dict = {} result = parse_changelog_path(table) assert isinstance(result, list) From efed2d615ae44d6235ae6241dbd5a95d851b8b13 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 08:35:39 +0000 Subject: [PATCH 21/43] Fix type annotation for empty dict in test Co-authored-by: 97gamjak <77228802+97gamjak@users.noreply.github.com> --- tests/config/test_config_file.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/config/test_config_file.py b/tests/config/test_config_file.py index 8411fe4..a749282 100644 --- a/tests/config/test_config_file.py +++ b/tests/config/test_config_file.py @@ -34,7 +34,7 @@ def test_parse_changelog_path_list_of_paths() -> None: def test_parse_changelog_path_missing_key_uses_default() -> None: """Test that missing key uses default value.""" - table: dict[str, str] = {} + table: dict = {} result = parse_changelog_path(table) assert isinstance(result, list) From ca2f02245a45d592c3bfdf184a10e1bf2008fae1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 08:36:07 +0000 Subject: [PATCH 22/43] Add comprehensive tests for parse_default_changelog_path function Co-authored-by: 97gamjak <77228802+97gamjak@users.noreply.github.com> --- tests/config/test_config_file.py | 138 +++++++++++++++++++ uv.lock | 226 +++++++++++++++++++++++++------ 2 files changed, 322 insertions(+), 42 deletions(-) create mode 100644 tests/config/test_config_file.py diff --git a/tests/config/test_config_file.py b/tests/config/test_config_file.py new file mode 100644 index 0000000..75df603 --- /dev/null +++ b/tests/config/test_config_file.py @@ -0,0 +1,138 @@ +"""Tests for devops.config.config_file module.""" + +from pathlib import Path + +import pytest + +from devops.config.base import ConfigError +from devops.config.config_file import ( + FileConfig, + parse_default_changelog_path, + parse_file_config, +) + + +def test_parse_default_changelog_path_with_valid_string() -> None: + """Test parse_default_changelog_path with a valid path string.""" + table = {"default_changelog_path": "CHANGELOG-custom.md"} + + result = parse_default_changelog_path(table) + + assert result == Path("CHANGELOG-custom.md") + assert isinstance(result, Path) + + +def test_parse_default_changelog_path_with_none_value() -> None: + """Test parse_default_changelog_path when key has None value.""" + table = {"default_changelog_path": None} + + result = parse_default_changelog_path(table) + + assert result is None + + +def test_parse_default_changelog_path_with_missing_key() -> None: + """Test parse_default_changelog_path when key is missing from table.""" + table: dict[str, str] = {} + + result = parse_default_changelog_path(table) + + assert result is None + + +def test_parse_default_changelog_path_with_different_paths() -> None: + """Test parse_default_changelog_path with various path formats.""" + test_cases = [ + "CHANGELOG.md", + "docs/CHANGELOG.md", + "path/to/CHANGELOG.txt", + "HISTORY.md", + ] + + for path_str in test_cases: + table = {"default_changelog_path": path_str} + result = parse_default_changelog_path(table) + assert result == Path(path_str) + assert isinstance(result, Path) + + +def test_parse_default_changelog_path_with_absolute_path() -> None: + """Test parse_default_changelog_path with an absolute path.""" + table = {"default_changelog_path": "/absolute/path/to/CHANGELOG.md"} + + result = parse_default_changelog_path(table) + + assert result == Path("/absolute/path/to/CHANGELOG.md") + + +def test_file_config_uses_default_changelog_path() -> None: + """Test FileConfig uses _default_changelog_path when set.""" + raw_config = { + "file": { + "changelog_paths": ["CHANGELOG1.md", "CHANGELOG2.md"], + "default_changelog_path": "CHANGELOG2.md", + } + } + + config = parse_file_config(raw_config) + + assert config.default_changelog_path == Path("CHANGELOG2.md") + assert config.changelog_paths == [Path("CHANGELOG1.md"), Path("CHANGELOG2.md")] + + +def test_file_config_default_path_fallback_to_first_changelog() -> None: + """Test FileConfig falls back to first changelog_path when no default set.""" + raw_config = { + "file": { + "changelog_paths": ["FIRST.md", "SECOND.md"], + } + } + + config = parse_file_config(raw_config) + + # Should fall back to first path in changelog_paths + assert config.default_changelog_path == Path("FIRST.md") + + +def test_file_config_default_path_independent_of_changelog_paths() -> None: + """Test default_changelog_path can be set independently of changelog_paths.""" + raw_config = { + "file": { + "changelog_paths": ["CHANGELOG1.md", "CHANGELOG2.md"], + "default_changelog_path": "CUSTOM.md", + } + } + + config = parse_file_config(raw_config) + + # default_changelog_path can be different from any in changelog_paths + assert config.default_changelog_path == Path("CUSTOM.md") + assert Path("CUSTOM.md") not in config.changelog_paths + + +def test_file_config_no_changelog_paths_and_no_default_raises_error() -> None: + """Test FileConfig raises error when no changelog paths configured and default accessed.""" + # This scenario shouldn't happen in normal usage since parse_file_config + # provides defaults, but we test the property behavior directly + config = FileConfig(changelog_paths=[], _default_changelog_path=None) + + with pytest.raises(ConfigError) as exc_info: + _ = config.default_changelog_path + + assert "No changelog paths configured" in str(exc_info.value) + + +def test_file_config_with_only_default_path_set() -> None: + """Test FileConfig when only default_changelog_path is explicitly set.""" + raw_config = { + "file": { + "default_changelog_path": "SPECIFIC.md", + } + } + + config = parse_file_config(raw_config) + + # changelog_paths should use the default + assert config.changelog_paths == [Path("CHANGELOG.md")] + # default_changelog_path should be the explicitly set one + assert config.default_changelog_path == Path("SPECIFIC.md") diff --git a/uv.lock b/uv.lock index e189042..fb2bae2 100644 --- a/uv.lock +++ b/uv.lock @@ -2,23 +2,6 @@ version = 1 revision = 3 requires-python = ">=3.12" -[[package]] -name = "checks" -version = "0.1.0" -source = { virtual = "." } -dependencies = [ - { name = "pytest" }, - { name = "ruff" }, - { name = "typer" }, -] - -[package.metadata] -requires-dist = [ - { name = "pytest", specifier = ">=9.0.1" }, - { name = "ruff", specifier = ">=0.14.6" }, - { name = "typer", specifier = ">=0.20.0" }, -] - [[package]] name = "click" version = "8.3.1" @@ -40,6 +23,119 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/f9/e92df5e07f3fc8d4c7f9a0f146ef75446bf870351cd37b788cf5897f8079/coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd", size = 825862, upload-time = "2025-12-28T15:42:56.969Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8a/87af46cccdfa78f53db747b09f5f9a21d5fc38d796834adac09b30a8ce74/coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3", size = 218927, upload-time = "2025-12-28T15:40:52.814Z" }, + { url = "https://files.pythonhosted.org/packages/82/a8/6e22fdc67242a4a5a153f9438d05944553121c8f4ba70cb072af4c41362e/coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e", size = 219288, upload-time = "2025-12-28T15:40:54.262Z" }, + { url = "https://files.pythonhosted.org/packages/d0/0a/853a76e03b0f7c4375e2ca025df45c918beb367f3e20a0a8e91967f6e96c/coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c", size = 250786, upload-time = "2025-12-28T15:40:56.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b4/694159c15c52b9f7ec7adf49d50e5f8ee71d3e9ef38adb4445d13dd56c20/coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62", size = 253543, upload-time = "2025-12-28T15:40:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/96/b2/7f1f0437a5c855f87e17cf5d0dc35920b6440ff2b58b1ba9788c059c26c8/coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968", size = 254635, upload-time = "2025-12-28T15:40:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d1/73c3fdb8d7d3bddd9473c9c6a2e0682f09fc3dfbcb9c3f36412a7368bcab/coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e", size = 251202, upload-time = "2025-12-28T15:41:01.328Z" }, + { url = "https://files.pythonhosted.org/packages/66/3c/f0edf75dcc152f145d5598329e864bbbe04ab78660fe3e8e395f9fff010f/coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f", size = 252566, upload-time = "2025-12-28T15:41:03.319Z" }, + { url = "https://files.pythonhosted.org/packages/17/b3/e64206d3c5f7dcbceafd14941345a754d3dbc78a823a6ed526e23b9cdaab/coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee", size = 250711, upload-time = "2025-12-28T15:41:06.411Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ad/28a3eb970a8ef5b479ee7f0c484a19c34e277479a5b70269dc652b730733/coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf", size = 250278, upload-time = "2025-12-28T15:41:08.285Z" }, + { url = "https://files.pythonhosted.org/packages/54/e3/c8f0f1a93133e3e1291ca76cbb63565bd4b5c5df63b141f539d747fff348/coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c", size = 252154, upload-time = "2025-12-28T15:41:09.969Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bf/9939c5d6859c380e405b19e736321f1c7d402728792f4c752ad1adcce005/coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7", size = 221487, upload-time = "2025-12-28T15:41:11.468Z" }, + { url = "https://files.pythonhosted.org/packages/fa/dc/7282856a407c621c2aad74021680a01b23010bb8ebf427cf5eacda2e876f/coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6", size = 222299, upload-time = "2025-12-28T15:41:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/10/79/176a11203412c350b3e9578620013af35bcdb79b651eb976f4a4b32044fa/coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c", size = 220941, upload-time = "2025-12-28T15:41:14.975Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a4/e98e689347a1ff1a7f67932ab535cef82eb5e78f32a9e4132e114bbb3a0a/coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78", size = 218951, upload-time = "2025-12-28T15:41:16.653Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/7cbfe2bdc6e2f03d6b240d23dc45fdaf3fd270aaf2d640be77b7f16989ab/coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b", size = 219325, upload-time = "2025-12-28T15:41:18.609Z" }, + { url = "https://files.pythonhosted.org/packages/59/f6/efdabdb4929487baeb7cb2a9f7dac457d9356f6ad1b255be283d58b16316/coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd", size = 250309, upload-time = "2025-12-28T15:41:20.629Z" }, + { url = "https://files.pythonhosted.org/packages/12/da/91a52516e9d5aea87d32d1523f9cdcf7a35a3b298e6be05d6509ba3cfab2/coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992", size = 252907, upload-time = "2025-12-28T15:41:22.257Z" }, + { url = "https://files.pythonhosted.org/packages/75/38/f1ea837e3dc1231e086db1638947e00d264e7e8c41aa8ecacf6e1e0c05f4/coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4", size = 254148, upload-time = "2025-12-28T15:41:23.87Z" }, + { url = "https://files.pythonhosted.org/packages/7f/43/f4f16b881aaa34954ba446318dea6b9ed5405dd725dd8daac2358eda869a/coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a", size = 250515, upload-time = "2025-12-28T15:41:25.437Z" }, + { url = "https://files.pythonhosted.org/packages/84/34/8cba7f00078bd468ea914134e0144263194ce849ec3baad187ffb6203d1c/coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766", size = 252292, upload-time = "2025-12-28T15:41:28.459Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/cffac66c7652d84ee4ac52d3ccb94c015687d3b513f9db04bfcac2ac800d/coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4", size = 250242, upload-time = "2025-12-28T15:41:30.02Z" }, + { url = "https://files.pythonhosted.org/packages/f4/78/9a64d462263dde416f3c0067efade7b52b52796f489b1037a95b0dc389c9/coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398", size = 250068, upload-time = "2025-12-28T15:41:32.007Z" }, + { url = "https://files.pythonhosted.org/packages/69/c8/a8994f5fece06db7c4a97c8fc1973684e178599b42e66280dded0524ef00/coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784", size = 251846, upload-time = "2025-12-28T15:41:33.946Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f7/91fa73c4b80305c86598a2d4e54ba22df6bf7d0d97500944af7ef155d9f7/coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461", size = 221512, upload-time = "2025-12-28T15:41:35.519Z" }, + { url = "https://files.pythonhosted.org/packages/45/0b/0768b4231d5a044da8f75e097a8714ae1041246bb765d6b5563bab456735/coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500", size = 222321, upload-time = "2025-12-28T15:41:37.371Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b8/bdcb7253b7e85157282450262008f1366aa04663f3e3e4c30436f596c3e2/coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9", size = 220949, upload-time = "2025-12-28T15:41:39.553Z" }, + { url = "https://files.pythonhosted.org/packages/70/52/f2be52cc445ff75ea8397948c96c1b4ee14f7f9086ea62fc929c5ae7b717/coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc", size = 219643, upload-time = "2025-12-28T15:41:41.567Z" }, + { url = "https://files.pythonhosted.org/packages/47/79/c85e378eaa239e2edec0c5523f71542c7793fe3340954eafb0bc3904d32d/coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a", size = 219997, upload-time = "2025-12-28T15:41:43.418Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9b/b1ade8bfb653c0bbce2d6d6e90cc6c254cbb99b7248531cc76253cb4da6d/coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4", size = 261296, upload-time = "2025-12-28T15:41:45.207Z" }, + { url = "https://files.pythonhosted.org/packages/1f/af/ebf91e3e1a2473d523e87e87fd8581e0aa08741b96265730e2d79ce78d8d/coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6", size = 263363, upload-time = "2025-12-28T15:41:47.163Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8b/fb2423526d446596624ac7fde12ea4262e66f86f5120114c3cfd0bb2befa/coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1", size = 265783, upload-time = "2025-12-28T15:41:49.03Z" }, + { url = "https://files.pythonhosted.org/packages/9b/26/ef2adb1e22674913b89f0fe7490ecadcef4a71fa96f5ced90c60ec358789/coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd", size = 260508, upload-time = "2025-12-28T15:41:51.035Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7d/f0f59b3404caf662e7b5346247883887687c074ce67ba453ea08c612b1d5/coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c", size = 263357, upload-time = "2025-12-28T15:41:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b1/29896492b0b1a047604d35d6fa804f12818fa30cdad660763a5f3159e158/coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0", size = 260978, upload-time = "2025-12-28T15:41:54.589Z" }, + { url = "https://files.pythonhosted.org/packages/48/f2/971de1238a62e6f0a4128d37adadc8bb882ee96afbe03ff1570291754629/coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e", size = 259877, upload-time = "2025-12-28T15:41:56.263Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fc/0474efcbb590ff8628830e9aaec5f1831594874360e3251f1fdec31d07a3/coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53", size = 262069, upload-time = "2025-12-28T15:41:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/88/4f/3c159b7953db37a7b44c0eab8a95c37d1aa4257c47b4602c04022d5cb975/coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842", size = 222184, upload-time = "2025-12-28T15:41:59.763Z" }, + { url = "https://files.pythonhosted.org/packages/58/a5/6b57d28f81417f9335774f20679d9d13b9a8fb90cd6160957aa3b54a2379/coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2", size = 223250, upload-time = "2025-12-28T15:42:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/81/7c/160796f3b035acfbb58be80e02e484548595aa67e16a6345e7910ace0a38/coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09", size = 221521, upload-time = "2025-12-28T15:42:03.275Z" }, + { url = "https://files.pythonhosted.org/packages/aa/8e/ba0e597560c6563fc0adb902fda6526df5d4aa73bb10adf0574d03bd2206/coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894", size = 218996, upload-time = "2025-12-28T15:42:04.978Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8e/764c6e116f4221dc7aa26c4061181ff92edb9c799adae6433d18eeba7a14/coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a", size = 219326, upload-time = "2025-12-28T15:42:06.691Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a6/6130dc6d8da28cdcbb0f2bf8865aeca9b157622f7c0031e48c6cf9a0e591/coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f", size = 250374, upload-time = "2025-12-28T15:42:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/82/2b/783ded568f7cd6b677762f780ad338bf4b4750205860c17c25f7c708995e/coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909", size = 252882, upload-time = "2025-12-28T15:42:10.515Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b2/9808766d082e6a4d59eb0cc881a57fc1600eb2c5882813eefff8254f71b5/coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4", size = 254218, upload-time = "2025-12-28T15:42:12.208Z" }, + { url = "https://files.pythonhosted.org/packages/44/ea/52a985bb447c871cb4d2e376e401116520991b597c85afdde1ea9ef54f2c/coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75", size = 250391, upload-time = "2025-12-28T15:42:14.21Z" }, + { url = "https://files.pythonhosted.org/packages/7f/1d/125b36cc12310718873cfc8209ecfbc1008f14f4f5fa0662aa608e579353/coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9", size = 252239, upload-time = "2025-12-28T15:42:16.292Z" }, + { url = "https://files.pythonhosted.org/packages/6a/16/10c1c164950cade470107f9f14bbac8485f8fb8515f515fca53d337e4a7f/coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465", size = 250196, upload-time = "2025-12-28T15:42:18.54Z" }, + { url = "https://files.pythonhosted.org/packages/2a/c6/cd860fac08780c6fd659732f6ced1b40b79c35977c1356344e44d72ba6c4/coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864", size = 250008, upload-time = "2025-12-28T15:42:20.365Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/a8c58d3d38f82a5711e1e0a67268362af48e1a03df27c03072ac30feefcf/coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9", size = 251671, upload-time = "2025-12-28T15:42:22.114Z" }, + { url = "https://files.pythonhosted.org/packages/f0/bc/fd4c1da651d037a1e3d53e8cb3f8182f4b53271ffa9a95a2e211bacc0349/coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5", size = 221777, upload-time = "2025-12-28T15:42:23.919Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/71acabdc8948464c17e90b5ffd92358579bd0910732c2a1c9537d7536aa6/coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a", size = 222592, upload-time = "2025-12-28T15:42:25.619Z" }, + { url = "https://files.pythonhosted.org/packages/f7/c8/a6fb943081bb0cc926499c7907731a6dc9efc2cbdc76d738c0ab752f1a32/coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0", size = 221169, upload-time = "2025-12-28T15:42:27.629Z" }, + { url = "https://files.pythonhosted.org/packages/16/61/d5b7a0a0e0e40d62e59bc8c7aa1afbd86280d82728ba97f0673b746b78e2/coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a", size = 219730, upload-time = "2025-12-28T15:42:29.306Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2c/8881326445fd071bb49514d1ce97d18a46a980712b51fee84f9ab42845b4/coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6", size = 220001, upload-time = "2025-12-28T15:42:31.319Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d7/50de63af51dfa3a7f91cc37ad8fcc1e244b734232fbc8b9ab0f3c834a5cd/coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673", size = 261370, upload-time = "2025-12-28T15:42:32.992Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2c/d31722f0ec918fd7453b2758312729f645978d212b410cd0f7c2aed88a94/coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5", size = 263485, upload-time = "2025-12-28T15:42:34.759Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7a/2c114fa5c5fc08ba0777e4aec4c97e0b4a1afcb69c75f1f54cff78b073ab/coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d", size = 265890, upload-time = "2025-12-28T15:42:36.517Z" }, + { url = "https://files.pythonhosted.org/packages/65/d9/f0794aa1c74ceabc780fe17f6c338456bbc4e96bd950f2e969f48ac6fb20/coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8", size = 260445, upload-time = "2025-12-28T15:42:38.646Z" }, + { url = "https://files.pythonhosted.org/packages/49/23/184b22a00d9bb97488863ced9454068c79e413cb23f472da6cbddc6cfc52/coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486", size = 263357, upload-time = "2025-12-28T15:42:40.788Z" }, + { url = "https://files.pythonhosted.org/packages/7d/bd/58af54c0c9199ea4190284f389005779d7daf7bf3ce40dcd2d2b2f96da69/coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564", size = 260959, upload-time = "2025-12-28T15:42:42.808Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2a/6839294e8f78a4891bf1df79d69c536880ba2f970d0ff09e7513d6e352e9/coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7", size = 259792, upload-time = "2025-12-28T15:42:44.818Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c3/528674d4623283310ad676c5af7414b9850ab6d55c2300e8aa4b945ec554/coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416", size = 262123, upload-time = "2025-12-28T15:42:47.108Z" }, + { url = "https://files.pythonhosted.org/packages/06/c5/8c0515692fb4c73ac379d8dc09b18eaf0214ecb76ea6e62467ba7a1556ff/coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f", size = 222562, upload-time = "2025-12-28T15:42:49.144Z" }, + { url = "https://files.pythonhosted.org/packages/05/0e/c0a0c4678cb30dac735811db529b321d7e1c9120b79bd728d4f4d6b010e9/coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79", size = 223670, upload-time = "2025-12-28T15:42:51.218Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/b177aa0011f354abf03a8f30a85032686d290fdeed4222b27d36b4372a50/coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4", size = 221707, upload-time = "2025-12-28T15:42:53.034Z" }, + { url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" }, +] + +[[package]] +name = "devops" +source = { editable = "." } +dependencies = [ + { name = "typer" }, +] + +[package.optional-dependencies] +test = [ + { name = "coverage" }, + { name = "docstr-coverage" }, + { name = "pytest" }, + { name = "pytest-cov" }, +] + +[package.metadata] +requires-dist = [ + { name = "coverage", marker = "extra == 'test'" }, + { name = "docstr-coverage", marker = "extra == 'test'" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=9.0.1" }, + { name = "pytest-cov", marker = "extra == 'test'" }, + { name = "typer", specifier = ">=0.20.0" }, +] +provides-extras = ["test"] + +[[package]] +name = "docstr-coverage" +version = "2.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "pyyaml" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/86/d3f02e5baf426eac0f039849272204649137449f050d3fe0eb104c6f399c/docstr-coverage-2.3.2.tar.gz", hash = "sha256:e99a28c502ed21ae8a310cb9e14e8de2d7cff44d365b46fa6dca6de05bf156a0", size = 26750, upload-time = "2024-05-07T16:54:33.514Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/f7/7923d915a80aa2e04939260f7a000e1a353b25c7ffcf7771cd777559c27a/docstr_coverage-2.3.2-py3-none-any.whl", hash = "sha256:37a885d6560ad87e289b23bf0e54527885bacbf6b17cf55278d514dd0fef8ff5", size = 25816, upload-time = "2024-05-07T16:54:31.636Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -113,6 +209,66 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668, upload-time = "2025-11-12T13:05:07.379Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "rich" version = "14.2.0" @@ -127,38 +283,24 @@ wheels = [ ] [[package]] -name = "ruff" -version = "0.14.6" +name = "shellingham" +version = "1.5.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/52/f0/62b5a1a723fe183650109407fa56abb433b00aa1c0b9ba555f9c4efec2c6/ruff-0.14.6.tar.gz", hash = "sha256:6f0c742ca6a7783a736b867a263b9a7a80a45ce9bee391eeda296895f1b4e1cc", size = 5669501, upload-time = "2025-11-21T14:26:17.903Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/67/d2/7dd544116d107fffb24a0064d41a5d2ed1c9d6372d142f9ba108c8e39207/ruff-0.14.6-py3-none-linux_armv6l.whl", hash = "sha256:d724ac2f1c240dbd01a2ae98db5d1d9a5e1d9e96eba999d1c48e30062df578a3", size = 13326119, upload-time = "2025-11-21T14:25:24.2Z" }, - { url = "https://files.pythonhosted.org/packages/36/6a/ad66d0a3315d6327ed6b01f759d83df3c4d5f86c30462121024361137b6a/ruff-0.14.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9f7539ea257aa4d07b7ce87aed580e485c40143f2473ff2f2b75aee003186004", size = 13526007, upload-time = "2025-11-21T14:25:26.906Z" }, - { url = "https://files.pythonhosted.org/packages/a3/9d/dae6db96df28e0a15dea8e986ee393af70fc97fd57669808728080529c37/ruff-0.14.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7f6007e55b90a2a7e93083ba48a9f23c3158c433591c33ee2e99a49b889c6332", size = 12676572, upload-time = "2025-11-21T14:25:29.826Z" }, - { url = "https://files.pythonhosted.org/packages/76/a4/f319e87759949062cfee1b26245048e92e2acce900ad3a909285f9db1859/ruff-0.14.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a8e7b9d73d8728b68f632aa8e824ef041d068d231d8dbc7808532d3629a6bef", size = 13140745, upload-time = "2025-11-21T14:25:32.788Z" }, - { url = "https://files.pythonhosted.org/packages/95/d3/248c1efc71a0a8ed4e8e10b4b2266845d7dfc7a0ab64354afe049eaa1310/ruff-0.14.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d50d45d4553a3ebcbd33e7c5e0fe6ca4aafd9a9122492de357205c2c48f00775", size = 13076486, upload-time = "2025-11-21T14:25:35.601Z" }, - { url = "https://files.pythonhosted.org/packages/a5/19/b68d4563fe50eba4b8c92aa842149bb56dd24d198389c0ed12e7faff4f7d/ruff-0.14.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:118548dd121f8a21bfa8ab2c5b80e5b4aed67ead4b7567790962554f38e598ce", size = 13727563, upload-time = "2025-11-21T14:25:38.514Z" }, - { url = "https://files.pythonhosted.org/packages/47/ac/943169436832d4b0e867235abbdb57ce3a82367b47e0280fa7b4eabb7593/ruff-0.14.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:57256efafbfefcb8748df9d1d766062f62b20150691021f8ab79e2d919f7c11f", size = 15199755, upload-time = "2025-11-21T14:25:41.516Z" }, - { url = "https://files.pythonhosted.org/packages/c9/b9/288bb2399860a36d4bb0541cb66cce3c0f4156aaff009dc8499be0c24bf2/ruff-0.14.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff18134841e5c68f8e5df1999a64429a02d5549036b394fafbe410f886e1989d", size = 14850608, upload-time = "2025-11-21T14:25:44.428Z" }, - { url = "https://files.pythonhosted.org/packages/ee/b1/a0d549dd4364e240f37e7d2907e97ee80587480d98c7799d2d8dc7a2f605/ruff-0.14.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29c4b7ec1e66a105d5c27bd57fa93203637d66a26d10ca9809dc7fc18ec58440", size = 14118754, upload-time = "2025-11-21T14:25:47.214Z" }, - { url = "https://files.pythonhosted.org/packages/13/ac/9b9fe63716af8bdfddfacd0882bc1586f29985d3b988b3c62ddce2e202c3/ruff-0.14.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:167843a6f78680746d7e226f255d920aeed5e4ad9c03258094a2d49d3028b105", size = 13949214, upload-time = "2025-11-21T14:25:50.002Z" }, - { url = "https://files.pythonhosted.org/packages/12/27/4dad6c6a77fede9560b7df6802b1b697e97e49ceabe1f12baf3ea20862e9/ruff-0.14.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:16a33af621c9c523b1ae006b1b99b159bf5ac7e4b1f20b85b2572455018e0821", size = 14106112, upload-time = "2025-11-21T14:25:52.841Z" }, - { url = "https://files.pythonhosted.org/packages/6a/db/23e322d7177873eaedea59a7932ca5084ec5b7e20cb30f341ab594130a71/ruff-0.14.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1432ab6e1ae2dc565a7eea707d3b03a0c234ef401482a6f1621bc1f427c2ff55", size = 13035010, upload-time = "2025-11-21T14:25:55.536Z" }, - { url = "https://files.pythonhosted.org/packages/a8/9c/20e21d4d69dbb35e6a1df7691e02f363423658a20a2afacf2a2c011800dc/ruff-0.14.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4c55cfbbe7abb61eb914bfd20683d14cdfb38a6d56c6c66efa55ec6570ee4e71", size = 13054082, upload-time = "2025-11-21T14:25:58.625Z" }, - { url = "https://files.pythonhosted.org/packages/66/25/906ee6a0464c3125c8d673c589771a974965c2be1a1e28b5c3b96cb6ef88/ruff-0.14.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:efea3c0f21901a685fff4befda6d61a1bf4cb43de16da87e8226a281d614350b", size = 13303354, upload-time = "2025-11-21T14:26:01.816Z" }, - { url = "https://files.pythonhosted.org/packages/4c/58/60577569e198d56922b7ead07b465f559002b7b11d53f40937e95067ca1c/ruff-0.14.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:344d97172576d75dc6afc0e9243376dbe1668559c72de1864439c4fc95f78185", size = 14054487, upload-time = "2025-11-21T14:26:05.058Z" }, - { url = "https://files.pythonhosted.org/packages/67/0b/8e4e0639e4cc12547f41cb771b0b44ec8225b6b6a93393176d75fe6f7d40/ruff-0.14.6-py3-none-win32.whl", hash = "sha256:00169c0c8b85396516fdd9ce3446c7ca20c2a8f90a77aa945ba6b8f2bfe99e85", size = 13013361, upload-time = "2025-11-21T14:26:08.152Z" }, - { url = "https://files.pythonhosted.org/packages/fb/02/82240553b77fd1341f80ebb3eaae43ba011c7a91b4224a9f317d8e6591af/ruff-0.14.6-py3-none-win_amd64.whl", hash = "sha256:390e6480c5e3659f8a4c8d6a0373027820419ac14fa0d2713bd8e6c3e125b8b9", size = 14432087, upload-time = "2025-11-21T14:26:10.891Z" }, - { url = "https://files.pythonhosted.org/packages/a5/1f/93f9b0fad9470e4c829a5bb678da4012f0c710d09331b860ee555216f4ea/ruff-0.14.6-py3-none-win_arm64.whl", hash = "sha256:d43c81fbeae52cfa8728d8766bbf46ee4298c888072105815b392da70ca836b2", size = 13520930, upload-time = "2025-11-21T14:26:13.951Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] [[package]] -name = "shellingham" -version = "1.5.4" +name = "tqdm" +version = "4.67.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, + { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, ] [[package]] From 510dc32b2ae5697c584fa530241c154e63ccde3e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 08:37:12 +0000 Subject: [PATCH 23/43] Fix type annotation in test_parse_default_changelog_path_with_missing_key Co-authored-by: 97gamjak <77228802+97gamjak@users.noreply.github.com> --- tests/config/test_config_file.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/config/test_config_file.py b/tests/config/test_config_file.py index 75df603..c04094c 100644 --- a/tests/config/test_config_file.py +++ b/tests/config/test_config_file.py @@ -33,7 +33,7 @@ def test_parse_default_changelog_path_with_none_value() -> None: def test_parse_default_changelog_path_with_missing_key() -> None: """Test parse_default_changelog_path when key is missing from table.""" - table: dict[str, str] = {} + table: dict = {} result = parse_default_changelog_path(table) From 24f4e11c1c3bd1ce978a6610712a83f1230d5237 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 08:41:08 +0000 Subject: [PATCH 24/43] feat: add comprehensive test coverage for get_str_or_str_list function Co-authored-by: 97gamjak <77228802+97gamjak@users.noreply.github.com> --- tests/config/test_base.py | 167 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 tests/config/test_base.py diff --git a/tests/config/test_base.py b/tests/config/test_base.py new file mode 100644 index 0000000..e220244 --- /dev/null +++ b/tests/config/test_base.py @@ -0,0 +1,167 @@ +"""Tests for devops.config.base module.""" + +import pytest + +from devops.config.base import ConfigError, get_str_or_str_list + + +class TestGetStrOrStrList: + """Tests for the get_str_or_str_list function.""" + + def test_string_value(self) -> None: + """Test that a string value is returned correctly.""" + mapping = {"key": "value"} + result = get_str_or_str_list(mapping, "key") + assert result == "value" + assert isinstance(result, str) + + def test_list_of_strings(self) -> None: + """Test that a list of strings is returned correctly.""" + mapping = {"key": ["value1", "value2", "value3"]} + result = get_str_or_str_list(mapping, "key") + assert result == ["value1", "value2", "value3"] + assert isinstance(result, list) + assert all(isinstance(item, str) for item in result) + + def test_empty_list_of_strings(self) -> None: + """Test that an empty list of strings is returned correctly.""" + mapping = {"key": []} + result = get_str_or_str_list(mapping, "key") + assert result == [] + assert isinstance(result, list) + + def test_single_element_list(self) -> None: + """Test that a single-element list is returned correctly.""" + mapping = {"key": ["single"]} + result = get_str_or_str_list(mapping, "key") + assert result == ["single"] + assert isinstance(result, list) + + def test_mixed_list_with_non_strings(self) -> None: + """Test that a list with non-string items raises ConfigError.""" + mapping = {"key": ["string", 123, "another"]} + with pytest.raises(ConfigError) as exc_info: + get_str_or_str_list(mapping, "key") + + assert "Expected str or list of str for key 'key'" in str(exc_info.value) + assert "got list with non-string items" in str(exc_info.value) + + def test_list_with_only_integers(self) -> None: + """Test that a list with only integers raises ConfigError.""" + mapping = {"key": [1, 2, 3]} + with pytest.raises(ConfigError) as exc_info: + get_str_or_str_list(mapping, "key") + + assert "Expected str or list of str for key 'key'" in str(exc_info.value) + assert "got list with non-string items" in str(exc_info.value) + + def test_list_with_nested_list(self) -> None: + """Test that a list with nested list raises ConfigError.""" + mapping = {"key": ["string", ["nested"]]} + with pytest.raises(ConfigError) as exc_info: + get_str_or_str_list(mapping, "key") + + assert "Expected str or list of str for key 'key'" in str(exc_info.value) + assert "got list with non-string items" in str(exc_info.value) + + def test_integer_type(self) -> None: + """Test that an integer type raises ConfigError.""" + mapping = {"key": 123} + with pytest.raises(ConfigError) as exc_info: + get_str_or_str_list(mapping, "key") + + assert "Expected str or list of str for key 'key'" in str(exc_info.value) + assert "got int" in str(exc_info.value) + + def test_float_type(self) -> None: + """Test that a float type raises ConfigError.""" + mapping = {"key": 3.14} + with pytest.raises(ConfigError) as exc_info: + get_str_or_str_list(mapping, "key") + + assert "Expected str or list of str for key 'key'" in str(exc_info.value) + assert "got float" in str(exc_info.value) + + def test_dict_type(self) -> None: + """Test that a dict type raises ConfigError.""" + mapping = {"key": {"nested": "value"}} + with pytest.raises(ConfigError) as exc_info: + get_str_or_str_list(mapping, "key") + + assert "Expected str or list of str for key 'key'" in str(exc_info.value) + assert "got dict" in str(exc_info.value) + + def test_boolean_type(self) -> None: + """Test that a boolean type raises ConfigError.""" + mapping = {"key": True} + with pytest.raises(ConfigError) as exc_info: + get_str_or_str_list(mapping, "key") + + assert "Expected str or list of str for key 'key'" in str(exc_info.value) + assert "got bool" in str(exc_info.value) + + def test_missing_key_no_default(self) -> None: + """Test that a missing key returns None when no default is provided.""" + mapping = {"other_key": "value"} + result = get_str_or_str_list(mapping, "key") + assert result is None + + def test_missing_key_with_string_default(self) -> None: + """Test that a missing key returns the string default value.""" + mapping = {"other_key": "value"} + result = get_str_or_str_list(mapping, "key", default="default_value") + assert result == "default_value" + assert isinstance(result, str) + + def test_missing_key_with_list_default(self) -> None: + """Test that a missing key returns the list default value.""" + mapping = {"other_key": "value"} + result = get_str_or_str_list(mapping, "key", default=["default1", "default2"]) + assert result == ["default1", "default2"] + assert isinstance(result, list) + + def test_missing_key_with_none_default(self) -> None: + """Test that a missing key returns None when None is explicitly set as default.""" + mapping = {"other_key": "value"} + result = get_str_or_str_list(mapping, "key", default=None) + assert result is None + + def test_none_value_with_default(self) -> None: + """Test that an explicit None value returns None even with a default.""" + mapping = {"key": None} + result = get_str_or_str_list(mapping, "key", default="default_value") + assert result is None + + def test_none_value_no_default(self) -> None: + """Test that an explicit None value returns None when no default is provided.""" + mapping = {"key": None} + result = get_str_or_str_list(mapping, "key") + assert result is None + + def test_empty_string(self) -> None: + """Test that an empty string is returned correctly.""" + mapping = {"key": ""} + result = get_str_or_str_list(mapping, "key") + assert result == "" + assert isinstance(result, str) + + def test_whitespace_string(self) -> None: + """Test that a whitespace string is returned correctly.""" + mapping = {"key": " "} + result = get_str_or_str_list(mapping, "key") + assert result == " " + assert isinstance(result, str) + + def test_string_with_special_characters(self) -> None: + """Test that strings with special characters are handled correctly.""" + mapping = {"key": "path/to/file.txt"} + result = get_str_or_str_list(mapping, "key") + assert result == "path/to/file.txt" + assert isinstance(result, str) + + def test_list_with_empty_strings(self) -> None: + """Test that a list containing empty strings is handled correctly.""" + mapping = {"key": ["", "non-empty", ""]} + result = get_str_or_str_list(mapping, "key") + assert result == ["", "non-empty", ""] + assert isinstance(result, list) From 72ec2728cddc1b9219ff28721b96ff175b65751e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 08:41:40 +0000 Subject: [PATCH 25/43] test: add comprehensive tests for get_str_or_str_list function Co-authored-by: 97gamjak <77228802+97gamjak@users.noreply.github.com> --- tests/config/test_base.py | 133 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 tests/config/test_base.py diff --git a/tests/config/test_base.py b/tests/config/test_base.py new file mode 100644 index 0000000..da89eaa --- /dev/null +++ b/tests/config/test_base.py @@ -0,0 +1,133 @@ +"""Tests for devops.config.base module.""" + +import pytest + +from devops.config.base import ConfigError, get_str_or_str_list + + +class TestGetStrOrStrList: + """Test cases for get_str_or_str_list function.""" + + def test_string_value(self) -> None: + """Test that a string value is returned correctly.""" + mapping = {"key": "value"} + result = get_str_or_str_list(mapping, "key") + assert result == "value" + assert isinstance(result, str) + + def test_list_of_strings(self) -> None: + """Test that a list of strings is returned correctly.""" + mapping = {"key": ["value1", "value2", "value3"]} + result = get_str_or_str_list(mapping, "key") + assert result == ["value1", "value2", "value3"] + assert isinstance(result, list) + assert all(isinstance(item, str) for item in result) + + def test_empty_list(self) -> None: + """Test that an empty list is handled correctly.""" + mapping = {"key": []} + result = get_str_or_str_list(mapping, "key") + assert result == [] + assert isinstance(result, list) + + def test_mixed_list_with_non_strings(self) -> None: + """Test that a list with non-string items raises ConfigError.""" + mapping = {"key": ["string", 42, "another"]} + + with pytest.raises(ConfigError) as exc_info: + get_str_or_str_list(mapping, "key") + + assert "Expected str or list of str for key 'key'" in str(exc_info.value) + assert "got list with non-string items" in str(exc_info.value) + + def test_mixed_list_with_none(self) -> None: + """Test that a list containing None raises ConfigError.""" + mapping = {"key": ["string", None, "another"]} + + with pytest.raises(ConfigError) as exc_info: + get_str_or_str_list(mapping, "key") + + assert "Expected str or list of str for key 'key'" in str(exc_info.value) + assert "got list with non-string items" in str(exc_info.value) + + def test_non_string_non_list_int(self) -> None: + """Test that an integer value raises ConfigError.""" + mapping = {"key": 42} + + with pytest.raises(ConfigError) as exc_info: + get_str_or_str_list(mapping, "key") + + assert "Expected str or list of str for key 'key'" in str(exc_info.value) + assert "got int" in str(exc_info.value) + + def test_non_string_non_list_dict(self) -> None: + """Test that a dict value raises ConfigError.""" + mapping = {"key": {"nested": "value"}} + + with pytest.raises(ConfigError) as exc_info: + get_str_or_str_list(mapping, "key") + + assert "Expected str or list of str for key 'key'" in str(exc_info.value) + assert "got dict" in str(exc_info.value) + + def test_non_string_non_list_bool(self) -> None: + """Test that a boolean value raises ConfigError.""" + mapping = {"key": True} + + with pytest.raises(ConfigError) as exc_info: + get_str_or_str_list(mapping, "key") + + assert "Expected str or list of str for key 'key'" in str(exc_info.value) + assert "got bool" in str(exc_info.value) + + def test_missing_key_no_default(self) -> None: + """Test that a missing key without default returns None.""" + mapping = {"other_key": "value"} + result = get_str_or_str_list(mapping, "missing_key") + assert result is None + + def test_missing_key_with_string_default(self) -> None: + """Test that a missing key returns the string default value.""" + mapping = {"other_key": "value"} + result = get_str_or_str_list(mapping, "missing_key", default="default_value") + assert result == "default_value" + assert isinstance(result, str) + + def test_missing_key_with_list_default(self) -> None: + """Test that a missing key returns the list default value.""" + mapping = {"other_key": "value"} + result = get_str_or_str_list(mapping, "missing_key", default=["default1", "default2"]) + assert result == ["default1", "default2"] + assert isinstance(result, list) + + def test_missing_key_with_none_default(self) -> None: + """Test that a missing key with None default returns None.""" + mapping = {"other_key": "value"} + result = get_str_or_str_list(mapping, "missing_key", default=None) + assert result is None + + def test_explicit_none_value(self) -> None: + """Test that an explicit None value in mapping returns None.""" + mapping = {"key": None} + result = get_str_or_str_list(mapping, "key") + assert result is None + + def test_explicit_none_value_with_default(self) -> None: + """Test that an explicit None value returns None even with default.""" + mapping = {"key": None} + result = get_str_or_str_list(mapping, "key", default="default_value") + assert result is None + + def test_empty_string(self) -> None: + """Test that an empty string is handled correctly.""" + mapping = {"key": ""} + result = get_str_or_str_list(mapping, "key") + assert result == "" + assert isinstance(result, str) + + def test_single_item_list(self) -> None: + """Test that a single-item list is returned correctly.""" + mapping = {"key": ["single"]} + result = get_str_or_str_list(mapping, "key") + assert result == ["single"] + assert isinstance(result, list) From d0a13b31a4786cf2f1e113f66102235b56b5eb48 Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Sat, 17 Jan 2026 09:48:55 +0100 Subject: [PATCH 26/43] fix: ruff check errors --- src/devops/config/config_file.py | 5 ++++- tests/config/test_base.py | 24 +++++++++++++----------- tests/config/test_config_file.py | 7 +++++-- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/src/devops/config/config_file.py b/src/devops/config/config_file.py index 34d6724..a292dd7 100644 --- a/src/devops/config/config_file.py +++ b/src/devops/config/config_file.py @@ -27,7 +27,10 @@ def default_changelog_path(self) -> Path: return self._default_changelog_path if not self.changelog_paths: - msg = "No changelog paths configured; cannot determine default changelog path." + msg = ( + "No changelog paths configured; " + "cannot determine default changelog path." + ) raise ConfigError(msg) return self.changelog_paths[0] diff --git a/tests/config/test_base.py b/tests/config/test_base.py index da89eaa..1218923 100644 --- a/tests/config/test_base.py +++ b/tests/config/test_base.py @@ -33,50 +33,50 @@ def test_empty_list(self) -> None: def test_mixed_list_with_non_strings(self) -> None: """Test that a list with non-string items raises ConfigError.""" mapping = {"key": ["string", 42, "another"]} - + with pytest.raises(ConfigError) as exc_info: get_str_or_str_list(mapping, "key") - + assert "Expected str or list of str for key 'key'" in str(exc_info.value) assert "got list with non-string items" in str(exc_info.value) def test_mixed_list_with_none(self) -> None: """Test that a list containing None raises ConfigError.""" mapping = {"key": ["string", None, "another"]} - + with pytest.raises(ConfigError) as exc_info: get_str_or_str_list(mapping, "key") - + assert "Expected str or list of str for key 'key'" in str(exc_info.value) assert "got list with non-string items" in str(exc_info.value) def test_non_string_non_list_int(self) -> None: """Test that an integer value raises ConfigError.""" mapping = {"key": 42} - + with pytest.raises(ConfigError) as exc_info: get_str_or_str_list(mapping, "key") - + assert "Expected str or list of str for key 'key'" in str(exc_info.value) assert "got int" in str(exc_info.value) def test_non_string_non_list_dict(self) -> None: """Test that a dict value raises ConfigError.""" mapping = {"key": {"nested": "value"}} - + with pytest.raises(ConfigError) as exc_info: get_str_or_str_list(mapping, "key") - + assert "Expected str or list of str for key 'key'" in str(exc_info.value) assert "got dict" in str(exc_info.value) def test_non_string_non_list_bool(self) -> None: """Test that a boolean value raises ConfigError.""" mapping = {"key": True} - + with pytest.raises(ConfigError) as exc_info: get_str_or_str_list(mapping, "key") - + assert "Expected str or list of str for key 'key'" in str(exc_info.value) assert "got bool" in str(exc_info.value) @@ -96,7 +96,9 @@ def test_missing_key_with_string_default(self) -> None: def test_missing_key_with_list_default(self) -> None: """Test that a missing key returns the list default value.""" mapping = {"other_key": "value"} - result = get_str_or_str_list(mapping, "missing_key", default=["default1", "default2"]) + result = get_str_or_str_list( + mapping, "missing_key", default=["default1", "default2"] + ) assert result == ["default1", "default2"] assert isinstance(result, list) diff --git a/tests/config/test_config_file.py b/tests/config/test_config_file.py index 52f11af..aaae92b 100644 --- a/tests/config/test_config_file.py +++ b/tests/config/test_config_file.py @@ -72,7 +72,7 @@ def test_file_config_uses_explicit_default_changelog_path(self) -> None: ] def test_file_config_defaults_to_first_changelog_path(self) -> None: - """Test that FileConfig defaults to first changelog_path when no default specified.""" + """Test FileConfig defaults to first changelog_path when no default present.""" raw_config = { "file": { "changelog_paths": ["CHANGELOG.md", "docs/CHANGELOG.md"], @@ -108,7 +108,10 @@ def test_file_config_default_changelog_path_not_in_list(self) -> None: assert result.changelog_paths == [Path("CHANGELOG.md")] def test_file_config_raises_error_when_no_paths_configured(self) -> None: - """Test that accessing default_changelog_path raises error when no paths configured.""" + """Test accessing default_changelog_path. + + When no changelog paths are configured, accessing default_changelog_path + """ # Create a FileConfig with empty changelog_paths and no default config = FileConfig(changelog_paths=[], _default_changelog_path=None) From 759b8af36f4e33f7f5656ab64f139bb0e21ff843 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 08:50:27 +0000 Subject: [PATCH 27/43] Initial plan From 9ec6ec2b163174206693055ff37533249a00b638 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 08:53:57 +0000 Subject: [PATCH 28/43] fix: avoid re-fetching value from mapping in get_str_or_str_list Co-authored-by: 97gamjak <77228802+97gamjak@users.noreply.github.com> --- src/devops/config/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/devops/config/base.py b/src/devops/config/base.py index 72ce545..0db1cfd 100644 --- a/src/devops/config/base.py +++ b/src/devops/config/base.py @@ -170,11 +170,11 @@ def get_str_or_str_list( return None if isinstance(value, str): - return get_str(mapping, key, default) + return value if isinstance(value, list): if all(isinstance(item, str) for item in value): - return get_str_list(mapping, key, default) + return value msg = ( f"Expected str or list of str for key '{key}', " "got list with non-string items" From c7b288d5c05d46fd25f7f43b73f4a5480b628336 Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Sat, 17 Jan 2026 22:46:09 +0100 Subject: [PATCH 29/43] feat: add changelog_path argument to update_changelog function and update CLI command --- CHANGELOG.md | 4 ++++ src/devops/files/update_changelog.py | 2 +- src/devops/scripts/update_changelog.py | 12 +++++++++--- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85530a5..9708f13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ All notable changes to this project will be documented in this file. - Add `changelog_paths` to config toml approach - Add `default_changelog_path` to config toml approach +#### API + +- Add `changelog_path` input to `update_changelog` + ## [0.0.4](https://github.com/repo/owner/releases/tag/0.0.4) - 2025-12-20 diff --git a/src/devops/files/update_changelog.py b/src/devops/files/update_changelog.py index 3938943..ac0553c 100644 --- a/src/devops/files/update_changelog.py +++ b/src/devops/files/update_changelog.py @@ -20,7 +20,7 @@ def __init__(self, message: str) -> None: self.message = message -def update_changelog(version: str, changelog_path: Path = __CHANGELOG_PATH__) -> None: +def update_changelog(version: str, changelog_path: Path) -> None: """Update the changelog file with a new version entry. Parameters diff --git a/src/devops/scripts/update_changelog.py b/src/devops/scripts/update_changelog.py index 93fe779..dbbec84 100644 --- a/src/devops/scripts/update_changelog.py +++ b/src/devops/scripts/update_changelog.py @@ -4,6 +4,7 @@ import typer +from devops.config import config from devops.files import update_changelog from devops.files.update_changelog import DevOpsChangelogError from devops.utils import mstd_print @@ -12,17 +13,22 @@ @app.command() -def main(version: str) -> None: +def main(version: str, changelog_path: str | None) -> None: """Update the changelog file with a new version entry. Parameters ---------- version: str The new version to add to the changelog. - + changelog_path: str | None + The path to the changelog file. If None, it defaults + to the default_changelog_path from the configuration file. """ + if changelog_path is None: + changelog_path = config.file.default_changelog_path + try: - update_changelog.update_changelog(version) + update_changelog.update_changelog(version, changelog_path) mstd_print(f"✅ CHANGELOG.md updated for version {version}") except DevOpsChangelogError as e: mstd_print(f"❌ Error updating changelog: {e}") From 52210fd54d8d953ad2fb86bb0ca0d92682a3ba82 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 21:55:26 +0000 Subject: [PATCH 30/43] Initial plan From aea121a95a9b725a83911a931d966cd77df34570 Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Sat, 17 Jan 2026 22:57:04 +0100 Subject: [PATCH 31/43] feat: add optional changelog_path argument to main function in update_changelog script --- src/devops/scripts/update_changelog.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/devops/scripts/update_changelog.py b/src/devops/scripts/update_changelog.py index dbbec84..c2b80d1 100644 --- a/src/devops/scripts/update_changelog.py +++ b/src/devops/scripts/update_changelog.py @@ -1,6 +1,7 @@ """Script for updating the changelog file.""" import sys +from pathlib import Path import typer @@ -13,7 +14,7 @@ @app.command() -def main(version: str, changelog_path: str | None) -> None: +def main(version: str, changelog_path: str | None = None) -> None: """Update the changelog file with a new version entry. Parameters @@ -26,7 +27,8 @@ def main(version: str, changelog_path: str | None) -> None: """ if changelog_path is None: changelog_path = config.file.default_changelog_path - + else: + changelog_path = Path(changelog_path) try: update_changelog.update_changelog(version, changelog_path) mstd_print(f"✅ CHANGELOG.md updated for version {version}") From 783d7ceb273bd6a169b279267ea7861aca84f2ca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 22:00:57 +0000 Subject: [PATCH 32/43] feat: add CLI tests for update_changelog and fix Path conversion Co-authored-by: 97gamjak <77228802+97gamjak@users.noreply.github.com> --- src/devops/scripts/update_changelog.py | 9 +- tests/scripts/test_update_changelog_cli.py | 159 +++++++++++++++++++++ 2 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 tests/scripts/test_update_changelog_cli.py diff --git a/src/devops/scripts/update_changelog.py b/src/devops/scripts/update_changelog.py index dbbec84..023e4a4 100644 --- a/src/devops/scripts/update_changelog.py +++ b/src/devops/scripts/update_changelog.py @@ -1,6 +1,7 @@ """Script for updating the changelog file.""" import sys +from pathlib import Path import typer @@ -13,7 +14,7 @@ @app.command() -def main(version: str, changelog_path: str | None) -> None: +def main(version: str, changelog_path: str | None = None) -> None: """Update the changelog file with a new version entry. Parameters @@ -25,10 +26,12 @@ def main(version: str, changelog_path: str | None) -> None: to the default_changelog_path from the configuration file. """ if changelog_path is None: - changelog_path = config.file.default_changelog_path + changelog_path_obj = config.file.default_changelog_path + else: + changelog_path_obj = Path(changelog_path) try: - update_changelog.update_changelog(version, changelog_path) + update_changelog.update_changelog(version, changelog_path_obj) mstd_print(f"✅ CHANGELOG.md updated for version {version}") except DevOpsChangelogError as e: mstd_print(f"❌ Error updating changelog: {e}") diff --git a/tests/scripts/test_update_changelog_cli.py b/tests/scripts/test_update_changelog_cli.py new file mode 100644 index 0000000..c89c5b0 --- /dev/null +++ b/tests/scripts/test_update_changelog_cli.py @@ -0,0 +1,159 @@ +"""Tests for update_changelog CLI script.""" + +from __future__ import annotations + +import typing +from pathlib import Path +from unittest.mock import patch + +from typer.testing import CliRunner + +from devops.scripts.update_changelog import app + +if typing.TYPE_CHECKING: + from pathlib import Path as PathType + +runner = CliRunner() + + +class TestUpdateChangelogCLI: + """Tests for update_changelog Typer CLI command.""" + + def test_update_changelog_command_exists(self) -> None: + """Test that update_changelog command is registered.""" + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "Update the changelog file" in result.stdout + + def test_update_changelog_with_changelog_path_argument( + self, tmp_path: PathType + ) -> None: + """Test update_changelog command with changelog_path argument. + + Parameters + ---------- + tmp_path: PathType + Temporary path for creating test files. + + """ + # Create a test changelog file + changelog_file = tmp_path / "TEST_CHANGELOG.md" + changelog_file.write_text( + "# Changelog\n\n## Next Release\n\n- Test change\n\n" + "\n" + ) + + with patch( + "devops.scripts.update_changelog.update_changelog.update_changelog" + ) as mock_update: + mock_update.return_value = None + + result = runner.invoke( + app, ["1.0.0", "--changelog-path", str(changelog_file)] + ) + + assert result.exit_code == 0 + # Should call update_changelog with the provided path + assert mock_update.call_count == 1 + call_args = mock_update.call_args[0] + assert call_args[0] == "1.0.0" + # Should convert string to Path + assert isinstance(call_args[1], Path) + assert call_args[1] == Path(changelog_file) + + def test_update_changelog_uses_config_default_when_no_arg(self) -> None: + """Test update_changelog uses config default when no changelog_path provided.""" + with ( + patch( + "devops.scripts.update_changelog.update_changelog.update_changelog" + ) as mock_update, + patch("devops.scripts.update_changelog.config") as mock_config, + ): + mock_update.return_value = None + mock_config.file.default_changelog_path = "/config/path/CHANGELOG.md" + + result = runner.invoke(app, ["1.0.0"]) + + # Should use the global config's default_changelog_path + assert mock_update.call_count == 1 + call_args = mock_update.call_args[0] + assert call_args[0] == "1.0.0" + # The config returns the value directly (could be Path or str) + assert call_args[1] == "/config/path/CHANGELOG.md" + + def test_update_changelog_converts_string_to_path(self, tmp_path: PathType) -> None: + """Test that string input is properly handled for Path conversion. + + Parameters + ---------- + tmp_path: PathType + Temporary path for creating test files. + + """ + changelog_file = tmp_path / "CHANGELOG.md" + changelog_file.write_text( + "# Changelog\n\n## Next Release\n\n- Feature\n\n" + "\n" + ) + + with patch( + "devops.scripts.update_changelog.update_changelog.update_changelog" + ) as mock_update: + mock_update.return_value = None + + # Pass as string + result = runner.invoke( + app, ["2.0.0", "--changelog-path", str(changelog_file)] + ) + + assert result.exit_code == 0 + assert mock_update.call_count == 1 + # Verify it's called with a Path object + call_args = mock_update.call_args[0] + assert isinstance(call_args[1], Path) + assert call_args[1] == Path(changelog_file) + + def test_update_changelog_handles_error(self) -> None: + """Test update_changelog command handles DevOpsChangelogError.""" + from devops.files.update_changelog import DevOpsChangelogError + + with ( + patch( + "devops.scripts.update_changelog.update_changelog.update_changelog" + ) as mock_update, + patch("devops.scripts.update_changelog.config") as mock_config, + ): + mock_update.side_effect = DevOpsChangelogError("Test error") + mock_config.file.default_changelog_path = Path("/default/CHANGELOG.md") + + result = runner.invoke(app, ["1.0.0"]) + + assert result.exit_code == 1 + assert "Error updating changelog" in result.stdout + + def test_update_changelog_success_message(self, tmp_path: PathType) -> None: + """Test that successful update shows correct message. + + Parameters + ---------- + tmp_path: PathType + Temporary path for creating test files. + + """ + changelog_file = tmp_path / "CHANGELOG.md" + changelog_file.write_text( + "# Changelog\n\n## Next Release\n\n- Change\n\n" + "\n" + ) + + with patch( + "devops.scripts.update_changelog.update_changelog.update_changelog" + ) as mock_update: + mock_update.return_value = None + + result = runner.invoke( + app, ["3.0.0", "--changelog-path", str(changelog_file)] + ) + + assert result.exit_code == 0 + assert "✅ CHANGELOG.md updated for version 3.0.0" in result.stdout From 24974d2b0b5d0ddf2f2a49756c251fab91af5fc1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 22:02:20 +0000 Subject: [PATCH 33/43] fix: use Path objects in test mocks to match actual implementation Co-authored-by: 97gamjak <77228802+97gamjak@users.noreply.github.com> --- tests/scripts/test_update_changelog_cli.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/scripts/test_update_changelog_cli.py b/tests/scripts/test_update_changelog_cli.py index c89c5b0..34c7468 100644 --- a/tests/scripts/test_update_changelog_cli.py +++ b/tests/scripts/test_update_changelog_cli.py @@ -70,7 +70,7 @@ def test_update_changelog_uses_config_default_when_no_arg(self) -> None: patch("devops.scripts.update_changelog.config") as mock_config, ): mock_update.return_value = None - mock_config.file.default_changelog_path = "/config/path/CHANGELOG.md" + mock_config.file.default_changelog_path = Path("/config/path/CHANGELOG.md") result = runner.invoke(app, ["1.0.0"]) @@ -78,8 +78,8 @@ def test_update_changelog_uses_config_default_when_no_arg(self) -> None: assert mock_update.call_count == 1 call_args = mock_update.call_args[0] assert call_args[0] == "1.0.0" - # The config returns the value directly (could be Path or str) - assert call_args[1] == "/config/path/CHANGELOG.md" + # The config returns a Path object + assert call_args[1] == Path("/config/path/CHANGELOG.md") def test_update_changelog_converts_string_to_path(self, tmp_path: PathType) -> None: """Test that string input is properly handled for Path conversion. From 6f0a472a8c0aa23e4e79470d44efcbb9ff3cd140 Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Sat, 17 Jan 2026 23:24:34 +0100 Subject: [PATCH 34/43] fix: update changelog function call to use changelog_path argument --- src/devops/scripts/update_changelog.py | 2 +- tests/scripts/test_update_changelog_cli.py | 11 ++++------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/devops/scripts/update_changelog.py b/src/devops/scripts/update_changelog.py index 414a350..c2b80d1 100644 --- a/src/devops/scripts/update_changelog.py +++ b/src/devops/scripts/update_changelog.py @@ -30,7 +30,7 @@ def main(version: str, changelog_path: str | None = None) -> None: else: changelog_path = Path(changelog_path) try: - update_changelog.update_changelog(version, changelog_path_obj) + update_changelog.update_changelog(version, changelog_path) mstd_print(f"✅ CHANGELOG.md updated for version {version}") except DevOpsChangelogError as e: mstd_print(f"❌ Error updating changelog: {e}") diff --git a/tests/scripts/test_update_changelog_cli.py b/tests/scripts/test_update_changelog_cli.py index 34c7468..7d3367c 100644 --- a/tests/scripts/test_update_changelog_cli.py +++ b/tests/scripts/test_update_changelog_cli.py @@ -8,6 +8,7 @@ from typer.testing import CliRunner +from devops.files.update_changelog import DevOpsChangelogError from devops.scripts.update_changelog import app if typing.TYPE_CHECKING: @@ -72,7 +73,7 @@ def test_update_changelog_uses_config_default_when_no_arg(self) -> None: mock_update.return_value = None mock_config.file.default_changelog_path = Path("/config/path/CHANGELOG.md") - result = runner.invoke(app, ["1.0.0"]) + _result = runner.invoke(app, ["1.0.0"]) # Should use the global config's default_changelog_path assert mock_update.call_count == 1 @@ -92,8 +93,7 @@ def test_update_changelog_converts_string_to_path(self, tmp_path: PathType) -> N """ changelog_file = tmp_path / "CHANGELOG.md" changelog_file.write_text( - "# Changelog\n\n## Next Release\n\n- Feature\n\n" - "\n" + "# Changelog\n\n## Next Release\n\n- Feature\n\n\n" ) with patch( @@ -115,8 +115,6 @@ def test_update_changelog_converts_string_to_path(self, tmp_path: PathType) -> N def test_update_changelog_handles_error(self) -> None: """Test update_changelog command handles DevOpsChangelogError.""" - from devops.files.update_changelog import DevOpsChangelogError - with ( patch( "devops.scripts.update_changelog.update_changelog.update_changelog" @@ -142,8 +140,7 @@ def test_update_changelog_success_message(self, tmp_path: PathType) -> None: """ changelog_file = tmp_path / "CHANGELOG.md" changelog_file.write_text( - "# Changelog\n\n## Next Release\n\n- Change\n\n" - "\n" + "# Changelog\n\n## Next Release\n\n- Change\n\n\n" ) with patch( From 70b6d0c46ae7d1d8583989749120027ebad5c89f Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Sun, 18 Jan 2026 09:07:35 +0100 Subject: [PATCH 35/43] feat: add support for updating multiple changelogs with new version entries --- CHANGELOG.md | 1 + pyproject.toml | 5 +-- src/devops/scripts/update_changelog.py | 42 +++++++++++++++++++++----- 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9708f13..5027027 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to this project will be documented in this file. #### API - Add `changelog_path` input to `update_changelog` +- ADD `update_changelogs` to update multiple changelogs at once ## [0.0.4](https://github.com/repo/owner/releases/tag/0.0.4) - 2025-12-20 diff --git a/pyproject.toml b/pyproject.toml index e62e234..1711f6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,8 @@ test = ["pytest>=9.0.1", "pytest-cov", "coverage", "docstr-coverage"] [project.scripts] cpp_checks = "devops.scripts.cpp_checks:app" -update_changelog = "devops.scripts.update_changelog:app" +update_changelog = "devops.scripts.update_changelog:update_changelog" +update_changelogs = "devops.scripts.update_changelog:update_changelogs" get_latest_tag = "devops.scripts.get_latest_git_tag:latest_tag" increase_latest_tag = "devops.scripts.get_latest_git_tag:increase_tag" generate_toml_template = "devops.scripts.generate_toml_template:app" @@ -24,4 +25,4 @@ add_license_headers = "devops.scripts.add_license_header:add_multiple_headers" filter_buggy_cpp_files = "devops.scripts.cpp_files:app" [tool.setuptools_scm] -version_file = "src/devops/__version__.py" \ No newline at end of file +version_file = "src/devops/__version__.py" diff --git a/src/devops/scripts/update_changelog.py b/src/devops/scripts/update_changelog.py index c2b80d1..a0fe5b8 100644 --- a/src/devops/scripts/update_changelog.py +++ b/src/devops/scripts/update_changelog.py @@ -6,15 +6,16 @@ import typer from devops.config import config -from devops.files import update_changelog +from devops.files import update_changelog as update_changelog_func from devops.files.update_changelog import DevOpsChangelogError from devops.utils import mstd_print -app = typer.Typer() +update_changelog = typer.Typer() +update_changelogs = typer.Typer() -@app.command() -def main(version: str, changelog_path: str | None = None) -> None: +@update_changelog.command() +def _update_changelog(version: str, changelog_path: str | None = None) -> None: """Update the changelog file with a new version entry. Parameters @@ -30,12 +31,39 @@ def main(version: str, changelog_path: str | None = None) -> None: else: changelog_path = Path(changelog_path) try: - update_changelog.update_changelog(version, changelog_path) + update_changelog_func(version, changelog_path) mstd_print(f"✅ CHANGELOG.md updated for version {version}") except DevOpsChangelogError as e: mstd_print(f"❌ Error updating changelog: {e}") sys.exit(1) -if __name__ == "__main__": - app() +@update_changelogs.command() +def _update_changelogs(version: str, changelog_paths: list[str] | None = None) -> None: + """Update multiple changelog files with a new version entry. + + Parameters + ---------- + version: str + The new version to add to the changelogs. + changelog_paths: list[str] | None + The list of paths to the changelog files. If None, it defaults + to the changelog_paths from the configuration file. + """ + if changelog_paths is None: + changelog_paths = config.file.changelog_paths + else: + changelog_paths = [Path(p) for p in changelog_paths] + + failed_updates = [] + + for changelog_path in changelog_paths: + try: + update_changelog_func(version, changelog_path) + mstd_print(f"✅ {changelog_path} updated for version {version}") + except DevOpsChangelogError as e: + mstd_print(f"❌ Error updating {changelog_path}: {e}") + failed_updates.append(changelog_path) + + if failed_updates: + sys.exit(1) From 545e81837585a84514c0ba9b3ee4ed582fee6f11 Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Sun, 18 Jan 2026 09:34:29 +0100 Subject: [PATCH 36/43] Update CHANGELOG.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5027027..48cd7ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ All notable changes to this project will be documented in this file. #### API - Add `changelog_path` input to `update_changelog` -- ADD `update_changelogs` to update multiple changelogs at once +- Add `update_changelogs` to update multiple changelogs at once ## [0.0.4](https://github.com/repo/owner/releases/tag/0.0.4) - 2025-12-20 From b0331414179b3caeaadbcfbd6f418e10218db31f Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Sun, 18 Jan 2026 09:36:38 +0100 Subject: [PATCH 37/43] Update src/devops/scripts/update_changelog.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/devops/scripts/update_changelog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/devops/scripts/update_changelog.py b/src/devops/scripts/update_changelog.py index a0fe5b8..3803217 100644 --- a/src/devops/scripts/update_changelog.py +++ b/src/devops/scripts/update_changelog.py @@ -51,7 +51,7 @@ def _update_changelogs(version: str, changelog_paths: list[str] | None = None) - to the changelog_paths from the configuration file. """ if changelog_paths is None: - changelog_paths = config.file.changelog_paths + changelog_paths = [Path(p) for p in config.file.changelog_paths] else: changelog_paths = [Path(p) for p in changelog_paths] From 366c9295c1e7175de36baf0dc33d9217c129e548 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 18 Jan 2026 08:38:40 +0000 Subject: [PATCH 38/43] Initial plan From c0271e6e2d23cf0b2f385af5a8ab37daecc7fd06 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 18 Jan 2026 08:38:43 +0000 Subject: [PATCH 39/43] Initial plan From fc2e66f42db5ef7cfebe2f7f74697b4df5ca3b4f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 18 Jan 2026 08:43:05 +0000 Subject: [PATCH 40/43] Fix breaking change: update test imports for refactored update_changelog module Co-authored-by: 97gamjak <77228802+97gamjak@users.noreply.github.com> --- tests/scripts/test_update_changelog_cli.py | 24 +++++++++++----------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/scripts/test_update_changelog_cli.py b/tests/scripts/test_update_changelog_cli.py index 7d3367c..caada20 100644 --- a/tests/scripts/test_update_changelog_cli.py +++ b/tests/scripts/test_update_changelog_cli.py @@ -9,7 +9,7 @@ from typer.testing import CliRunner from devops.files.update_changelog import DevOpsChangelogError -from devops.scripts.update_changelog import app +from devops.scripts.update_changelog import update_changelog if typing.TYPE_CHECKING: from pathlib import Path as PathType @@ -22,7 +22,7 @@ class TestUpdateChangelogCLI: def test_update_changelog_command_exists(self) -> None: """Test that update_changelog command is registered.""" - result = runner.invoke(app, ["--help"]) + result = runner.invoke(update_changelog, ["--help"]) assert result.exit_code == 0 assert "Update the changelog file" in result.stdout @@ -45,12 +45,12 @@ def test_update_changelog_with_changelog_path_argument( ) with patch( - "devops.scripts.update_changelog.update_changelog.update_changelog" + "devops.scripts.update_changelog.update_changelog_func" ) as mock_update: mock_update.return_value = None result = runner.invoke( - app, ["1.0.0", "--changelog-path", str(changelog_file)] + update_changelog, ["1.0.0", "--changelog-path", str(changelog_file)] ) assert result.exit_code == 0 @@ -66,14 +66,14 @@ def test_update_changelog_uses_config_default_when_no_arg(self) -> None: """Test update_changelog uses config default when no changelog_path provided.""" with ( patch( - "devops.scripts.update_changelog.update_changelog.update_changelog" + "devops.scripts.update_changelog.update_changelog_func" ) as mock_update, patch("devops.scripts.update_changelog.config") as mock_config, ): mock_update.return_value = None mock_config.file.default_changelog_path = Path("/config/path/CHANGELOG.md") - _result = runner.invoke(app, ["1.0.0"]) + _result = runner.invoke(update_changelog, ["1.0.0"]) # Should use the global config's default_changelog_path assert mock_update.call_count == 1 @@ -97,13 +97,13 @@ def test_update_changelog_converts_string_to_path(self, tmp_path: PathType) -> N ) with patch( - "devops.scripts.update_changelog.update_changelog.update_changelog" + "devops.scripts.update_changelog.update_changelog_func" ) as mock_update: mock_update.return_value = None # Pass as string result = runner.invoke( - app, ["2.0.0", "--changelog-path", str(changelog_file)] + update_changelog, ["2.0.0", "--changelog-path", str(changelog_file)] ) assert result.exit_code == 0 @@ -117,14 +117,14 @@ def test_update_changelog_handles_error(self) -> None: """Test update_changelog command handles DevOpsChangelogError.""" with ( patch( - "devops.scripts.update_changelog.update_changelog.update_changelog" + "devops.scripts.update_changelog.update_changelog_func" ) as mock_update, patch("devops.scripts.update_changelog.config") as mock_config, ): mock_update.side_effect = DevOpsChangelogError("Test error") mock_config.file.default_changelog_path = Path("/default/CHANGELOG.md") - result = runner.invoke(app, ["1.0.0"]) + result = runner.invoke(update_changelog, ["1.0.0"]) assert result.exit_code == 1 assert "Error updating changelog" in result.stdout @@ -144,12 +144,12 @@ def test_update_changelog_success_message(self, tmp_path: PathType) -> None: ) with patch( - "devops.scripts.update_changelog.update_changelog.update_changelog" + "devops.scripts.update_changelog.update_changelog_func" ) as mock_update: mock_update.return_value = None result = runner.invoke( - app, ["3.0.0", "--changelog-path", str(changelog_file)] + update_changelog, ["3.0.0", "--changelog-path", str(changelog_file)] ) assert result.exit_code == 0 From 744f04778d0f68e3bbdd766b2aad898a16bf04d3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 18 Jan 2026 08:48:26 +0000 Subject: [PATCH 41/43] Add comprehensive test coverage for _update_changelogs function Co-authored-by: 97gamjak <77228802+97gamjak@users.noreply.github.com> --- src/devops/scripts/update_changelog.py | 6 +- tests/scripts/test_update_changelog_cli.py | 262 ++++++++++++++++++++- 2 files changed, 260 insertions(+), 8 deletions(-) diff --git a/src/devops/scripts/update_changelog.py b/src/devops/scripts/update_changelog.py index 3803217..ef9ed63 100644 --- a/src/devops/scripts/update_changelog.py +++ b/src/devops/scripts/update_changelog.py @@ -6,13 +6,15 @@ import typer from devops.config import config -from devops.files import update_changelog as update_changelog_func -from devops.files.update_changelog import DevOpsChangelogError +from devops.files.update_changelog import DevOpsChangelogError, update_changelog as update_changelog_func from devops.utils import mstd_print update_changelog = typer.Typer() update_changelogs = typer.Typer() +# Alias for tests and backwards compatibility +app = update_changelog + @update_changelog.command() def _update_changelog(version: str, changelog_path: str | None = None) -> None: diff --git a/tests/scripts/test_update_changelog_cli.py b/tests/scripts/test_update_changelog_cli.py index 7d3367c..1f7a482 100644 --- a/tests/scripts/test_update_changelog_cli.py +++ b/tests/scripts/test_update_changelog_cli.py @@ -9,7 +9,7 @@ from typer.testing import CliRunner from devops.files.update_changelog import DevOpsChangelogError -from devops.scripts.update_changelog import app +from devops.scripts.update_changelog import app, update_changelogs if typing.TYPE_CHECKING: from pathlib import Path as PathType @@ -45,7 +45,7 @@ def test_update_changelog_with_changelog_path_argument( ) with patch( - "devops.scripts.update_changelog.update_changelog.update_changelog" + "devops.scripts.update_changelog.update_changelog_func" ) as mock_update: mock_update.return_value = None @@ -66,7 +66,7 @@ def test_update_changelog_uses_config_default_when_no_arg(self) -> None: """Test update_changelog uses config default when no changelog_path provided.""" with ( patch( - "devops.scripts.update_changelog.update_changelog.update_changelog" + "devops.scripts.update_changelog.update_changelog_func" ) as mock_update, patch("devops.scripts.update_changelog.config") as mock_config, ): @@ -97,7 +97,7 @@ def test_update_changelog_converts_string_to_path(self, tmp_path: PathType) -> N ) with patch( - "devops.scripts.update_changelog.update_changelog.update_changelog" + "devops.scripts.update_changelog.update_changelog_func" ) as mock_update: mock_update.return_value = None @@ -117,7 +117,7 @@ def test_update_changelog_handles_error(self) -> None: """Test update_changelog command handles DevOpsChangelogError.""" with ( patch( - "devops.scripts.update_changelog.update_changelog.update_changelog" + "devops.scripts.update_changelog.update_changelog_func" ) as mock_update, patch("devops.scripts.update_changelog.config") as mock_config, ): @@ -144,7 +144,7 @@ def test_update_changelog_success_message(self, tmp_path: PathType) -> None: ) with patch( - "devops.scripts.update_changelog.update_changelog.update_changelog" + "devops.scripts.update_changelog.update_changelog_func" ) as mock_update: mock_update.return_value = None @@ -154,3 +154,253 @@ def test_update_changelog_success_message(self, tmp_path: PathType) -> None: assert result.exit_code == 0 assert "✅ CHANGELOG.md updated for version 3.0.0" in result.stdout + + +class TestUpdateChangelogsCLI: + """Tests for update_changelogs Typer CLI command.""" + + def test_update_changelogs_command_exists(self) -> None: + """Test that update_changelogs command is registered.""" + result = runner.invoke(update_changelogs, ["--help"]) + assert result.exit_code == 0 + assert "Update multiple changelog files" in result.stdout + + def test_update_changelogs_with_multiple_explicit_paths( + self, tmp_path: PathType + ) -> None: + """Test update_changelogs with multiple explicit changelog paths. + + Parameters + ---------- + tmp_path: PathType + Temporary path for creating test files. + + """ + # Create test changelog files + changelog1 = tmp_path / "CHANGELOG1.md" + changelog2 = tmp_path / "CHANGELOG2.md" + + with patch( + "devops.scripts.update_changelog.update_changelog_func" + ) as mock_update: + mock_update.return_value = None + + result = runner.invoke( + update_changelogs, + [ + "1.0.0", + "--changelog-paths", + str(changelog1), + "--changelog-paths", + str(changelog2), + ], + ) + + assert result.exit_code == 0 + # Should call update_changelog_func twice, once for each path + assert mock_update.call_count == 2 + + # Check both calls + first_call = mock_update.call_args_list[0] + assert first_call[0][0] == "1.0.0" + assert isinstance(first_call[0][1], Path) + assert first_call[0][1] == Path(changelog1) + + second_call = mock_update.call_args_list[1] + assert second_call[0][0] == "1.0.0" + assert isinstance(second_call[0][1], Path) + assert second_call[0][1] == Path(changelog2) + + def test_update_changelogs_uses_config_defaults(self) -> None: + """Test update_changelogs uses config defaults when no paths provided.""" + with ( + patch( + "devops.scripts.update_changelog.update_changelog_func" + ) as mock_update, + patch("devops.scripts.update_changelog.config") as mock_config, + ): + mock_update.return_value = None + mock_config.file.changelog_paths = [ + Path("/config/CHANGELOG1.md"), + Path("/config/CHANGELOG2.md"), + Path("/config/CHANGELOG3.md"), + ] + + result = runner.invoke(update_changelogs, ["2.0.0"]) + + assert result.exit_code == 0 + # Should call update_changelog_func three times + assert mock_update.call_count == 3 + + # Verify all three config paths were used + call_paths = [call[0][1] for call in mock_update.call_args_list] + assert Path("/config/CHANGELOG1.md") in call_paths + assert Path("/config/CHANGELOG2.md") in call_paths + assert Path("/config/CHANGELOG3.md") in call_paths + + def test_update_changelogs_handles_partial_failures( + self, tmp_path: PathType + ) -> None: + """Test update_changelogs handles partial failures correctly. + + Parameters + ---------- + tmp_path: PathType + Temporary path for creating test files. + + """ + changelog1 = tmp_path / "CHANGELOG1.md" + changelog2 = tmp_path / "CHANGELOG2.md" + changelog3 = tmp_path / "CHANGELOG3.md" + + def mock_update_side_effect(version: str, path: Path) -> None: + # Fail for the second changelog + if path == Path(changelog2): + raise DevOpsChangelogError("Mock error for CHANGELOG2") + + with patch( + "devops.scripts.update_changelog.update_changelog_func" + ) as mock_update: + mock_update.side_effect = mock_update_side_effect + + result = runner.invoke( + update_changelogs, + [ + "1.0.0", + "--changelog-paths", + str(changelog1), + "--changelog-paths", + str(changelog2), + "--changelog-paths", + str(changelog3), + ], + ) + + # Should exit with error code 1 due to failures + assert result.exit_code == 1 + + # Should have tried all three changelogs + assert mock_update.call_count == 3 + + # Check output contains success and error messages + # Use 'in' check that accounts for potential line wrapping + assert "✅" in result.stdout + assert str(changelog1) in result.stdout + assert "1.0.0" in result.stdout + assert "❌ Error updating" in result.stdout + assert str(changelog2) in result.stdout + assert "Mock error for CHANGELOG2" in result.stdout + assert str(changelog3) in result.stdout + + def test_update_changelogs_handles_all_failures(self, tmp_path: PathType) -> None: + """Test update_changelogs when all updates fail. + + Parameters + ---------- + tmp_path: PathType + Temporary path for creating test files. + + """ + changelog1 = tmp_path / "CHANGELOG1.md" + changelog2 = tmp_path / "CHANGELOG2.md" + + with patch( + "devops.scripts.update_changelog.update_changelog_func" + ) as mock_update: + mock_update.side_effect = DevOpsChangelogError("Mock error") + + result = runner.invoke( + update_changelogs, + [ + "1.0.0", + "--changelog-paths", + str(changelog1), + "--changelog-paths", + str(changelog2), + ], + ) + + # Should exit with error code 1 + assert result.exit_code == 1 + + # Should have tried both changelogs + assert mock_update.call_count == 2 + + # Both should show error messages + assert "❌ Error updating" in result.stdout + assert str(changelog1) in result.stdout + assert str(changelog2) in result.stdout + assert "Mock error" in result.stdout + + def test_update_changelogs_converts_strings_to_paths( + self, tmp_path: PathType + ) -> None: + """Test that string paths are properly converted to Path objects. + + Parameters + ---------- + tmp_path: PathType + Temporary path for creating test files. + + """ + changelog1 = tmp_path / "CHANGELOG1.md" + changelog2 = tmp_path / "CHANGELOG2.md" + + with patch( + "devops.scripts.update_changelog.update_changelog_func" + ) as mock_update: + mock_update.return_value = None + + result = runner.invoke( + update_changelogs, + [ + "1.0.0", + "--changelog-paths", + str(changelog1), + "--changelog-paths", + str(changelog2), + ], + ) + + assert result.exit_code == 0 + assert mock_update.call_count == 2 + + # Verify both calls received Path objects + for call in mock_update.call_args_list: + assert isinstance(call[0][1], Path) + + def test_update_changelogs_success_messages(self, tmp_path: PathType) -> None: + """Test that successful updates show correct messages for each file. + + Parameters + ---------- + tmp_path: PathType + Temporary path for creating test files. + + """ + changelog1 = tmp_path / "PROJECT_CHANGELOG.md" + changelog2 = tmp_path / "API_CHANGELOG.md" + + with patch( + "devops.scripts.update_changelog.update_changelog_func" + ) as mock_update: + mock_update.return_value = None + + result = runner.invoke( + update_changelogs, + [ + "3.5.0", + "--changelog-paths", + str(changelog1), + "--changelog-paths", + str(changelog2), + ], + ) + + assert result.exit_code == 0 + + # Should show success message for each changelog with specific filename + assert "✅" in result.stdout + assert "PROJECT_CHANGELOG" in result.stdout + assert "API_CHANGELOG" in result.stdout + assert "3.5.0" in result.stdout From 87063d9bfa0aa69ef8b78d5d377a44219a3f45b7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 18 Jan 2026 08:49:45 +0000 Subject: [PATCH 42/43] Remove redundant Path conversion for config.file.changelog_paths Co-authored-by: 97gamjak <77228802+97gamjak@users.noreply.github.com> --- src/devops/scripts/update_changelog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/devops/scripts/update_changelog.py b/src/devops/scripts/update_changelog.py index ef9ed63..16edb6e 100644 --- a/src/devops/scripts/update_changelog.py +++ b/src/devops/scripts/update_changelog.py @@ -53,7 +53,7 @@ def _update_changelogs(version: str, changelog_paths: list[str] | None = None) - to the changelog_paths from the configuration file. """ if changelog_paths is None: - changelog_paths = [Path(p) for p in config.file.changelog_paths] + changelog_paths = config.file.changelog_paths else: changelog_paths = [Path(p) for p in changelog_paths] From ea66959bfd916488300146291ae8a2b4a7264824 Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Sun, 18 Jan 2026 10:32:57 +0100 Subject: [PATCH 43/43] fix: ruff linting --- src/devops/scripts/update_changelog.py | 3 ++- tests/scripts/test_update_changelog_cli.py | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/devops/scripts/update_changelog.py b/src/devops/scripts/update_changelog.py index 16edb6e..e721c35 100644 --- a/src/devops/scripts/update_changelog.py +++ b/src/devops/scripts/update_changelog.py @@ -6,7 +6,8 @@ import typer from devops.config import config -from devops.files.update_changelog import DevOpsChangelogError, update_changelog as update_changelog_func +from devops.files.update_changelog import DevOpsChangelogError +from devops.files.update_changelog import update_changelog as update_changelog_func from devops.utils import mstd_print update_changelog = typer.Typer() diff --git a/tests/scripts/test_update_changelog_cli.py b/tests/scripts/test_update_changelog_cli.py index fc2c983..2292948 100644 --- a/tests/scripts/test_update_changelog_cli.py +++ b/tests/scripts/test_update_changelog_cli.py @@ -11,7 +11,6 @@ from devops.files.update_changelog import DevOpsChangelogError from devops.scripts.update_changelog import update_changelog, update_changelogs - if typing.TYPE_CHECKING: from pathlib import Path as PathType @@ -254,10 +253,11 @@ def test_update_changelogs_handles_partial_failures( changelog2 = tmp_path / "CHANGELOG2.md" changelog3 = tmp_path / "CHANGELOG3.md" - def mock_update_side_effect(version: str, path: Path) -> None: + def mock_update_side_effect(_version: str, path: Path) -> None: # Fail for the second changelog if path == Path(changelog2): - raise DevOpsChangelogError("Mock error for CHANGELOG2") + msg = "Mock error for CHANGELOG2" + raise DevOpsChangelogError(msg) with patch( "devops.scripts.update_changelog.update_changelog_func"