Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 56 additions & 10 deletions src/devops/config/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -136,7 +137,46 @@ 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(
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)
Comment on lines +173 to +176

Copilot AI Jan 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function performs redundant lookups by calling get_str or get_str_list after already retrieving the value. Instead of re-querying the mapping, directly return the validated value to avoid duplicate lookups and potential inconsistencies. The current implementation also doesn't handle the case where the default is provided and used as the value - it will be passed to nested functions unnecessarily.

Suggested change
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)
return value
if isinstance(value, list) and all(isinstance(item, str) for item in value):
return value

Copilot uses AI. Check for mistakes.

msg = f"Expected str or list of str for key '{key}', got {type(value).__name__}"
raise ConfigError(msg)


def get_str_enum(
Expand Down Expand Up @@ -165,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
Expand Down Expand Up @@ -205,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 []
Expand Down
99 changes: 96 additions & 3 deletions src/devops/config/config_file.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,32 @@
"""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
from .base import ConfigError, get_str, get_str_or_str_list, get_table


@dataclass(frozen=True)
class FileConfig:
"""Dataclass to hold file configuration values."""

encoding: str = "utf-8"
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.
Expand All @@ -23,6 +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_paths)
lines.append(f'#changelog_paths = ["{paths_str}"]\n')
lines.append(f'#_default_changelog_path = "{self.default_changelog_path}"\n')

Copilot AI Jan 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _default_changelog_path field is an internal implementation detail (indicated by the leading underscore) and should not be exposed in the TOML configuration output. Users should only configure changelog_paths and optionally default_changelog_path (without the underscore). Consider removing this line or changing it to default_changelog_path if this is intended to be a user-facing configuration option.

Suggested change
lines.append(f'#_default_changelog_path = "{self.default_changelog_path}"\n')
lines.append(f'#default_changelog_path = "{self.default_changelog_path}"\n')

Copilot uses AI. Check for mistakes.
return lines


Expand All @@ -46,12 +65,86 @@ def parse_file_config(raw_config: dict) -> FileConfig:
"""
table = get_table(raw_config, "file")

encoding = parse_encoding(table)
changelog_paths = parse_changelog_path(table)
default_changelog_path = parse_default_changelog_path(table)

return FileConfig(
encoding=encoding,
changelog_paths=changelog_paths,
_default_changelog_path=default_changelog_path,
)


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

Copilot AI Jan 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use double hash ## for section comments rather than triple hash ### to maintain consistency with Python documentation conventions. Triple hash is typically reserved for major section headers in documentation.

Suggested change
### Validate encoding
## Validate encoding

Copilot uses AI. Check for mistakes.
try:
Path(__file__).open("r", encoding=encoding).close()
except LookupError as e:
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

Copilot AI Jan 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code references FileConfig.changelog_path (singular), but the actual attribute defined in the FileConfig class is changelog_paths (plural). This will cause an AttributeError at runtime. Change FileConfig.changelog_path to FileConfig.changelog_paths.

Suggested change
table, "changelog_path", FileConfig.changelog_path
table, "changelog_path", FileConfig.changelog_paths

Copilot uses AI. Check for mistakes.
)

if isinstance(changelog_paths, str):
changelog_paths = [changelog_paths]

Comment on lines +123 to +128

Copilot AI Jan 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This normalization logic is redundant because get_str_or_str_list already returns either a string or list. If you want the result to always be a list, this conversion should happen, but the function name and logic suggest it can return either type. Consider returning the value directly and removing this check, or ensure the contract is clear about always returning a list from this function.

Suggested change
table, "changelog_path", FileConfig.changelog_path
)
if isinstance(changelog_paths, str):
changelog_paths = [changelog_paths]
table,
"changelog_path",
FileConfig.changelog_paths,
)

Copilot uses AI. Check for mistakes.
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
4 changes: 2 additions & 2 deletions tests/config/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down Expand Up @@ -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)


Expand Down
Loading