From 937d126fdb054550c690e558c9ad426330da4a24 Mon Sep 17 00:00:00 2001 From: kzoltner Date: Thu, 24 Sep 2026 15:11:51 +0200 Subject: [PATCH 01/21] BREAKING: Switched Source Model Instead of defining enum and options, now I use a single Source with its own inner settings. Every source is applied in order, so the overrides were replaced with a simple DictSource. Same behaviour can be achieved by simply adding DictSource at the end. --- README.md | 188 ++++++++-------- src/appysetty/__init__.py | 7 +- src/appysetty/env.py | 2 +- src/appysetty/model.py | 22 +- src/appysetty/parse.py | 65 ++++++ src/appysetty/read.py | 239 ++------------------ src/appysetty/source.py | 145 ++++++++++++ src/appysetty/write.py | 2 +- tests/test_dict_source.py | 41 ++++ tests/test_env_source.py | 81 +++++++ tests/test_model.py | 16 -- tests/test_parse.py | 87 ++++++++ tests/test_read.py | 451 ++------------------------------------ tests/test_yaml_source.py | 170 ++++++++++++++ 14 files changed, 745 insertions(+), 771 deletions(-) create mode 100644 src/appysetty/parse.py create mode 100644 src/appysetty/source.py create mode 100644 tests/test_dict_source.py create mode 100644 tests/test_env_source.py create mode 100644 tests/test_parse.py create mode 100644 tests/test_yaml_source.py diff --git a/README.md b/README.md index 08cf2ae..8e52e46 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ # ApPySetty ⚙️ -**A simple, type-safe Python library for managing application configuration from environment variables and YAML.** +**A simple, type-safe Python library for managing application configuration from Environment variables and YAML.** ApPySetty uses Python dataclasses as the single definition of your application configuration. It can load values from configuration files and environment variables and generate documentation from the same definition. @@ -15,12 +15,12 @@ Install: uv add appysetty ``` -Define your configuration: +Define your configuration and read from Environment: ```python from dataclasses import dataclass -from appysetty import AppConfigSource, read_configuration +from appysetty import EnvSource, read_configuration @dataclass @@ -32,22 +32,11 @@ class Config: config = read_configuration( Config, - AppConfigSource.ENV, + EnvSource(), ) ``` -> [!note] -> Please note that `.strip()` is applied to all string values which removes leading and trailing whitespaces - -Override values with environment variables: - -```bash -export HOST="0.0.0.0" -export PORT="9000" -export DEBUG="true" -``` - -Or load them from YAML: +Or load values from YAML: ```yaml host: localhost @@ -58,51 +47,117 @@ debug: false ```python config = read_configuration( Config, - AppConfigSource.YAML, + YamlSource(path="yaml_file.yaml"), +) +``` + +Or both: + +```python +config = read_configuration( + Config, + [YamlSource(path="yaml_file.yaml"), EnvSource()], ) ``` -And also document your configuration with an example yaml and a markdown document: +> [!note] +> Sources are applied in order. Later sources override values from earlier sources. + +And also document your configuration with an example .yaml and a markdown document: ```python write_configuration_documentation(Config, output_dir=Path("./docs")) ``` +> [!caution] +> Please note that `.strip()` is applied to all string values which removes leading and trailing whitespaces. Inputs like ` hello ` would become `hello`. + ## Configuration Sources -ApPySetty currently supports: +ApPySetty uses `AppConfigSource` as the interface to define loaders. These sources are loaded and applied in the order they are provided. -- `ENV`: environment variables using UPPER_SNAKE_CASE only -- `YAML`: YAML configuration files +```python +cfg = read_configuration( + Config, + [YamlSource(...), EnvSource(...), DictSource(...)] +) +``` -Multiple sources can be combined. Later sources overwrite values from earlier sources: +In the example above, YAML values are applied first, then environment variables, and finally dictionary values. Later sources override values from earlier sources. + +The available sources are: + +#### `EnvSource()` - Reading from Environment ```python -config = read_configuration( +cfg = read_configuration( Config, - [ - AppConfigSource.YAML, - AppConfigSource.ENV, - ], + EnvSource(prefix="MY_PREFIX") ) ``` -By default, YAML files are searched for in: +For every key within the config, the key is converted to UPPER_SNAKE_CASE, the optional prefix is applied and the resulting key is used to read a value from the environment. + +> [!note] +> The prefix itself will not be converted to UPPER_SNAKE_CASE + +#### `DictSource()` - Reading from a Dict + +```python +cfg = read_configuration( + Config, + DictSource(input={"key": "val"}) +) +``` + +Values are read from the provided dictionary using the configuration field names as keys. Unknown dictionary keys are rejected. + +#### `YamlSource()` - Reading from a .yaml file + +```python +cfg = read_configuration( + Config, + YamlSource(path="", required=True) +) +``` + +If path is specified, that file is used. Otherwise, the first existing file from the following list is used: ```text -config.yaml config.yml -config/config.yaml +config.yaml config/config.yml +config/config.yaml ``` -An explicit YAML path can also be provided. +If required is False, a missing file will simply be ignored. If required is True an AppConfigError is raised. By default required is set to True. -## Usage Details +> [!note] +> Only flat mappings are allowed and the YAML key must match the config key exactly -### Define Config +#### Define your own source -First you need to define a dataclass which contains all the configuration options you want to support. +All sources are based on the `AppConfigSource`. To extend the list of sources, you could supply your own implementation: + +```python +@dataclass(frozen=True) +class MyOwnSource(AppConfigSource): + """Example for your source, based on the DictSource""" + + input: dict[str, str] + + def load(self, config_type_hints): + values: dict[str, object] = {} + + for name, value in self.input.items(): + ... + + return values +``` + +## Define Config + +The simplest form of a config class looks like this: ```python @dataclass @@ -114,7 +169,7 @@ class Config: ``` > [!note] -> As of now, only `str`, `int`, `float` and `bool` are supported. Experience shows that other types are usually better handled on the user side +> As of now, only `str`, `int`, `float` and `bool` are supported You can also extend your dataclass with additional information for better documentation and for masking secrets: @@ -130,63 +185,18 @@ class ConfigWithMetadata: str, AppConfigEntry(description="The database password", is_secret=True), ] = "secret" -``` - -Both variants can be mixed. If no description is provided, the name of the field will be the description. - -### Read Config - -To read the config, use: - -```python -cfg = read_configuration(ConfigWithMetadata, AppConfigSource.ENV) -``` - -After that `cfg` should have all configurations with auto-complete ready for you. - -You can also pass an instance and use multiple sources, where each source will overwrite the previous one: - -```python -cfg = read_configuration( - ConfigWithMetadata(), [AppConfigSource.ENV, AppConfigSource.YAML] -) -``` - -The available sources are: -| Key | Source | Description | -| ---------------------- | ----------- | ---------------------------------------------------------------------------------------- | -| `AppConfigSource.ENV` | Environment | This will read config from environment, using UPPER_SNAKE_CASE variant of the field name | -| `AppConfigSource.YAML` | YAML file | This will read the config from a yaml file, only matching field name exactly | - -#### Options - -Options can be used to customize the config: - -```python -cfg = read_configuration( - ConfigWithMetadata, - [AppConfigSource.ENV, AppConfigSource.YAML], - AppConfigOptions( - env_prefix="MY_APP_PREFIX", - yaml_path="config.dev.yaml", - overwrite={"port": "8000"}, - ), -) + debug: bool = False ``` -| Option | Description | -| ------------ | ----------------------------------------------------------------------------------------------- | -| `env_prefix` | A Prefix that will be prepended to all field names using UPPER_SNAKE_CASE to read environment . | -| `yaml_path` | Setting a specific yaml file to use. It unset, the tool will look for `(config)/config.y(a)ml`. | -| `overwrite` | This accepts a mapping. values set with overwrite will always overwrite anything else | +Both variants can be mixed. If no description is provided, the name of the field will be the description. ### Write Documentation -The second feature of this tool is automated creation of a few documentation items for configuration options: +One feature of this tool is automating the documentation items for configuration options: -- `config.example.yaml` containing an example yaml file with default values and descriptive comments (if descriptions were defined) -- `DefaultConfiguration.md` containing a table of all options with ENV variant, a docker environment block for docker compose and a docker run example command with all -e set. +- `config.example.yaml` containing an example YAML file with default values and descriptive comments (if descriptions were defined) +- `DefaultConfiguration.md` containing a table of all options with ENV variant, a docker compose `environment` block for docker compose and a docker run example command with all -e set. To create the documentation, use: @@ -196,7 +206,7 @@ write_configuration_documentation( ConfigWithMetadata, env_prefix="MY_APP_PREFIX", output_dir=Path() ) -# Only create yaml example +# Only create YAML example write_config_yaml_example(ConfigWithMetadata, output_dir=Path()) # Only create markdown document @@ -206,6 +216,9 @@ write_config_markdown(ConfigWithMetadata, env_prefix="MY_APP_PREFIX", output_dir > [!note] > `env_prefix` defaults to "" if not set and `output_dir` defaults to `./docs/config` if not set. +> [!caution] +> Make sure to always match the `env_prefix` to the actual prefix used for the EnvSource if applied + ## Development Clone the repository and install the development dependencies: @@ -235,7 +248,8 @@ uv run python -m examples.write_documentation uv run python -m examples.read_documentation ``` -Ruffing: +Run Ruff: + ```bash uv run ruff check uv run ruff format . diff --git a/src/appysetty/__init__.py b/src/appysetty/__init__.py index 0ec2636..5072ded 100644 --- a/src/appysetty/__init__.py +++ b/src/appysetty/__init__.py @@ -1,5 +1,6 @@ -from appysetty.model import AppConfigEntry, AppConfigOptions, AppConfigSource +from appysetty.model import AppConfigEntry, AppConfigSource from appysetty.read import read_configuration +from appysetty.source import DictSource, EnvSource, YamlSource from appysetty.write import ( write_config_markdown, write_config_yaml_example, @@ -8,8 +9,10 @@ __all__ = [ "AppConfigEntry", - "AppConfigOptions", "AppConfigSource", + "DictSource", + "EnvSource", + "YamlSource", "read_configuration", "write_config_markdown", "write_config_yaml_example", diff --git a/src/appysetty/env.py b/src/appysetty/env.py index f1dd360..e4b3ead 100644 --- a/src/appysetty/env.py +++ b/src/appysetty/env.py @@ -1,4 +1,4 @@ -def get_env_name(prefix: str | None, field_name: str) -> str: +def get_env_name(field_name: str, prefix: str | None) -> str: """Returns MY_PREFIX_FIELD_NAME from MY_PREFIX(_) and the fields name""" name = field_name.upper() diff --git a/src/appysetty/model.py b/src/appysetty/model.py index dd14ab4..f9c8858 100644 --- a/src/appysetty/model.py +++ b/src/appysetty/model.py @@ -1,13 +1,13 @@ +from abc import ABC, abstractmethod from collections.abc import Callable, Mapping from dataclasses import dataclass -from enum import Enum -from pathlib import Path -class AppConfigSource(Enum): - ENV = "env" - YAML = "yaml" - TOML = "toml" +class AppConfigSource(ABC): + """Represents any source, which only provide a load method""" + + @abstractmethod + def load(self, config_type_hints: Mapping[str, object]) -> Mapping[str, object]: ... class AppConfigError(Exception): @@ -31,13 +31,3 @@ class AppConfigEntry: # AppConfigEntryVisitor # [field_name, field_type_as_str, entry] AppConfigEntryVisitor = Callable[[str, str, AppConfigEntry], None] - - -@dataclass -class AppConfigOptions: - env_prefix: str | None = None - """prefix to use to read environment variables. Should be UPPER_SNAKE_CASE.""" - yaml_path: Path | str | None = None - """Path to read YAML values from. Defaults to ./(config)/config.y(a)ml if not set""" - overwrite: Mapping[str, str] | None = None - """Mapping to overwrite any existing values. Mostly useful for testing, not intended for production use""" diff --git a/src/appysetty/parse.py b/src/appysetty/parse.py new file mode 100644 index 0000000..0041729 --- /dev/null +++ b/src/appysetty/parse.py @@ -0,0 +1,65 @@ +from typing import Annotated, get_args, get_origin + +from appysetty.model import AppConfigError + + +def parse_value_from_string(value: str, value_type: object) -> object: + """Parses a string to either str, int, float or bool. Other types are not supported.""" + if get_origin(value_type) is Annotated: + value_type = get_args(value_type)[0] + + trimmed = value.strip() + + if value_type is str: + return trimmed + + if value_type is int: + return int(trimmed) + + if value_type is float: + return float(trimmed) + + if value_type is bool: + normalized = trimmed.lower() + return normalized in {"true", "1", "yes", "on"} + + raise AppConfigError(f"Unsupported configuration type {value_type}") + + +def parse_value(value: object, value_type: object) -> object: + """Parses more generic input to either str, int, float or bool. Other types are not supported.""" + if get_origin(value_type) is Annotated: + value_type = get_args(value_type)[0] + + if isinstance(value, str): + return parse_value_from_string(value=value, value_type=value_type) + + if value_type is int and isinstance(value, int) and not isinstance(value, bool): + return value + + if ( + value_type is float + and isinstance(value, (int, float)) + and not isinstance(value, bool) + ): + return float(value) + + if value_type is bool and isinstance(value, bool): + return value + + if value_type is bool and isinstance(value, int): + if value == 0: + return False + elif value == 1: + return True + + raise AppConfigError( + f"Invalid value: expected {value_type}, got {type(value).__name__}" + ) + + +def type_to_string(field_type: object) -> str: + """Returns a concise string representation of a type.""" + if isinstance(field_type, type): + return field_type.__name__ + return str(field_type) diff --git a/src/appysetty/read.py b/src/appysetty/read.py index 420ab8e..bfa3f3b 100644 --- a/src/appysetty/read.py +++ b/src/appysetty/read.py @@ -1,28 +1,22 @@ -import os import warnings -from collections.abc import Mapping, Sequence +from collections.abc import Sequence from dataclasses import is_dataclass -from pathlib import Path from typing import Annotated, cast, get_args, get_origin, get_type_hints -import yaml - -from appysetty.env import get_env_name from appysetty.model import ( AppConfigEntry, AppConfigEntryVisitor, AppConfigError, - AppConfigOptions, AppConfigSource, AppConfigVisitor, AppConfigWarning, ) +from appysetty.parse import type_to_string def read_configuration[T]( config: type[T] | T, - sources: AppConfigSource | Sequence[AppConfigSource], - options: AppConfigOptions | None = None, + sources: AppConfigSource | Sequence[AppConfigSource] | None = None, ) -> T: """Read application configuration from at least one source @@ -41,7 +35,9 @@ def read_configuration[T]( Raises: AppConfigError: whenever the provided value is invalid """ - if isinstance(sources, AppConfigSource): + if sources is None: + sources = [] + elif isinstance(sources, AppConfigSource): sources = [sources] if isinstance(config, type): @@ -63,18 +59,24 @@ def read_configuration[T]( stacklevel=2, ) + type_hints = get_type_hints(cfg_type, include_extras=True) + + values = {field_name: getattr(cfg, field_name) for field_name in type_hints} + for source in sources: - if source == AppConfigSource.ENV: - cfg = _read_env(cfg, cfg_type, options) - elif source == AppConfigSource.YAML: - cfg = _read_yaml(cfg, cfg_type, options) - else: - raise AppConfigError(f"Unsupported source: {source}") + next_values = source.load(type_hints) - if options is not None and options.overwrite is not None: - cfg = _apply_overwrite(cfg, cfg_type, options.overwrite) + unknown = next_values.keys() - type_hints.keys() + + if unknown: + raise AppConfigError( + f"{type(source).__name__} returned unkown fields that are not part of configuration " + f"fields: {', '.join(sorted(unknown))}" + ) - return cast(T, cfg) + values.update(next_values) + + return cast(T, cfg_type(**values)) def visit_config_entries[T](config: object, visitor: AppConfigEntryVisitor): @@ -92,7 +94,7 @@ def visit_config_entries[T](config: object, visitor: AppConfigEntryVisitor): if get_origin(field_type) is Annotated: field_type = get_args(field_type)[0] - visitor(field_name, _type_to_string(field_type), entry) + visitor(field_name, type_to_string(field_type), entry) def visit_config_strings[T](config: object, visitor: AppConfigVisitor): @@ -110,195 +112,7 @@ def visit_config_strings[T](config: object, visitor: AppConfigVisitor): if get_origin(field_type) is Annotated: field_type = get_args(field_type)[0] - visitor(field_name, _type_to_string(field_type), field_value) - - -def _read_env[T]( - config: T, config_type: type[T], options: AppConfigOptions | None -) -> T: - """Reads the environment while using a possible env_prefix in UPPER_SNAKE_CASE""" - prefix = options.env_prefix if options is not None else None - type_hints = get_type_hints(config_type, include_extras=True) - - values: dict[str, object] = {} - - for field_name in type_hints: - env_name = get_env_name(prefix, field_name) - val = os.environ.get(env_name, None) - - if val is None: - continue - - try: - values[field_name] = _parse_value_from_string( - val, - type_hints[field_name], - ) - except Exception as e: - raise AppConfigError(f"Failed to parse {env_name} from ENV: {e}") from e - - for field_name in type_hints: - if field_name not in values: - values[field_name] = getattr(config, field_name) - - return config_type(**values) - - -def _read_yaml[T]( - config: T, config_type: type[T], options: AppConfigOptions | None -) -> T: - """Reads configuration from a flat yaml file""" - yaml_path = _get_yaml_path(options) - - if yaml_path is None: - raise AppConfigError("YAML was used as source, but no yaml file was found") - - try: - with yaml_path.open("r", encoding="utf-8") as file: - yaml_values: object = yaml.safe_load(file) - except OSError as e: - raise AppConfigError( - f"Failed to read YAML configuration from {yaml_path}: {e}" - ) from e - except yaml.YAMLError as e: - raise AppConfigError( - f"Failed to parse YAML configuration from {yaml_path}: {e}" - ) from e - - if yaml_values is None: - return config - - if not isinstance(yaml_values, Mapping): - raise AppConfigError( - f"Expected YAML configuration to contain a mapping, but got {type(yaml_values).__name__}" - ) - - type_hints = get_type_hints(config_type, include_extras=True) - values: dict[str, object] = {} - - for field_name in type_hints: - try: - if field_name not in yaml_values: - continue - - value = yaml_values[field_name] - if value is None: - continue - - values[field_name] = _parse_value( - yaml_values[field_name], type_hints[field_name] - ) - except Exception as e: - raise AppConfigError(f"Failed to parse {field_name} from YAML: {e}") from e - - for field_name in type_hints: - if field_name not in values: - values[field_name] = getattr(config, field_name) - - return config_type(**values) - - -def _apply_overwrite[T]( - config: T, config_type: type[T], overwrites: Mapping[str, str] -) -> T: - """Uses the string mapping as ENV to write configuration. Intended for testing.""" - type_hints = get_type_hints(config_type, include_extras=True) - config_fields = {field_name for field_name in type_hints} - - values: dict[str, object] = {} - - for name, value in overwrites.items(): - if name not in config_fields: - raise AppConfigError(f"Unknown configuration field in overwrite: {name}") - - try: - values[name] = _parse_value_from_string( - value, - type_hints[name], - ) - except Exception as e: - raise AppConfigError(f"Failed to parse overwrite for {name}: {e}") from e - - for field_name in type_hints: - if field_name not in overwrites: - values[field_name] = getattr(config, field_name) - - return config_type(**values) - - -def _get_yaml_path(options: AppConfigOptions | None) -> Path | None: - """Returns either the set yaml_path in options or the first found path for (config)/config.y(a)ml. - Returns None if no path was found""" - if options is not None and options.yaml_path is not None: - return Path(options.yaml_path) - - candidates = ( - Path("config.yaml"), - Path("config.yml"), - Path("config", "config.yaml"), - Path("config", "config.yml"), - ) - - for yaml_path in candidates: - if yaml_path.is_file(): - return yaml_path - - return None - - -def _parse_value_from_string(value: str, value_type: object) -> object: - """Parses a string to either str, int, float or bool. Other types are not supported.""" - if get_origin(value_type) is Annotated: - value_type = get_args(value_type)[0] - - trimmed = value.strip() - - if value_type is str: - return trimmed - - if value_type is int: - return int(trimmed) - - if value_type is float: - return float(trimmed) - - if value_type is bool: - normalized = trimmed.lower() - return normalized in {"true", "1", "yes", "on"} - - raise AppConfigError(f"Unsupported configuration type {value_type}") - - -def _parse_value(value: object, value_type: object) -> object: - """Parses more generic input to either str, int, float or bool. Other types are not supported.""" - if get_origin(value_type) is Annotated: - value_type = get_args(value_type)[0] - - if isinstance(value, str): - return _parse_value_from_string(value=value, value_type=value_type) - - if value_type is int and isinstance(value, int) and not isinstance(value, bool): - return value - - if ( - value_type is float - and isinstance(value, (int, float)) - and not isinstance(value, bool) - ): - return float(value) - - if value_type is bool and isinstance(value, bool): - return value - - if value_type is bool and isinstance(value, int): - if value == 0: - return False - elif value == 1: - return True - - raise AppConfigError( - f"Invalid value: expected {value_type}, got {type(value).__name__}" - ) + visitor(field_name, type_to_string(field_type), field_value) def _get_config_entry(value_type: object) -> AppConfigEntry | None: @@ -311,10 +125,3 @@ def _get_config_entry(value_type: object) -> AppConfigEntry | None: for metadata in args[1:]: if isinstance(metadata, AppConfigEntry): return metadata - - -def _type_to_string(field_type: object) -> str: - """Returns a concise string representation of a type.""" - if isinstance(field_type, type): - return field_type.__name__ - return str(field_type) diff --git a/src/appysetty/source.py b/src/appysetty/source.py new file mode 100644 index 0000000..cc94409 --- /dev/null +++ b/src/appysetty/source.py @@ -0,0 +1,145 @@ +import os +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path + +import yaml + +from appysetty import AppConfigSource +from appysetty.env import get_env_name +from appysetty.model import AppConfigError +from appysetty.parse import parse_value, parse_value_from_string + + +@dataclass(frozen=True) +class EnvSource(AppConfigSource): + """Uses Environemtn as source while all keys are converted to UPPER_SNAKE_CASE (with an optional PREFIX if supplied)""" + + prefix: str | None = None + + def load(self, config_type_hints): + values: dict[str, object] = {} + + for field_name in config_type_hints: + env_name = get_env_name(field_name, self.prefix) + val = os.environ.get(env_name, None) + + if val is None: + continue + + try: + values[field_name] = parse_value_from_string( + val, + config_type_hints[field_name], + ) + except Exception as e: + raise AppConfigError(f"Failed to parse {env_name} from ENV: {e}") from e + + return values + + +@dataclass(frozen=True) +class DictSource(AppConfigSource): + """Uses a simple dict[str,str] as input. Keys must match names of config dataclass""" + + input: dict[str, str] + + def load(self, config_type_hints): + values: dict[str, object] = {} + + for name, value in self.input.items(): + try: + if name not in config_type_hints: + raise AppConfigError(f"Dict key {name} not found in configuration field names") + + values[name] = parse_value_from_string( + value, + config_type_hints[name], + ) + except Exception as e: + raise AppConfigError( + f"Failed to parse overwrite for {name}: {e}" + ) from e + + return values + + +@dataclass(frozen=True) +class YamlSource(AppConfigSource): + """Reads input from YAML file, ignoring non-existing files when required is False. + + If no path is specified, the first file of the following list is used: + [config.yml, config.yaml, config/config.yml, config.config.yaml] + + If required is True and no file is found, an error is raised. + """ + + path: Path | str | None = None + required: bool = True + + def load(self, config_type_hints): + yaml_path: Path | None = None + + if self.path is None: + candidates = [ + Path("config.yml"), + Path("config.yaml"), + Path("config", "config.yml"), + Path("config", "config.yaml"), + ] + + for candidate in candidates: + if candidate.is_file(): + yaml_path = candidate + elif isinstance(self.path, str): + yaml_path = Path(self.path) + else: + yaml_path = self.path + + if yaml_path is None: + if self.required: + raise AppConfigError( + "YAML was used as required source, but no yaml file was found" + ) + else: + return {} + + try: + with yaml_path.open("r", encoding="utf-8") as file: + yaml_values: object = yaml.safe_load(file) + except OSError as e: + raise AppConfigError( + f"Failed to read YAML configuration from {yaml_path}: {e}" + ) from e + except yaml.YAMLError as e: + raise AppConfigError( + f"Failed to parse YAML configuration from {yaml_path}: {e}" + ) from e + + if yaml_values is None: + return {} + + if not isinstance(yaml_values, Mapping): + raise AppConfigError( + f"Expected YAML configuration to contain a mapping, but got {type(yaml_values).__name__}" + ) + + values: dict[str, object] = {} + + for yaml_key in yaml_values: + yaml_value = yaml_values[yaml_key] + + if yaml_value is None: + continue + + if yaml_key not in config_type_hints: + raise AppConfigError(f"YAML key {yaml_key} not found in configuration field names") + + try: + values[yaml_key] = parse_value(yaml_value, config_type_hints[yaml_key]) + except Exception as e: + raise AppConfigError( + f"Failed to parse {yaml_key} from YAML: {e}" + ) from e + + return values diff --git a/src/appysetty/write.py b/src/appysetty/write.py index 4614089..7be3254 100644 --- a/src/appysetty/write.py +++ b/src/appysetty/write.py @@ -84,7 +84,7 @@ def collect_entry(field_name: str, field_type: str, entry: AppConfigEntry) -> No entries.append( MarkdownInfoEntry( - env_name=get_env_name(env_prefix, field_name), + env_name=get_env_name(field_name, env_prefix), field_name=field_name, field_type=field_type, default_value=getattr(cfg, field_name), diff --git a/tests/test_dict_source.py b/tests/test_dict_source.py new file mode 100644 index 0000000..85b2526 --- /dev/null +++ b/tests/test_dict_source.py @@ -0,0 +1,41 @@ +from dataclasses import dataclass + +import pytest + +from appysetty import read_configuration +from appysetty.model import AppConfigError +from appysetty.source import DictSource + + +@dataclass +class Config: + host: str = "localhost" + port: int = 8080 + debug: bool = False + timeout: float = 5.0 + + +class TestReadConfigurationFromDict: + def test_overwrite(self): + inputs = { + "host": "example.com", + "port": "9000", + "debug": "true", + "timeout": "2.5", + } + + config = read_configuration(Config, DictSource(input=inputs)) + + assert config.host == "example.com" + assert config.port == 9000 + assert config.debug is True + assert config.timeout == 2.5 + + def test_overwrite_unknown_field_raises(self): + inputs = {"does_not_exist": "value"} + + with pytest.raises( + AppConfigError, + match="not found", + ): + read_configuration(Config, DictSource(input=inputs)) diff --git a/tests/test_env_source.py b/tests/test_env_source.py new file mode 100644 index 0000000..57df053 --- /dev/null +++ b/tests/test_env_source.py @@ -0,0 +1,81 @@ +from dataclasses import dataclass + +import pytest + +from appysetty import EnvSource, read_configuration +from appysetty.env import get_env_name +from appysetty.model import AppConfigError + + +@dataclass +class Config: + host: str = "localhost" + port: int = 8080 + debug: bool = False + timeout: float = 5.0 + + +class TestGetEnvName: + @pytest.mark.parametrize( + ("prefix", "field_name", "expected"), + [ + (None, "host", "HOST"), + ("", "host", "HOST"), + (" ", "host", "HOST"), + ("APP", "host", "APP_HOST"), + ("APP_", "host", "APP_HOST"), + ("MY_APP", "database_url", "MY_APP_DATABASE_URL"), + ("MY_APP_", "database_url", "MY_APP_DATABASE_URL"), + ], + ) + def test_get_env_name(self, prefix, field_name, expected): + assert get_env_name(field_name, prefix) == expected + + +class TestReadConfigurationFromEnv: + def test_reads_environment_variables(self, monkeypatch): + monkeypatch.setenv("HOST", "example.com") + monkeypatch.setenv("PORT", "9000") + monkeypatch.setenv("DEBUG", "true") + monkeypatch.setenv("TIMEOUT", "2.5") + + config = read_configuration(Config, sources=EnvSource()) + + assert config.host == "example.com" + assert config.port == 9000 + assert config.debug is True + assert config.timeout == 2.5 + + def test_environment_uses_defaults_for_missing_values(self, monkeypatch): + monkeypatch.setenv("HOST", "example.com") + + config = read_configuration(Config, sources=EnvSource()) + + assert config.host == "example.com" + assert config.port == 8080 + assert config.debug is False + assert config.timeout == 5.0 + + def test_environment_prefix(self, monkeypatch): + monkeypatch.setenv("APP_HOST", "example.com") + monkeypatch.setenv("APP_PORT", "9000") + + config = read_configuration(Config, sources=EnvSource(prefix="APP")) + + assert config.host == "example.com" + assert config.port == 9000 + + def test_environment_prefix_with_trailing_underscore(self, monkeypatch): + monkeypatch.setenv("APP_HOST", "example.com") + monkeypatch.setenv("APP_PORT", "9000") + + config = read_configuration(Config, sources=EnvSource(prefix="APP_")) + + assert config.host == "example.com" + assert config.port == 9000 + + def test_invalid_environment_value_raises_app_config_error(self, monkeypatch): + monkeypatch.setenv("PORT", "not-an-integer") + + with pytest.raises(AppConfigError, match="Failed to parse PORT"): + read_configuration(Config, sources=EnvSource()) diff --git a/tests/test_model.py b/tests/test_model.py index 29259dd..19b5dcb 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -1,16 +1,8 @@ from appysetty.model import ( AppConfigEntry, - AppConfigOptions, - AppConfigSource, ) -def test_sources(): - assert AppConfigSource.ENV.value == "env" - assert AppConfigSource.YAML.value == "yaml" - assert AppConfigSource.TOML.value == "toml" - - def test_entry_defaults(): entry = AppConfigEntry("A description") @@ -23,11 +15,3 @@ def test_entry_secret(): assert entry.description == "A secret" assert entry.is_secret is True - - -def test_options_defaults(): - options = AppConfigOptions() - - assert options.env_prefix is None - assert options.yaml_path is None - assert options.overwrite is None diff --git a/tests/test_parse.py b/tests/test_parse.py new file mode 100644 index 0000000..c289007 --- /dev/null +++ b/tests/test_parse.py @@ -0,0 +1,87 @@ +from dataclasses import dataclass + +import pytest + +from appysetty import read_configuration +from appysetty.model import AppConfigError, AppConfigWarning +from appysetty.source import DictSource + + +@dataclass +class Config: + host: str = "localhost" + port: int = 8080 + debug: bool = False + timeout: float = 5.0 + + +class TestParsing: + @pytest.mark.parametrize( + ("field", "value", "expected"), + [ + ("host", "example.com", "example.com"), + ("host", " example.com ", "example.com"), + ("port", "9000", 9000), + ("timeout", "2.5", 2.5), + ("debug", " true ", True), + ("debug", " 1 ", True), + ("debug", "true", True), + ("debug", "1", True), + ("debug", "yes", True), + ("debug", "on", True), + ("debug", "false", False), + ("debug", "0", False), + ("debug", "no", False), + ("debug", "off", False), + ], + ) + def test_overwrite_parses_values(self, field, value, expected): + config = read_configuration(Config, DictSource({field: value})) + + assert getattr(config, field) == expected + + @pytest.mark.parametrize( + ("field", "value"), + [ + ("port", "not-an-int"), + ("timeout", "not-a-float"), + ], + ) + def test_invalid_numeric_overwrite_raises(self, field, value): + + with ( + pytest.raises( + AppConfigError, + match=f"Failed to parse overwrite for {field}", + ), + ): + read_configuration(Config, DictSource({field: value})) + + def test_unsupported_type_raises(self): + @dataclass + class UnsupportedConfig: + values: list[str] = None # type: ignore[assignment] + + with pytest.raises( + AppConfigError, + match="Unsupported configuration type", + ): + read_configuration( + UnsupportedConfig, + DictSource( + input={"values": "foo"}, + ), + ) + + def test_config_instance_can_be_passed(self): + original = Config( + host="custom-host", + port=1234, + debug=True, + timeout=10.0, + ) + + with pytest.warns(AppConfigWarning, match="No configuration sources"): + config = read_configuration(original, []) + + assert config == original diff --git a/tests/test_read.py b/tests/test_read.py index 725bd05..1116ed3 100644 --- a/tests/test_read.py +++ b/tests/test_read.py @@ -1,22 +1,16 @@ from dataclasses import dataclass -from os import mkdir from typing import Annotated import pytest -from appysetty.env import get_env_name -from appysetty.model import ( - AppConfigEntry, - AppConfigError, - AppConfigOptions, - AppConfigSource, - AppConfigWarning, -) +from appysetty import EnvSource +from appysetty.model import AppConfigEntry, AppConfigError, AppConfigWarning from appysetty.read import ( read_configuration, visit_config_entries, visit_config_strings, ) +from appysetty.source import DictSource, YamlSource @dataclass @@ -43,329 +37,33 @@ class NonDataclassConfig: with pytest.raises(AppConfigError): read_configuration(NonDataclassConfig, []) - def test_reads_environment_variables(self, monkeypatch): - monkeypatch.setenv("HOST", "example.com") - monkeypatch.setenv("PORT", "9000") - monkeypatch.setenv("DEBUG", "true") - monkeypatch.setenv("TIMEOUT", "2.5") - - config = read_configuration(Config, AppConfigSource.ENV) - - assert config.host == "example.com" - assert config.port == 9000 - assert config.debug is True - assert config.timeout == 2.5 - - def test_environment_uses_defaults_for_missing_values(self, monkeypatch): - monkeypatch.setenv("HOST", "example.com") - - config = read_configuration(Config, AppConfigSource.ENV) - - assert config.host == "example.com" - assert config.port == 8080 - assert config.debug is False - assert config.timeout == 5.0 - - def test_environment_prefix(self, monkeypatch): - monkeypatch.setenv("APP_HOST", "example.com") - monkeypatch.setenv("APP_PORT", "9000") - - options = AppConfigOptions(env_prefix="APP") - - config = read_configuration(Config, AppConfigSource.ENV, options) - - assert config.host == "example.com" - assert config.port == 9000 - - def test_environment_prefix_with_trailing_underscore(self, monkeypatch): - monkeypatch.setenv("APP_HOST", "example.com") - monkeypatch.setenv("APP_PORT", "9000") - - options = AppConfigOptions(env_prefix="APP_") - - config = read_configuration(Config, AppConfigSource.ENV, options) - - assert config.host == "example.com" - assert config.port == 9000 - - def test_invalid_environment_value_raises_app_config_error(self, monkeypatch): - monkeypatch.setenv("PORT", "not-an-integer") - - with pytest.raises(AppConfigError, match="Failed to parse PORT"): - read_configuration(Config, AppConfigSource.ENV) - def test_sources_are_applied_in_order(self, monkeypatch, tmp_path): - monkeypatch.setenv("HOST", "from-env") - monkeypatch.setenv("DEBUG", "true") - - config_file = tmp_path / "config.yaml" - config_file.write_text( - """ - host: from-yaml - port: 9000 - """, - encoding="utf-8", - ) - - options = AppConfigOptions(yaml_path=config_file) - - config = read_configuration( - Config, - [AppConfigSource.ENV, AppConfigSource.YAML], - options, - ) - - assert config.host == "from-yaml" - assert config.port == 9000 - assert config.debug == True - - def test_yaml_source(self, tmp_path): - config_file = tmp_path / "config.yaml" - config_file.write_text( - """ - host: example.com - port: 9000 - debug: true - timeout: 2.5 - """, - encoding="utf-8", - ) - - options = AppConfigOptions(yaml_path=config_file) - - config = read_configuration(Config, AppConfigSource.YAML, options) - - assert config.host == "example.com" - assert config.port == 9000 - assert config.debug is True - assert config.timeout == 2.5 - - def test_yaml_uses_defaults_for_missing_values(self, tmp_path): - config_file = tmp_path / "config.yaml" - config_file.write_text( - "host: example.com", - encoding="utf-8", - ) - - options = AppConfigOptions(yaml_path=config_file) - - config = read_configuration(Config, AppConfigSource.YAML, options) - - assert config.host == "example.com" - assert config.port == 8080 - assert config.debug is False - assert config.timeout == 5.0 - - def test_yaml_path_can_be_string(self, tmp_path): - config_file = tmp_path / "config_test.yaml" - config_file.write_text( - "port: 9000", - encoding="utf-8", - ) - - options = AppConfigOptions(yaml_path=str(config_file)) - - config = read_configuration(Config, AppConfigSource.YAML, options) - - assert config.port == 9000 - - def test_yaml_path_is_none(self): - options = AppConfigOptions(yaml_path=None) - - with pytest.raises(AppConfigError): - read_configuration(Config, AppConfigSource.YAML, options) - - def test_yaml_path_defaults(self, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - - variants = [ - "config.yml", - "config.yaml", - "config/config.yml", - "config/config.yaml", - ] - - mkdir(tmp_path / "config") - - for variant in variants: - (tmp_path / variant).write_text("port: 9000\n") - - config = read_configuration(Config, AppConfigSource.YAML) - assert config.port == 9000 - - def test_yaml_skips_none_values(self, tmp_path): - config_file = tmp_path / "config.yaml" - config_file.write_text( - """ - host: example.com - port: - """, - encoding="utf-8", - ) - - options = AppConfigOptions(yaml_path=config_file) - - config = read_configuration(Config, AppConfigSource.YAML, options) - - assert config.host == "example.com" - assert config.port == 8080 - assert config.debug is False - assert config.timeout == 5.0 - - def test_yaml_raises_for_invalid_type(self, tmp_path): - config_file = tmp_path / "config.yaml" - config_file.write_text( - """ - host: example.com - port: [] - """, - encoding="utf-8", - ) - - options = AppConfigOptions(yaml_path=config_file) - - with pytest.raises(AppConfigError): - read_configuration(Config, AppConfigSource.YAML, options) - - def test_yaml_missing_path_raises(self): - options = AppConfigOptions(yaml_path="/does/not/exist/config.yaml") + @dataclass + class OrderConfig: + from_env: str = "not-set" + from_yaml: str = "not-set" + from_dict: str = "not-set" - with pytest.raises( - AppConfigError, - ): - read_configuration(Config, AppConfigSource.YAML, options) + monkeypatch.setenv("FROM_ENV", "from-env") - def test_yaml_invalid_syntax_raises(self, tmp_path): config_file = tmp_path / "config.yaml" config_file.write_text( - """ - host: [this is - invalid yaml - """, + "from_yaml: from-yaml", encoding="utf-8", ) - options = AppConfigOptions(yaml_path=config_file) - - with pytest.raises(AppConfigError): - read_configuration(Config, AppConfigSource.YAML, options) - - def test_yaml_requires_mapping(self, tmp_path): - config_file = tmp_path / "config.yaml" - config_file.write_text( - """ - - one - - two - """, - encoding="utf-8", - ) - - options = AppConfigOptions(yaml_path=config_file) - - with pytest.raises( - AppConfigError, - ): - read_configuration(Config, AppConfigSource.YAML, options) - - def test_empty_yaml_uses_defaults(self, tmp_path): - config_file = tmp_path / "config.yaml" - config_file.write_text("", encoding="utf-8") - - options = AppConfigOptions(yaml_path=config_file) - - config = read_configuration(Config, AppConfigSource.YAML, options) - - assert config == Config() - - def test_overwrite(self): - options = AppConfigOptions( - overwrite={ - "host": "example.com", - "port": "9000", - "debug": "true", - "timeout": "2.5", - } - ) - - with pytest.warns(AppConfigWarning, match="No configuration sources"): - config = read_configuration(Config, [], options) - - assert config.host == "example.com" - assert config.port == 9000 - assert config.debug is True - assert config.timeout == 2.5 - - def test_overwrite_takes_precedence_over_environment(self, monkeypatch): - monkeypatch.setenv("HOST", "from-env") - - options = AppConfigOptions( - overwrite={"host": "from-overwrite"}, - ) - config = read_configuration( - Config, - AppConfigSource.ENV, - options, - ) - - assert config.host == "from-overwrite" - - def test_overwrite_takes_precedence_over_yaml(self, tmp_path): - config_file = tmp_path / "config.yaml" - config_file.write_text( - "host: from-yaml", - encoding="utf-8", + OrderConfig, + [ + EnvSource(), + YamlSource(path=config_file, required=True), + DictSource(input={"from_dict": "from-dict"}), + ], ) - options = AppConfigOptions( - yaml_path=config_file, - overwrite={"host": "from-overwrite"}, - ) - - config = read_configuration( - Config, - AppConfigSource.YAML, - options, - ) - - assert config.host == "from-overwrite" - - def test_overwrite_unknown_field_raises(self): - options = AppConfigOptions( - overwrite={"does_not_exist": "value"}, - ) - - with ( - pytest.raises( - AppConfigError, - match="Unknown configuration field in overwrite", - ), - pytest.warns(AppConfigWarning, match="No configuration sources"), - ): - read_configuration(Config, [], options) - - # TODO: Add TOML tests once it is added - def test_unsupported_source_raises(self): - with pytest.raises(AppConfigError, match="Unsupported source"): - # TOML is deliberately not implemented. - read_configuration(Config, AppConfigSource.TOML) - - -class TestGetEnvName: - @pytest.mark.parametrize( - ("prefix", "field_name", "expected"), - [ - (None, "host", "HOST"), - ("", "host", "HOST"), - (" ", "host", "HOST"), - ("APP", "host", "APP_HOST"), - ("APP_", "host", "APP_HOST"), - ("MY_APP", "database_url", "MY_APP_DATABASE_URL"), - ("MY_APP_", "database_url", "MY_APP_DATABASE_URL"), - ], - ) - def test_get_env_name(self, prefix, field_name, expected): - assert get_env_name(prefix, field_name) == expected + assert config.from_env == "from-env" + assert config.from_yaml == "from-yaml" + assert config.from_dict == "from-dict" class TestAnnotatedConfig: @@ -381,34 +79,6 @@ class ConfigWithMetadata: AppConfigEntry(description="The database password", is_secret=True), ] = "secret" - def test_reads_environment_variables(self, monkeypatch): - monkeypatch.setenv("HOST", "example.com") - monkeypatch.setenv("PASSWORD", "9000") - - config = read_configuration(self.ConfigWithMetadata(), AppConfigSource.ENV) - - assert config.host == "example.com" - assert config.password == "9000" - - def test_yaml_source(self, tmp_path): - config_file = tmp_path / "config.yaml" - config_file.write_text( - """ - host: "example.com" - password: "9000" - """, - encoding="utf-8", - ) - - options = AppConfigOptions(yaml_path=config_file) - - config = read_configuration( - self.ConfigWithMetadata(), AppConfigSource.YAML, options - ) - - assert config.host == "example.com" - assert config.password == "9000" - def test_visit_config_entries(self): config = self.ConfigWithMetadata() entries = {} @@ -539,86 +209,3 @@ class Config: assert values["password"] == "Masked[len:12]" assert "super-secret" not in values["password"] - - -class TestParsing: - @pytest.mark.parametrize( - ("field", "value", "expected"), - [ - ("host", "example.com", "example.com"), - ("host", " example.com ", "example.com"), - ("port", "9000", 9000), - ("timeout", "2.5", 2.5), - ("debug", " true ", True), - ("debug", " 1 ", True), - ("debug", "true", True), - ("debug", "1", True), - ("debug", "yes", True), - ("debug", "on", True), - ("debug", "false", False), - ("debug", "0", False), - ("debug", "no", False), - ("debug", "off", False), - ], - ) - def test_overwrite_parses_values(self, field, value, expected): - options = AppConfigOptions( - overwrite={field: value}, - ) - - with pytest.warns(AppConfigWarning, match="No configuration sources"): - config = read_configuration(Config, [], options) - - assert getattr(config, field) == expected - - @pytest.mark.parametrize( - ("field", "value"), - [ - ("port", "not-an-int"), - ("timeout", "not-a-float"), - ], - ) - def test_invalid_numeric_overwrite_raises(self, field, value): - options = AppConfigOptions( - overwrite={field: value}, - ) - - with ( - pytest.raises( - AppConfigError, - match=f"Failed to parse overwrite for {field}", - ), - pytest.warns(AppConfigWarning, match="No configuration sources"), - ): - read_configuration(Config, [], options) - - def test_unsupported_type_raises(self): - @dataclass - class UnsupportedConfig: - values: list[str] = None # type: ignore[assignment] - - options = AppConfigOptions( - overwrite={"values": "foo"}, - ) - - with ( - pytest.raises( - AppConfigError, - match="Unsupported configuration type", - ), - pytest.warns(AppConfigWarning, match="No configuration sources"), - ): - read_configuration(UnsupportedConfig, [], options) - - def test_config_instance_can_be_passed(self): - original = Config( - host="custom-host", - port=1234, - debug=True, - timeout=10.0, - ) - - with pytest.warns(AppConfigWarning, match="No configuration sources"): - config = read_configuration(original, []) - - assert config == original diff --git a/tests/test_yaml_source.py b/tests/test_yaml_source.py new file mode 100644 index 0000000..5dde193 --- /dev/null +++ b/tests/test_yaml_source.py @@ -0,0 +1,170 @@ +from dataclasses import dataclass +from os import mkdir + +import pytest + +from appysetty import YamlSource, read_configuration +from appysetty.model import AppConfigError + + +@dataclass +class Config: + host: str = "localhost" + port: int = 8080 + debug: bool = False + timeout: float = 5.0 + + +class TestReadConfigurationFromYAML: + def test_yaml_source(self, tmp_path): + config_file = tmp_path / "config.yaml" + config_file.write_text( + """ + host: example.com + port: 9000 + debug: true + timeout: 2.5 + """, + encoding="utf-8", + ) + + config = read_configuration(Config, YamlSource(config_file)) + + assert config.host == "example.com" + assert config.port == 9000 + assert config.debug is True + assert config.timeout == 2.5 + + def test_yaml_uses_defaults_for_missing_values(self, tmp_path): + config_file = tmp_path / "config.yaml" + config_file.write_text( + "host: example.com", + encoding="utf-8", + ) + + config = read_configuration(Config, YamlSource(config_file)) + + assert config.host == "example.com" + assert config.port == 8080 + assert config.debug is False + assert config.timeout == 5.0 + + def test_yaml_path_can_be_string(self, tmp_path): + config_file = tmp_path / "config_test.yaml" + config_file.write_text( + "port: 9000", + encoding="utf-8", + ) + + config = read_configuration(Config, YamlSource(config_file)) + + assert config.port == 9000 + + def test_yaml_path_is_none(self): + yaml_path = None + + with pytest.raises(AppConfigError): + read_configuration(Config, YamlSource(yaml_path)) + + def test_yaml_path_defaults(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + variants = [ + "config.yml", + "config.yaml", + "config/config.yml", + "config/config.yaml", + ] + + mkdir(tmp_path / "config") + + for variant in variants: + (tmp_path / variant).write_text("port: 9000\n") + + config = read_configuration(Config, YamlSource()) + assert config.port == 9000 + + def test_yaml_skips_none_values(self, tmp_path): + config_file = tmp_path / "config.yaml" + config_file.write_text( + """ + host: example.com + port: + """, + encoding="utf-8", + ) + + config = read_configuration(Config, YamlSource(config_file)) + + assert config.host == "example.com" + assert config.port == 8080 + assert config.debug is False + assert config.timeout == 5.0 + + def test_yaml_raises_for_invalid_type(self, tmp_path): + config_file = tmp_path / "config.yaml" + config_file.write_text( + """ + host: example.com + port: [] + """, + encoding="utf-8", + ) + + with pytest.raises(AppConfigError): + read_configuration(Config, YamlSource(config_file)) + + def test_yaml_raises_for_invalid_key(self, tmp_path): + config_file = tmp_path / "config.yaml" + config_file.write_text( + """ + non_existing: example.com + port: [] + """, + encoding="utf-8", + ) + + with pytest.raises(AppConfigError): + read_configuration(Config, YamlSource(config_file)) + + def test_yaml_missing_path_raises(self): + yaml_path = "/does/not/exist/config.yaml" + + with pytest.raises( + AppConfigError, + ): + read_configuration(Config, YamlSource(yaml_path, required=True)) + + def test_yaml_invalid_syntax_raises(self, tmp_path): + config_file = tmp_path / "config.yaml" + config_file.write_text( + """ + host: [this is + invalid yaml + """, + encoding="utf-8", + ) + + with pytest.raises(AppConfigError): + read_configuration(Config, YamlSource(config_file)) + + def test_yaml_requires_mapping(self, tmp_path): + config_file = tmp_path / "config.yaml" + config_file.write_text( + """ + - one + - two + """, + encoding="utf-8", + ) + + with pytest.raises(AppConfigError): + read_configuration(Config, YamlSource(config_file)) + + def test_empty_yaml_uses_defaults(self, tmp_path): + config_file = tmp_path / "config.yaml" + config_file.write_text("", encoding="utf-8") + + config = read_configuration(Config, YamlSource(config_file)) + + assert config == Config() From 3f82fe39235e6421b72b55cd3a8e968ae561f10d Mon Sep 17 00:00:00 2001 From: kzoltner Date: Thu, 24 Sep 2026 15:16:57 +0200 Subject: [PATCH 02/21] ruff format --- README.md | 20 ++++---------------- src/appysetty/source.py | 8 ++++++-- 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 8e52e46..bcf993d 100644 --- a/README.md +++ b/README.md @@ -77,10 +77,7 @@ write_configuration_documentation(Config, output_dir=Path("./docs")) ApPySetty uses `AppConfigSource` as the interface to define loaders. These sources are loaded and applied in the order they are provided. ```python -cfg = read_configuration( - Config, - [YamlSource(...), EnvSource(...), DictSource(...)] -) +cfg = read_configuration(Config, [YamlSource(...), EnvSource(...), DictSource(...)]) ``` In the example above, YAML values are applied first, then environment variables, and finally dictionary values. Later sources override values from earlier sources. @@ -90,10 +87,7 @@ The available sources are: #### `EnvSource()` - Reading from Environment ```python -cfg = read_configuration( - Config, - EnvSource(prefix="MY_PREFIX") -) +cfg = read_configuration(Config, EnvSource(prefix="MY_PREFIX")) ``` For every key within the config, the key is converted to UPPER_SNAKE_CASE, the optional prefix is applied and the resulting key is used to read a value from the environment. @@ -104,10 +98,7 @@ For every key within the config, the key is converted to UPPER_SNAKE_CASE, the o #### `DictSource()` - Reading from a Dict ```python -cfg = read_configuration( - Config, - DictSource(input={"key": "val"}) -) +cfg = read_configuration(Config, DictSource(input={"key": "val"})) ``` Values are read from the provided dictionary using the configuration field names as keys. Unknown dictionary keys are rejected. @@ -115,10 +106,7 @@ Values are read from the provided dictionary using the configuration field names #### `YamlSource()` - Reading from a .yaml file ```python -cfg = read_configuration( - Config, - YamlSource(path="", required=True) -) +cfg = read_configuration(Config, YamlSource(path="", required=True)) ``` If path is specified, that file is used. Otherwise, the first existing file from the following list is used: diff --git a/src/appysetty/source.py b/src/appysetty/source.py index cc94409..bea9d26 100644 --- a/src/appysetty/source.py +++ b/src/appysetty/source.py @@ -50,7 +50,9 @@ def load(self, config_type_hints): for name, value in self.input.items(): try: if name not in config_type_hints: - raise AppConfigError(f"Dict key {name} not found in configuration field names") + raise AppConfigError( + f"Dict key {name} not found in configuration field names" + ) values[name] = parse_value_from_string( value, @@ -133,7 +135,9 @@ def load(self, config_type_hints): continue if yaml_key not in config_type_hints: - raise AppConfigError(f"YAML key {yaml_key} not found in configuration field names") + raise AppConfigError( + f"YAML key {yaml_key} not found in configuration field names" + ) try: values[yaml_key] = parse_value(yaml_value, config_type_hints[yaml_key]) From f932bf2672eaed370c108e6b4ecc9f8a3af5b1fa Mon Sep 17 00:00:00 2001 From: kzoltner Date: Thu, 24 Sep 2026 15:30:22 +0200 Subject: [PATCH 03/21] add support for toml --- README.md | 22 +++++- src/appysetty/__init__.py | 3 +- src/appysetty/source.py | 82 ++++++++++++++++++++ tests/test_toml_source.py | 152 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 tests/test_toml_source.py diff --git a/README.md b/README.md index bcf993d..2484d33 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,9 @@ write_configuration_documentation(Config, output_dir=Path("./docs")) ApPySetty uses `AppConfigSource` as the interface to define loaders. These sources are loaded and applied in the order they are provided. ```python -cfg = read_configuration(Config, [YamlSource(...), EnvSource(...), DictSource(...)]) +cfg = read_configuration( + Config, [YamlSource(...), TomlSource(...), EnvSource(...), DictSource(...)] +) ``` In the example above, YAML values are applied first, then environment variables, and finally dictionary values. Later sources override values from earlier sources. @@ -123,6 +125,24 @@ If required is False, a missing file will simply be ignored. If required is True > [!note] > Only flat mappings are allowed and the YAML key must match the config key exactly +#### `TomlSource()` - Reading from a .toml file + +```python +cfg = read_configuration(Config, TomlSource(path="", required=True)) +``` + +If path is specified, that file is used. Otherwise, the first existing file from the following list is used: + +```text +config.toml +config/config.toml +``` + +If required is False, a missing file will simply be ignored. If required is True an AppConfigError is raised. By default required is set to True. + +> [!note] +> Only flat mappings are allowed and the TOML key must match the config key exactly + #### Define your own source All sources are based on the `AppConfigSource`. To extend the list of sources, you could supply your own implementation: diff --git a/src/appysetty/__init__.py b/src/appysetty/__init__.py index 5072ded..4fcfaa0 100644 --- a/src/appysetty/__init__.py +++ b/src/appysetty/__init__.py @@ -1,6 +1,6 @@ from appysetty.model import AppConfigEntry, AppConfigSource from appysetty.read import read_configuration -from appysetty.source import DictSource, EnvSource, YamlSource +from appysetty.source import DictSource, EnvSource, TomlSource, YamlSource from appysetty.write import ( write_config_markdown, write_config_yaml_example, @@ -12,6 +12,7 @@ "AppConfigSource", "DictSource", "EnvSource", + "TomlSource", "YamlSource", "read_configuration", "write_config_markdown", diff --git a/src/appysetty/source.py b/src/appysetty/source.py index bea9d26..7c0dadf 100644 --- a/src/appysetty/source.py +++ b/src/appysetty/source.py @@ -1,4 +1,5 @@ import os +import tomllib from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path @@ -147,3 +148,84 @@ def load(self, config_type_hints): ) from e return values + + +@dataclass(frozen=True) +class TomlSource(AppConfigSource): + """Reads input from TOML file, ignoring non-existing files when required is False. + + If no path is specified, the first file of the following list is used: + [config.toml, config/config.toml] + + If required is True and no file is found, an error is raised. + """ + + path: Path | str | None = None + required: bool = True + + def load(self, config_type_hints): + toml_path: Path | None = None + + if self.path is None: + candidates = [ + Path("config.toml"), + Path("config", "config.toml"), + ] + + for candidate in candidates: + if candidate.is_file(): + toml_path = candidate + elif isinstance(self.path, str): + toml_path = Path(self.path) + else: + toml_path = self.path + + if toml_path is None: + if self.required: + raise AppConfigError( + "TOML was used as required source, but no toml file was found" + ) + else: + return {} + + try: + with toml_path.open("rb") as file: + toml_values: object = tomllib.load(file) + except OSError as e: + raise AppConfigError( + f"Failed to read TOML configuration from {toml_path}: {e}" + ) from e + except tomllib.TOMLDecodeError as e: + raise AppConfigError( + f"Failed to parse TOML configuration from {toml_path}: {e}" + ) from e + + if toml_values is None: + return {} + + if not isinstance(toml_values, Mapping): + raise AppConfigError( + f"Expected YAML configuration to contain a mapping, but got {type(toml_values).__name__}" + ) + + values: dict[str, object] = {} + + for toml_key in toml_values: + toml_value = toml_values[toml_key] + + if toml_value is None: + continue + + if toml_key not in config_type_hints: + raise AppConfigError( + f"TOML key {toml_key} not found in configuration field names" + ) + + try: + values[toml_key] = parse_value(toml_value, config_type_hints[toml_key]) + except Exception as e: + raise AppConfigError( + f"Failed to parse {toml_key} from TOML: {e}" + ) from e + + return values diff --git a/tests/test_toml_source.py b/tests/test_toml_source.py new file mode 100644 index 0000000..0e2f9ec --- /dev/null +++ b/tests/test_toml_source.py @@ -0,0 +1,152 @@ +from dataclasses import dataclass +from os import mkdir + +import pytest + +from appysetty import TomlSource, read_configuration +from appysetty.model import AppConfigError + + +@dataclass +class Config: + host: str = "localhost" + port: int = 8080 + debug: bool = False + timeout: float = 5.0 + + +class TestReadConfigurationFromTOML: + def test_toml_source(self, tmp_path): + config_file = tmp_path / "config.toml" + config_file.write_text( + """ + host = "example.com" + port = 9000 + debug = true + timeout = 2.5 + """, + encoding="utf-8", + ) + + config = read_configuration(Config, TomlSource(config_file)) + + assert config.host == "example.com" + assert config.port == 9000 + assert config.debug is True + assert config.timeout == 2.5 + + def test_toml_uses_defaults_for_missing_values(self, tmp_path): + config_file = tmp_path / "config.toml" + config_file.write_text( + 'host = "example.com"', + encoding="utf-8", + ) + + config = read_configuration(Config, TomlSource(config_file)) + + assert config.host == "example.com" + assert config.port == 8080 + assert config.debug is False + assert config.timeout == 5.0 + + def test_toml_path_can_be_string(self, tmp_path): + config_file = tmp_path / "config_test.toml" + config_file.write_text( + "port = 9000", + encoding="utf-8", + ) + + config = read_configuration(Config, TomlSource(config_file)) + + assert config.port == 9000 + + def test_toml_path_is_none(self): + toml_path = None + + with pytest.raises(AppConfigError): + read_configuration(Config, TomlSource(toml_path)) + + def test_toml_path_defaults(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + variants = [ + "config.toml", + "config/config.toml", + ] + + mkdir(tmp_path / "config") + + for variant in variants: + (tmp_path / variant).write_text("port = 9000\n") + + config = read_configuration(Config, TomlSource()) + assert config.port == 9000 + + def test_toml_raises_for_invalid_type(self, tmp_path): + config_file = tmp_path / "config.toml" + config_file.write_text( + """ + host = "example.com" + port = [] + """, + encoding="utf-8", + ) + + with pytest.raises(AppConfigError): + read_configuration(Config, TomlSource(config_file)) + + def test_toml_raises_for_invalid_key(self, tmp_path): + config_file = tmp_path / "config.toml" + config_file.write_text( + """ + non_existing = "example.com" + port = [] + """, + encoding="utf-8", + ) + + with pytest.raises(AppConfigError): + read_configuration(Config, TomlSource(config_file)) + + def test_toml_missing_path_raises(self): + toml_path = "/does/not/exist/config.toml" + + with pytest.raises(AppConfigError): + read_configuration( + Config, + TomlSource(toml_path, required=True), + ) + + def test_toml_invalid_syntax_raises(self, tmp_path): + config_file = tmp_path / "config.toml" + config_file.write_text( + """ + host = "example.com + port = 9000 + """, + encoding="utf-8", + ) + + with pytest.raises(AppConfigError): + read_configuration(Config, TomlSource(config_file)) + + def test_toml_requires_mapping(self, tmp_path): + config_file = tmp_path / "config.toml" + config_file.write_text( + """ + one + two + """, + encoding="utf-8", + ) + + with pytest.raises(AppConfigError): + read_configuration(Config, TomlSource(config_file)) + + def test_empty_toml_uses_defaults(self, tmp_path): + config_file = tmp_path / "config.toml" + config_file.write_text("", encoding="utf-8") + + config = read_configuration(Config, TomlSource(config_file)) + + assert config == Config() From 60f50b35b21ab9cd7469149dcdfb9dadb52e327d Mon Sep 17 00:00:00 2001 From: kzoltner Date: Thu, 24 Sep 2026 15:56:17 +0200 Subject: [PATCH 04/21] extend tests --- src/appysetty/read.py | 2 +- src/appysetty/source.py | 28 ++++++++++++++-------------- tests/test_env_source.py | 10 +++++++--- tests/test_parse.py | 14 ++++++++++++++ tests/test_read.py | 30 +++++++++++++++++++++++++++--- tests/test_toml_source.py | 12 +++++++++--- tests/test_yaml_source.py | 13 ++++++++++--- 7 files changed, 82 insertions(+), 27 deletions(-) diff --git a/src/appysetty/read.py b/src/appysetty/read.py index bfa3f3b..900310e 100644 --- a/src/appysetty/read.py +++ b/src/appysetty/read.py @@ -70,7 +70,7 @@ def read_configuration[T]( if unknown: raise AppConfigError( - f"{type(source).__name__} returned unkown fields that are not part of configuration " + f"{type(source).__name__} returned unknown fields that are not part of configuration " f"fields: {', '.join(sorted(unknown))}" ) diff --git a/src/appysetty/source.py b/src/appysetty/source.py index 7c0dadf..f8d1be3 100644 --- a/src/appysetty/source.py +++ b/src/appysetty/source.py @@ -111,10 +111,15 @@ def load(self, config_type_hints): with yaml_path.open("r", encoding="utf-8") as file: yaml_values: object = yaml.safe_load(file) except OSError as e: - raise AppConfigError( - f"Failed to read YAML configuration from {yaml_path}: {e}" - ) from e + if self.required: + raise AppConfigError( + f"YAML was used as required source, but failed to read YAML configuration from {yaml_path}: {e}" + ) + else: + return {} except yaml.YAMLError as e: + # this will raise even when required is off + # since the file exists but is invalid - that is a different case from the file not existing raise AppConfigError( f"Failed to parse YAML configuration from {yaml_path}: {e}" ) from e @@ -192,22 +197,17 @@ def load(self, config_type_hints): with toml_path.open("rb") as file: toml_values: object = tomllib.load(file) except OSError as e: - raise AppConfigError( - f"Failed to read TOML configuration from {toml_path}: {e}" - ) from e + if self.required: + raise AppConfigError( + f"TOML was used as required source, but failed to read TOML configuration from {toml_path}: {e}" + ) + else: + return {} except tomllib.TOMLDecodeError as e: raise AppConfigError( f"Failed to parse TOML configuration from {toml_path}: {e}" ) from e - if toml_values is None: - return {} - - if not isinstance(toml_values, Mapping): - raise AppConfigError( - f"Expected YAML configuration to contain a mapping, but got {type(toml_values).__name__}" - ) - values: dict[str, object] = {} for toml_key in toml_values: diff --git a/tests/test_env_source.py b/tests/test_env_source.py index 57df053..b507984 100644 --- a/tests/test_env_source.py +++ b/tests/test_env_source.py @@ -1,16 +1,20 @@ from dataclasses import dataclass +from typing import Annotated import pytest -from appysetty import EnvSource, read_configuration +from appysetty import AppConfigEntry, EnvSource, read_configuration from appysetty.env import get_env_name from appysetty.model import AppConfigError @dataclass class Config: - host: str = "localhost" - port: int = 8080 + host: Annotated[ + str, + AppConfigEntry(description="The application host"), + ] = "localhost" + port: Annotated[int, AppConfigEntry(description="port to run on")] = 8080 debug: bool = False timeout: float = 5.0 diff --git a/tests/test_parse.py b/tests/test_parse.py index c289007..191f435 100644 --- a/tests/test_parse.py +++ b/tests/test_parse.py @@ -4,6 +4,7 @@ from appysetty import read_configuration from appysetty.model import AppConfigError, AppConfigWarning +from appysetty.parse import parse_value from appysetty.source import DictSource @@ -85,3 +86,16 @@ def test_config_instance_can_be_passed(self): config = read_configuration(original, []) assert config == original + + def test_parse_bool_from_int(self): + tt = ["1", "on", "yes", 1, True, "true"] + + for input in tt: + result = parse_value(input, bool) + assert result == True + + tt = ["0", "off", "no", 0, False, "false"] + + for input in tt: + result = parse_value(input, bool) + assert result == False diff --git a/tests/test_read.py b/tests/test_read.py index 1116ed3..8c1012f 100644 --- a/tests/test_read.py +++ b/tests/test_read.py @@ -3,7 +3,7 @@ import pytest -from appysetty import EnvSource +from appysetty import AppConfigSource, EnvSource from appysetty.model import AppConfigEntry, AppConfigError, AppConfigWarning from appysetty.read import ( read_configuration, @@ -15,8 +15,11 @@ @dataclass class Config: - host: str = "localhost" - port: int = 8080 + host: Annotated[ + str, + AppConfigEntry(description="The application host"), + ] = "localhost" + port: Annotated[int, AppConfigEntry(description="port to run on")] = 8080 debug: bool = False timeout: float = 5.0 @@ -28,6 +31,11 @@ def test_uses_defaults_when_no_sources_are_provided(self): assert config == Config() + with pytest.warns(AppConfigWarning, match="No configuration sources"): + config = read_configuration(Config, None) + + assert config == Config() + def test_denies_non_dataclass(self): # not a dataclass class NonDataclassConfig: @@ -65,6 +73,22 @@ class OrderConfig: assert config.from_yaml == "from-yaml" assert config.from_dict == "from-dict" + def test_unknown_fields_raise(self): + @dataclass + class OrderConfig: + from_dict: str = "not-set" + + @dataclass(frozen=True) + class UnknownSource(AppConfigSource): + def load(self, config_type_hints): + return {"not-in-config": "hello"} + + with pytest.raises(AppConfigError): + read_configuration( + OrderConfig, + [DictSource(input={"from_dict": "from-dict"}), UnknownSource()], + ) + class TestAnnotatedConfig: @dataclass diff --git a/tests/test_toml_source.py b/tests/test_toml_source.py index 0e2f9ec..68ac69a 100644 --- a/tests/test_toml_source.py +++ b/tests/test_toml_source.py @@ -1,16 +1,20 @@ from dataclasses import dataclass from os import mkdir +from typing import Annotated import pytest -from appysetty import TomlSource, read_configuration +from appysetty import AppConfigEntry, TomlSource, read_configuration from appysetty.model import AppConfigError @dataclass class Config: - host: str = "localhost" - port: int = 8080 + host: Annotated[ + str, + AppConfigEntry(description="The application host"), + ] = "localhost" + port: Annotated[int, AppConfigEntry(description="port to run on")] = 8080 debug: bool = False timeout: float = 5.0 @@ -66,6 +70,8 @@ def test_toml_path_is_none(self): with pytest.raises(AppConfigError): read_configuration(Config, TomlSource(toml_path)) + read_configuration(Config, TomlSource(toml_path, required=False)) + def test_toml_path_defaults(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) diff --git a/tests/test_yaml_source.py b/tests/test_yaml_source.py index 5dde193..002d894 100644 --- a/tests/test_yaml_source.py +++ b/tests/test_yaml_source.py @@ -1,16 +1,20 @@ from dataclasses import dataclass from os import mkdir +from typing import Annotated import pytest -from appysetty import YamlSource, read_configuration +from appysetty import AppConfigEntry, YamlSource, read_configuration from appysetty.model import AppConfigError @dataclass class Config: - host: str = "localhost" - port: int = 8080 + host: Annotated[ + str, + AppConfigEntry(description="The application host"), + ] = "localhost" + port: Annotated[int, AppConfigEntry(description="port to run on")] = 8080 debug: bool = False timeout: float = 5.0 @@ -66,6 +70,9 @@ def test_yaml_path_is_none(self): with pytest.raises(AppConfigError): read_configuration(Config, YamlSource(yaml_path)) + # this should not raise + read_configuration(Config, YamlSource(yaml_path, required=False)) + def test_yaml_path_defaults(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) From 8ae75ab7c629a6d31d9faca77822dc33981620e7 Mon Sep 17 00:00:00 2001 From: kzoltner Date: Thu, 24 Sep 2026 16:12:02 +0200 Subject: [PATCH 05/21] Default values are masked now as well --- src/appysetty/write.py | 9 ++++++++- tests/test_write.py | 11 +++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/appysetty/write.py b/src/appysetty/write.py index 7be3254..0910611 100644 --- a/src/appysetty/write.py +++ b/src/appysetty/write.py @@ -46,6 +46,9 @@ def write_config_yaml_example[T]( def visit(field_name: str, field_type: str, entry: AppConfigEntry) -> None: default_value = getattr(cfg, field_name) + if entry.is_secret: + default_value = f"Masked[len:{len(default_value)}]" + description = entry.description if len(description.strip()) == 0: description = field_name @@ -82,12 +85,16 @@ def collect_entry(field_name: str, field_type: str, entry: AppConfigEntry) -> No if len(description.strip()) == 0: description = field_name + default_value = getattr(cfg, field_name) + if entry.is_secret: + default_value = f"Masked[len:{len(default_value)}]" + entries.append( MarkdownInfoEntry( env_name=get_env_name(field_name, env_prefix), field_name=field_name, field_type=field_type, - default_value=getattr(cfg, field_name), + default_value=default_value, description=description, is_secret=entry.is_secret, ) diff --git a/tests/test_write.py b/tests/test_write.py index 8d95126..e354041 100644 --- a/tests/test_write.py +++ b/tests/test_write.py @@ -1,6 +1,8 @@ from dataclasses import dataclass from pathlib import Path +from typing import Annotated +from appysetty import AppConfigEntry from appysetty.write import ( _encase_str_in_quotes, write_config_markdown, @@ -14,6 +16,7 @@ class Config: host: str = "localhost" port: int = 8080 enabled: bool = True + password: Annotated[str, AppConfigEntry(is_secret=True)] = "MyPassword" def test_encase_str_in_quotes(): @@ -35,6 +38,8 @@ def test_write_config_yaml_example(tmp_path: Path): assert "port: 8080" in output assert "enabled: True" in output + assert "MyPassword" not in output + def test_write_config_yaml_example_accepts_config_type(tmp_path: Path): write_config_yaml_example(Config, output_dir=tmp_path) @@ -62,6 +67,8 @@ def test_write_config_markdown(tmp_path: Path): assert "| APP_PORT | port | `int` | `8080` |" in output assert "| APP_ENABLED | enabled | `bool` | `True` |" in output + assert "MyPassword" not in output + def test_write_config_markdown_without_env_prefix(tmp_path: Path): write_config_markdown(Config(), output_dir=tmp_path) @@ -100,6 +107,8 @@ def test_write_config_markdown_contains_docker_compose(tmp_path: Path): assert " APP_PORT: 8080" in output assert " APP_ENABLED: True" in output + assert "MyPassword" not in output + def test_write_config_markdown_contains_docker_run(tmp_path: Path): write_config_markdown( @@ -119,6 +128,8 @@ def test_write_config_markdown_contains_docker_run(tmp_path: Path): assert " your-image:latest" in output + assert "MyPassword" not in output + def test_write_config_documentation_creates_both_files(tmp_path: Path): write_configuration_documentation( From e3ce8710987d2be20a70fe75704e7ed91f85704f Mon Sep 17 00:00:00 2001 From: kzoltner Date: Thu, 24 Sep 2026 16:15:33 +0200 Subject: [PATCH 06/21] fix bool behaviour to not silently go to False on typos. ture should not be False, but an error for example. --- src/appysetty/parse.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/appysetty/parse.py b/src/appysetty/parse.py index 0041729..a0b174a 100644 --- a/src/appysetty/parse.py +++ b/src/appysetty/parse.py @@ -21,7 +21,12 @@ def parse_value_from_string(value: str, value_type: object) -> object: if value_type is bool: normalized = trimmed.lower() - return normalized in {"true", "1", "yes", "on"} + if normalized in {"true", "1", "yes", "on"}: + return True + elif normalized in {"false", "0", "no", "off"}: + return False + else: + raise AppConfigError(f"invalid boolean value {normalized} - allowed are true/false, 1/0, yes/no, on/off") raise AppConfigError(f"Unsupported configuration type {value_type}") From 46ea713b2888bf931b917b169f34c4227376c704 Mon Sep 17 00:00:00 2001 From: kzoltner Date: Thu, 24 Sep 2026 16:18:55 +0200 Subject: [PATCH 07/21] ruff --- src/appysetty/parse.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/appysetty/parse.py b/src/appysetty/parse.py index a0b174a..0c3dd6e 100644 --- a/src/appysetty/parse.py +++ b/src/appysetty/parse.py @@ -26,7 +26,9 @@ def parse_value_from_string(value: str, value_type: object) -> object: elif normalized in {"false", "0", "no", "off"}: return False else: - raise AppConfigError(f"invalid boolean value {normalized} - allowed are true/false, 1/0, yes/no, on/off") + raise AppConfigError( + f"invalid boolean value {normalized} - allowed are true/false, 1/0, yes/no, on/off" + ) raise AppConfigError(f"Unsupported configuration type {value_type}") From 3f678a6dab778345ec23062c7144ec8804c32e30 Mon Sep 17 00:00:00 2001 From: kzoltner Date: Thu, 24 Sep 2026 16:36:47 +0200 Subject: [PATCH 08/21] Add filtering to type_hints --- src/appysetty/read.py | 42 +++++++++++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/src/appysetty/read.py b/src/appysetty/read.py index 900310e..a2dfeb5 100644 --- a/src/appysetty/read.py +++ b/src/appysetty/read.py @@ -1,7 +1,8 @@ +import dataclasses import warnings from collections.abc import Sequence from dataclasses import is_dataclass -from typing import Annotated, cast, get_args, get_origin, get_type_hints +from typing import Annotated, Any, cast, get_args, get_origin, get_type_hints from appysetty.model import ( AppConfigEntry, @@ -47,11 +48,6 @@ def read_configuration[T]( cfg_type = type(config) cfg = config - if not is_dataclass(cfg): - raise AppConfigError( - "the provided config class must be annotated with @dataclass" - ) - if not sources: warnings.warn( "No configuration sources defined. This is fine for testing but may be unintentional in production.", @@ -59,7 +55,7 @@ def read_configuration[T]( stacklevel=2, ) - type_hints = get_type_hints(cfg_type, include_extras=True) + type_hints = _get_config_type_hints(cfg) values = {field_name: getattr(cfg, field_name) for field_name in type_hints} @@ -79,9 +75,9 @@ def read_configuration[T]( return cast(T, cfg_type(**values)) -def visit_config_entries[T](config: object, visitor: AppConfigEntryVisitor): +def visit_config_entries[T](config: Any, visitor: AppConfigEntryVisitor): """Runs the method once for every configuration entry without the actual value. Intended to generate documentation.""" - type_hints = get_type_hints(type(config), include_extras=True) + type_hints = _get_config_type_hints(config) for field_name in type_hints: field_type = type_hints[field_name] @@ -97,9 +93,9 @@ def visit_config_entries[T](config: object, visitor: AppConfigEntryVisitor): visitor(field_name, type_to_string(field_type), entry) -def visit_config_strings[T](config: object, visitor: AppConfigVisitor): +def visit_config_strings[T](config: Any, visitor: AppConfigVisitor): """Runs visitor once for every tuple of [key:str, value:str] for the configuration. Masks anything marked with is_secret.""" - type_hints = get_type_hints(type(config), include_extras=True) + type_hints = _get_config_type_hints(config) for field_name in type_hints: field_metadata = _get_config_entry(type_hints[field_name]) @@ -125,3 +121,27 @@ def _get_config_entry(value_type: object) -> AppConfigEntry | None: for metadata in args[1:]: if isinstance(metadata, AppConfigEntry): return metadata + + +def _get_config_type_hints( + cfg: Any, +) -> dict[str, Any]: + """Filters type_hints output by only selecting fields that also are paret of dataclasses.fields()""" + if not is_dataclass(cfg): + raise AppConfigError( + "the provided config class must be annotated with @dataclass" + ) + + type_hints = get_type_hints( + cfg if isinstance(cfg, type) else type(cfg), include_extras=True + ) + + dataclass_fields = dataclasses.fields(cfg) + + filtered_type_hints: dict[str, Any] = {} + for dataclass_field in dataclass_fields: + # TODO: actually filter + if dataclass_field.name in type_hints: + filtered_type_hints[dataclass_field.name] = type_hints[dataclass_field.name] + + return filtered_type_hints From 5bfd2f96489f5183ffba07def753be9ff9381157 Mon Sep 17 00:00:00 2001 From: kzoltner Date: Thu, 24 Sep 2026 16:39:02 +0200 Subject: [PATCH 09/21] Fix file reading order for YAML/TOML --- src/appysetty/source.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/appysetty/source.py b/src/appysetty/source.py index f8d1be3..73c2e99 100644 --- a/src/appysetty/source.py +++ b/src/appysetty/source.py @@ -94,6 +94,8 @@ def load(self, config_type_hints): for candidate in candidates: if candidate.is_file(): yaml_path = candidate + break + elif isinstance(self.path, str): yaml_path = Path(self.path) else: @@ -180,6 +182,8 @@ def load(self, config_type_hints): for candidate in candidates: if candidate.is_file(): toml_path = candidate + break + elif isinstance(self.path, str): toml_path = Path(self.path) else: From 5b8e642d2b4daad9e4a3b7c38fa20d3e3565e02f Mon Sep 17 00:00:00 2001 From: kzoltner Date: Thu, 24 Sep 2026 16:41:14 +0200 Subject: [PATCH 10/21] Remove silently defaulting on filesystem errors --- src/appysetty/source.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/appysetty/source.py b/src/appysetty/source.py index 73c2e99..84afb4f 100644 --- a/src/appysetty/source.py +++ b/src/appysetty/source.py @@ -113,12 +113,9 @@ def load(self, config_type_hints): with yaml_path.open("r", encoding="utf-8") as file: yaml_values: object = yaml.safe_load(file) except OSError as e: - if self.required: - raise AppConfigError( - f"YAML was used as required source, but failed to read YAML configuration from {yaml_path}: {e}" - ) - else: - return {} + raise AppConfigError( + f"YAML was used as source, but failed to read YAML configuration from {yaml_path}: {e}" + ) except yaml.YAMLError as e: # this will raise even when required is off # since the file exists but is invalid - that is a different case from the file not existing @@ -201,12 +198,9 @@ def load(self, config_type_hints): with toml_path.open("rb") as file: toml_values: object = tomllib.load(file) except OSError as e: - if self.required: - raise AppConfigError( - f"TOML was used as required source, but failed to read TOML configuration from {toml_path}: {e}" - ) - else: - return {} + raise AppConfigError( + f"TOML was used as source, but failed to read TOML configuration from {toml_path}: {e}" + ) except tomllib.TOMLDecodeError as e: raise AppConfigError( f"Failed to parse TOML configuration from {toml_path}: {e}" From 4f063ab5bc2bad352fcc49f0b43b69730e16b318 Mon Sep 17 00:00:00 2001 From: kzoltner Date: Thu, 24 Sep 2026 17:07:59 +0200 Subject: [PATCH 11/21] removed the strip() and fixed a few other smalls --- README.md | 9 +++++---- pyproject.toml | 2 +- src/appysetty/model.py | 4 ++-- src/appysetty/parse.py | 25 +++++++++++++++++-------- src/appysetty/read.py | 12 ++++++------ src/appysetty/source.py | 31 +++++++++++++++++++++---------- src/appysetty/write.py | 2 -- tests/test_parse.py | 2 +- tests/test_read.py | 7 +++++-- uv.lock | 2 +- 10 files changed, 59 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 2484d33..b6276b9 100644 --- a/README.md +++ b/README.md @@ -69,9 +69,6 @@ And also document your configuration with an example .yaml and a markdown docume write_configuration_documentation(Config, output_dir=Path("./docs")) ``` -> [!caution] -> Please note that `.strip()` is applied to all string values which removes leading and trailing whitespaces. Inputs like ` hello ` would become `hello`. - ## Configuration Sources ApPySetty uses `AppConfigSource` as the interface to define loaders. These sources are loaded and applied in the order they are provided. @@ -163,6 +160,10 @@ class MyOwnSource(AppConfigSource): return values ``` +> [!caution] +> Using your own source might allow for more types then anticipated by the tool. So be careful. + + ## Define Config The simplest form of a config class looks like this: @@ -199,7 +200,7 @@ class ConfigWithMetadata: Both variants can be mixed. If no description is provided, the name of the field will be the description. -### Write Documentation +## Write Documentation One feature of this tool is automating the documentation items for configuration options: diff --git a/pyproject.toml b/pyproject.toml index a69917c..edd7a53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "appysetty" -version = "0.2.0" +version = "0.5.0" description = "Simplistic config helper" license = "MIT" readme = "README.md" diff --git a/src/appysetty/model.py b/src/appysetty/model.py index f9c8858..d3d719f 100644 --- a/src/appysetty/model.py +++ b/src/appysetty/model.py @@ -7,7 +7,7 @@ class AppConfigSource(ABC): """Represents any source, which only provide a load method""" @abstractmethod - def load(self, config_type_hints: Mapping[str, object]) -> Mapping[str, object]: ... + def load(self, config_type_hints: Mapping[str, object], trim_strings: bool = False) -> Mapping[str, object]: ... class AppConfigError(Exception): @@ -18,7 +18,7 @@ class AppConfigWarning(UserWarning): """Warning raised for uncommon use of configuration""" -@dataclass +@dataclass(frozen=True) class AppConfigEntry: description: str = "" is_secret: bool = False diff --git a/src/appysetty/parse.py b/src/appysetty/parse.py index 0c3dd6e..594e2f5 100644 --- a/src/appysetty/parse.py +++ b/src/appysetty/parse.py @@ -3,24 +3,29 @@ from appysetty.model import AppConfigError -def parse_value_from_string(value: str, value_type: object) -> object: +def parse_value_from_string( + value: str, value_type: object, trim_strings: bool = False +) -> object: """Parses a string to either str, int, float or bool. Other types are not supported.""" if get_origin(value_type) is Annotated: value_type = get_args(value_type)[0] - trimmed = value.strip() + if trim_strings: + string_val = value.strip() + else: + string_val = value if value_type is str: - return trimmed + return string_val if value_type is int: - return int(trimmed) + return int(string_val) if value_type is float: - return float(trimmed) + return float(string_val) if value_type is bool: - normalized = trimmed.lower() + normalized = string_val.lower().strip() if normalized in {"true", "1", "yes", "on"}: return True elif normalized in {"false", "0", "no", "off"}: @@ -33,13 +38,17 @@ def parse_value_from_string(value: str, value_type: object) -> object: raise AppConfigError(f"Unsupported configuration type {value_type}") -def parse_value(value: object, value_type: object) -> object: +def parse_value( + value: object, value_type: object, trim_strings: bool = False +) -> object: """Parses more generic input to either str, int, float or bool. Other types are not supported.""" if get_origin(value_type) is Annotated: value_type = get_args(value_type)[0] if isinstance(value, str): - return parse_value_from_string(value=value, value_type=value_type) + return parse_value_from_string( + value=value, value_type=value_type, trim_strings=trim_strings + ) if value_type is int and isinstance(value, int) and not isinstance(value, bool): return value diff --git a/src/appysetty/read.py b/src/appysetty/read.py index a2dfeb5..1b4fe2d 100644 --- a/src/appysetty/read.py +++ b/src/appysetty/read.py @@ -18,17 +18,17 @@ def read_configuration[T]( config: type[T] | T, sources: AppConfigSource | Sequence[AppConfigSource] | None = None, + trim_strings: bool = False ) -> T: """Read application configuration from at least one source - Sources are read in the provided order, with later values overwriting previos ones. + Sources are read in the provided order, with later values overwriting previous ones. If no values are provided the default values of AppConfig will be used. Note that ENV will only read UPPER_SNAKE_CASE variants of the name with a prefix as defined. Args: sources: list of sources to read - - options: further options like env_prefix and yaml_path and overwrites for tests + trim_strings: If set to True, all string values will apply .strip(), removing whitespaces at start and end Returns: The resulting application configuration @@ -60,7 +60,7 @@ def read_configuration[T]( values = {field_name: getattr(cfg, field_name) for field_name in type_hints} for source in sources: - next_values = source.load(type_hints) + next_values = source.load(type_hints, trim_strings) unknown = next_values.keys() - type_hints.keys() @@ -75,7 +75,7 @@ def read_configuration[T]( return cast(T, cfg_type(**values)) -def visit_config_entries[T](config: Any, visitor: AppConfigEntryVisitor): +def visit_config_entries[T](config: Any, visitor: AppConfigEntryVisitor) -> None: """Runs the method once for every configuration entry without the actual value. Intended to generate documentation.""" type_hints = _get_config_type_hints(config) @@ -93,7 +93,7 @@ def visit_config_entries[T](config: Any, visitor: AppConfigEntryVisitor): visitor(field_name, type_to_string(field_type), entry) -def visit_config_strings[T](config: Any, visitor: AppConfigVisitor): +def visit_config_strings[T](config: Any, visitor: AppConfigVisitor) -> None: """Runs visitor once for every tuple of [key:str, value:str] for the configuration. Masks anything marked with is_secret.""" type_hints = _get_config_type_hints(config) diff --git a/src/appysetty/source.py b/src/appysetty/source.py index 84afb4f..08b1de5 100644 --- a/src/appysetty/source.py +++ b/src/appysetty/source.py @@ -6,19 +6,20 @@ import yaml -from appysetty import AppConfigSource from appysetty.env import get_env_name -from appysetty.model import AppConfigError +from appysetty.model import AppConfigError, AppConfigSource from appysetty.parse import parse_value, parse_value_from_string @dataclass(frozen=True) class EnvSource(AppConfigSource): - """Uses Environemtn as source while all keys are converted to UPPER_SNAKE_CASE (with an optional PREFIX if supplied)""" + """Uses Environment as source while all keys are converted to UPPER_SNAKE_CASE (with an optional PREFIX if supplied)""" prefix: str | None = None - def load(self, config_type_hints): + def load( + self, config_type_hints, trim_strings: bool = False + ) -> Mapping[str, object]: values: dict[str, object] = {} for field_name in config_type_hints: @@ -32,6 +33,7 @@ def load(self, config_type_hints): values[field_name] = parse_value_from_string( val, config_type_hints[field_name], + trim_strings, ) except Exception as e: raise AppConfigError(f"Failed to parse {env_name} from ENV: {e}") from e @@ -45,7 +47,9 @@ class DictSource(AppConfigSource): input: dict[str, str] - def load(self, config_type_hints): + def load( + self, config_type_hints, trim_strings: bool = False + ) -> Mapping[str, object]: values: dict[str, object] = {} for name, value in self.input.items(): @@ -58,6 +62,7 @@ def load(self, config_type_hints): values[name] = parse_value_from_string( value, config_type_hints[name], + trim_strings, ) except Exception as e: raise AppConfigError( @@ -72,7 +77,7 @@ class YamlSource(AppConfigSource): """Reads input from YAML file, ignoring non-existing files when required is False. If no path is specified, the first file of the following list is used: - [config.yml, config.yaml, config/config.yml, config.config.yaml] + [config.yml, config.yaml, config/config.yml, config/config.yaml] If required is True and no file is found, an error is raised. """ @@ -80,7 +85,9 @@ class YamlSource(AppConfigSource): path: Path | str | None = None required: bool = True - def load(self, config_type_hints): + def load( + self, config_type_hints, trim_strings: bool = False + ) -> Mapping[str, object]: yaml_path: Path | None = None if self.path is None: @@ -145,7 +152,9 @@ def load(self, config_type_hints): ) try: - values[yaml_key] = parse_value(yaml_value, config_type_hints[yaml_key]) + values[yaml_key] = parse_value( + yaml_value, config_type_hints[yaml_key], trim_strings + ) except Exception as e: raise AppConfigError( f"Failed to parse {yaml_key} from YAML: {e}" @@ -167,7 +176,7 @@ class TomlSource(AppConfigSource): path: Path | str | None = None required: bool = True - def load(self, config_type_hints): + def load(self, config_type_hints, trim_strings: bool = False): toml_path: Path | None = None if self.path is None: @@ -220,7 +229,9 @@ def load(self, config_type_hints): ) try: - values[toml_key] = parse_value(toml_value, config_type_hints[toml_key]) + values[toml_key] = parse_value( + toml_value, config_type_hints[toml_key], trim_strings + ) except Exception as e: raise AppConfigError( f"Failed to parse {toml_key} from TOML: {e}" diff --git a/src/appysetty/write.py b/src/appysetty/write.py index 0910611..bae5356 100644 --- a/src/appysetty/write.py +++ b/src/appysetty/write.py @@ -6,8 +6,6 @@ from appysetty.model import AppConfigEntry from appysetty.read import visit_config_entries -_CONFIG_DOCS_PATH = Path("docs/config") - @dataclass class MarkdownInfoEntry: diff --git a/tests/test_parse.py b/tests/test_parse.py index 191f435..7870564 100644 --- a/tests/test_parse.py +++ b/tests/test_parse.py @@ -21,7 +21,7 @@ class TestParsing: ("field", "value", "expected"), [ ("host", "example.com", "example.com"), - ("host", " example.com ", "example.com"), + ("host", " example.com ", " example.com "), ("port", "9000", 9000), ("timeout", "2.5", 2.5), ("debug", " true ", True), diff --git a/tests/test_read.py b/tests/test_read.py index 8c1012f..3a4ee38 100644 --- a/tests/test_read.py +++ b/tests/test_read.py @@ -42,7 +42,10 @@ class NonDataclassConfig: host: str = "localhost" password: str = "secret" - with pytest.raises(AppConfigError): + with ( + pytest.raises(AppConfigError), + pytest.warns(AppConfigWarning, match="No configuration sources"), + ): read_configuration(NonDataclassConfig, []) def test_sources_are_applied_in_order(self, monkeypatch, tmp_path): @@ -80,7 +83,7 @@ class OrderConfig: @dataclass(frozen=True) class UnknownSource(AppConfigSource): - def load(self, config_type_hints): + def load(self, config_type_hints, trim_strings: bool = False): return {"not-in-config": "hello"} with pytest.raises(AppConfigError): diff --git a/uv.lock b/uv.lock index 24ed63e..7cc90ac 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.12" [[package]] name = "appysetty" -version = "0.2.0" +version = "0.5.0" source = { editable = "." } dependencies = [ { name = "pyyaml" }, From 467e867ee5c64375d117b8bc238a1a8c7925f06d Mon Sep 17 00:00:00 2001 From: kzoltner Date: Thu, 24 Sep 2026 17:21:07 +0200 Subject: [PATCH 12/21] formatting fixes --- .../example_output/DefaultConfiguration.md | 16 ++++++------ examples/example_output/config.example.yaml | 2 +- src/appysetty/model.py | 4 ++- src/appysetty/read.py | 2 +- src/appysetty/write.py | 25 ++++++++++++++----- tests/test_write.py | 8 +++--- 6 files changed, 36 insertions(+), 21 deletions(-) diff --git a/examples/example_output/DefaultConfiguration.md b/examples/example_output/DefaultConfiguration.md index 6ea0819..6e82ba6 100644 --- a/examples/example_output/DefaultConfiguration.md +++ b/examples/example_output/DefaultConfiguration.md @@ -4,12 +4,12 @@ | ENV | Variable | Type | Default | Is Secret | Description | |---|---|---|---|---|---| -| EXAMPLE_APP_HOST | host | `str` | `localhost` | False | Host to run the application on | -| EXAMPLE_APP_PORT | port | `int` | `8080` | False | port | -| EXAMPLE_APP_DEBUG | debug | `bool` | `False` | False | debug | -| EXAMPLE_APP_TIMEOUT | timeout | `float` | `5.0` | False | Request timeout in seconds | -| EXAMPLE_APP_WORKERS | workers | `int` | `4` | False | workers | -| EXAMPLE_APP_API_KEY | api_key | `str` | `` | True | API key used to access external services | +| EXAMPLE_APP_HOST | host | str | localhost | False | Host to run the application on | +| EXAMPLE_APP_PORT | port | int | 8080 | False | port | +| EXAMPLE_APP_DEBUG | debug | bool | False | False | debug | +| EXAMPLE_APP_TIMEOUT | timeout | float | 5.0 | False | Request timeout in seconds | +| EXAMPLE_APP_WORKERS | workers | int | 4 | False | workers | +| EXAMPLE_APP_API_KEY | api_key | str | Masked[len:0] | True | API key used to access external services | ## Docker Compose @@ -22,7 +22,7 @@ environment: EXAMPLE_APP_DEBUG: False EXAMPLE_APP_TIMEOUT: 5.0 EXAMPLE_APP_WORKERS: 4 - EXAMPLE_APP_API_KEY: + EXAMPLE_APP_API_KEY: Masked[len:0] ``` ## Docker Run @@ -36,6 +36,6 @@ docker run \ -e EXAMPLE_APP_DEBUG=False \ -e EXAMPLE_APP_TIMEOUT=5.0 \ -e EXAMPLE_APP_WORKERS=4 \ - -e EXAMPLE_APP_API_KEY='' + -e EXAMPLE_APP_API_KEY='Masked[len:0]' your-image:latest ``` diff --git a/examples/example_output/config.example.yaml b/examples/example_output/config.example.yaml index dc0734f..fcd592e 100644 --- a/examples/example_output/config.example.yaml +++ b/examples/example_output/config.example.yaml @@ -26,5 +26,5 @@ workers: 4 # API key used to access external services # Type: str # Is Secret: True -api_key: "" +api_key: "Masked[len:0]" \ No newline at end of file diff --git a/src/appysetty/model.py b/src/appysetty/model.py index d3d719f..3e9474b 100644 --- a/src/appysetty/model.py +++ b/src/appysetty/model.py @@ -7,7 +7,9 @@ class AppConfigSource(ABC): """Represents any source, which only provide a load method""" @abstractmethod - def load(self, config_type_hints: Mapping[str, object], trim_strings: bool = False) -> Mapping[str, object]: ... + def load( + self, config_type_hints: Mapping[str, object], trim_strings: bool = False + ) -> Mapping[str, object]: ... class AppConfigError(Exception): diff --git a/src/appysetty/read.py b/src/appysetty/read.py index 1b4fe2d..4f63828 100644 --- a/src/appysetty/read.py +++ b/src/appysetty/read.py @@ -18,7 +18,7 @@ def read_configuration[T]( config: type[T] | T, sources: AppConfigSource | Sequence[AppConfigSource] | None = None, - trim_strings: bool = False + trim_strings: bool = False, ) -> T: """Read application configuration from at least one source diff --git a/src/appysetty/write.py b/src/appysetty/write.py index bae5356..3a9c620 100644 --- a/src/appysetty/write.py +++ b/src/appysetty/write.py @@ -112,12 +112,12 @@ def collect_entry(field_name: str, field_type: str, entry: AppConfigEntry) -> No for info_entry in entries: lines.append( "| " - f"{info_entry.env_name} | " - f"{info_entry.field_name} | " - f"`{info_entry.field_type}` | " - f"`{info_entry.default_value}` | " - f"{info_entry.is_secret} | " - f"{info_entry.description} |" + f"{_markdown_table_cell(info_entry.env_name)} | " + f"{_markdown_table_cell(info_entry.field_name)} | " + f"{_markdown_table_cell(info_entry.field_type)} | " + f"{_markdown_table_cell(info_entry.default_value)} | " + f"{_markdown_table_cell(info_entry.is_secret)} | " + f"{_markdown_table_cell(info_entry.description)} |" ) lines.extend( @@ -171,3 +171,16 @@ def _encase_str_in_quotes(value: object) -> str: return f'"{value!s}"' return f"{value!s}" + + +def _markdown_table_cell(value: object) -> str: + return ( + str(value) + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("|", "|") + .replace("\r\n", "
") + .replace("\r", "
") + .replace("\n", "
") + ) diff --git a/tests/test_write.py b/tests/test_write.py index e354041..b59c5ea 100644 --- a/tests/test_write.py +++ b/tests/test_write.py @@ -63,9 +63,9 @@ def test_write_config_markdown(tmp_path: Path): assert "# Application Configuration" in output assert "| ENV | Variable | Type | Default | Is Secret | Description |" in output - assert "| APP_HOST | host | `str` | `localhost` |" in output - assert "| APP_PORT | port | `int` | `8080` |" in output - assert "| APP_ENABLED | enabled | `bool` | `True` |" in output + assert "| APP_HOST | host | str | localhost |" in output + assert "| APP_PORT | port | int | 8080 |" in output + assert "| APP_ENABLED | enabled | bool | True |" in output assert "MyPassword" not in output @@ -89,7 +89,7 @@ def test_write_config_markdown_accepts_config_type(tmp_path: Path): output = (tmp_path / "DefaultConfiguration.md").read_text() - assert "| APP_HOST | host | `str` | `localhost` |" in output + assert "| APP_HOST | host | str | localhost |" in output def test_write_config_markdown_contains_docker_compose(tmp_path: Path): From 55f2ddce397d97b5f23eed58995d48adc2876768 Mon Sep 17 00:00:00 2001 From: kzoltner Date: Thu, 24 Sep 2026 17:22:26 +0200 Subject: [PATCH 13/21] fix examples --- examples/define_config.py | 2 +- examples/read_documentation.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/examples/define_config.py b/examples/define_config.py index 1d8f0c7..9f6ac8d 100644 --- a/examples/define_config.py +++ b/examples/define_config.py @@ -35,4 +35,4 @@ class ExampleConfig: description="API key used to access external services", is_secret=True, ), - ] = "" + ] = "not here" diff --git a/examples/read_documentation.py b/examples/read_documentation.py index 8af0133..b282eda 100644 --- a/examples/read_documentation.py +++ b/examples/read_documentation.py @@ -1,12 +1,14 @@ -from appysetty import AppConfigSource, read_configuration +from appysetty import EnvSource, read_configuration from .define_config import ExampleConfig def run(): - cfg = read_configuration(ExampleConfig, [AppConfigSource.ENV]) + cfg = read_configuration(ExampleConfig, EnvSource()) print(cfg.port) + print(cfg.timeout) + print(cfg.workers) print(cfg.api_key) From 1b2e5defc8d6cc3beb3e9187856a6091135e1313 Mon Sep 17 00:00:00 2001 From: kzoltner Date: Fri, 25 Sep 2026 11:25:26 +0200 Subject: [PATCH 14/21] improve handling of \n and ``` for documents --- examples/define_config.py | 2 +- .../example_output/DefaultConfiguration.md | 22 +++---- examples/example_output/config.example.yaml | 19 +++--- src/appysetty/write.py | 65 ++++++++++++------- tests/test_write.py | 16 ++--- 5 files changed, 68 insertions(+), 56 deletions(-) diff --git a/examples/define_config.py b/examples/define_config.py index 9f6ac8d..3e3954e 100644 --- a/examples/define_config.py +++ b/examples/define_config.py @@ -12,7 +12,7 @@ class ExampleConfig: description="Host to run the application on", is_secret=False, ), - ] = "localhost" + ] = "localhost\nbadboy" # You can also only annotate what actually needs a description - unlike this port port: int = 8080 diff --git a/examples/example_output/DefaultConfiguration.md b/examples/example_output/DefaultConfiguration.md index 6e82ba6..88f9096 100644 --- a/examples/example_output/DefaultConfiguration.md +++ b/examples/example_output/DefaultConfiguration.md @@ -4,12 +4,12 @@ | ENV | Variable | Type | Default | Is Secret | Description | |---|---|---|---|---|---| -| EXAMPLE_APP_HOST | host | str | localhost | False | Host to run the application on | +| EXAMPLE_APP_HOST | host | str | localhost\nbadboy | False | Host to run the application on | | EXAMPLE_APP_PORT | port | int | 8080 | False | port | | EXAMPLE_APP_DEBUG | debug | bool | False | False | debug | | EXAMPLE_APP_TIMEOUT | timeout | float | 5.0 | False | Request timeout in seconds | | EXAMPLE_APP_WORKERS | workers | int | 4 | False | workers | -| EXAMPLE_APP_API_KEY | api_key | str | Masked[len:0] | True | API key used to access external services | +| EXAMPLE_APP_API_KEY | api_key | str | Masked[len:8] | True | API key used to access external services | ## Docker Compose @@ -17,12 +17,12 @@ Example environment block using the default values: ```yaml environment: - EXAMPLE_APP_HOST: localhost + EXAMPLE_APP_HOST: localhost\nbadboy EXAMPLE_APP_PORT: 8080 - EXAMPLE_APP_DEBUG: False + EXAMPLE_APP_DEBUG: false EXAMPLE_APP_TIMEOUT: 5.0 EXAMPLE_APP_WORKERS: 4 - EXAMPLE_APP_API_KEY: Masked[len:0] + EXAMPLE_APP_API_KEY: Masked[len:8] ``` ## Docker Run @@ -31,11 +31,11 @@ Example `docker run` command using the default values: ```bash docker run \ - -e EXAMPLE_APP_HOST=localhost \ - -e EXAMPLE_APP_PORT=8080 \ - -e EXAMPLE_APP_DEBUG=False \ - -e EXAMPLE_APP_TIMEOUT=5.0 \ - -e EXAMPLE_APP_WORKERS=4 \ - -e EXAMPLE_APP_API_KEY='Masked[len:0]' +-e EXAMPLE_APP_HOST='localhost\nbadboy' \ +-e EXAMPLE_APP_PORT=8080 \ +-e EXAMPLE_APP_DEBUG=False \ +-e EXAMPLE_APP_TIMEOUT=5.0 \ +-e EXAMPLE_APP_WORKERS=4 \ +-e EXAMPLE_APP_API_KEY='Masked[len:8]' \ your-image:latest ``` diff --git a/examples/example_output/config.example.yaml b/examples/example_output/config.example.yaml index fcd592e..ea8c139 100644 --- a/examples/example_output/config.example.yaml +++ b/examples/example_output/config.example.yaml @@ -1,30 +1,31 @@ # Host to run the application on # Type: str # Is Secret: False -host: "localhost" - +host: 'localhost + + badboy' + # port # Type: int # Is Secret: False port: 8080 - + # debug # Type: bool # Is Secret: False -debug: False - +debug: false + # Request timeout in seconds # Type: float # Is Secret: False timeout: 5.0 - + # workers # Type: int # Is Secret: False workers: 4 - + # API key used to access external services # Type: str # Is Secret: True -api_key: "Masked[len:0]" - \ No newline at end of file +api_key: Masked[len:8] diff --git a/src/appysetty/write.py b/src/appysetty/write.py index 3a9c620..abab849 100644 --- a/src/appysetty/write.py +++ b/src/appysetty/write.py @@ -2,6 +2,8 @@ from dataclasses import dataclass from pathlib import Path +import yaml + from appysetty.env import get_env_name from appysetty.model import AppConfigEntry from appysetty.read import visit_config_entries @@ -51,11 +53,11 @@ def visit(field_name: str, field_type: str, entry: AppConfigEntry) -> None: if len(description.strip()) == 0: description = field_name - lines.append(f"# {description}") - lines.append(f"# Type: {field_type}") - lines.append(f"# Is Secret: {entry.is_secret}") - lines.append(f"{field_name}: {_encase_str_in_quotes(default_value)}") - lines.append(" ") + lines.append(_yaml_comment(f"{description}")) + lines.append(_yaml_comment(f"Type: {field_type}")) + lines.append(_yaml_comment(f"Is Secret: {entry.is_secret}")) + lines.append(_yaml_line(key=field_name, val=default_value)) + lines.append("") visit_config_entries(cfg, visit) @@ -133,7 +135,9 @@ def collect_entry(field_name: str, field_type: str, entry: AppConfigEntry) -> No ) for info_entry in entries: - lines.append(f" {info_entry.env_name}: {info_entry.default_value}") + lines.append( + f" {_yaml_line(key=info_entry.env_name, val=_escape_value(info_entry.default_value))}" + ) lines.extend( [ @@ -148,16 +152,11 @@ def collect_entry(field_name: str, field_type: str, entry: AppConfigEntry) -> No ] ) - docker_envs = [] for info_entry in entries: - docker_envs.append( - f"-e {info_entry.env_name}={shlex.quote(str(info_entry.default_value))}" + lines.append( + f" -e {info_entry.env_name}={shlex.quote(str(_escape_value(info_entry.default_value)))} \\" ) - for index, env in enumerate(docker_envs): - suffix = " \\" if index < len(docker_envs) - 1 else "" - lines.append(f" {env}{suffix}") - lines.append(" your-image:latest") lines.append("```") lines.append("") @@ -165,22 +164,40 @@ def collect_entry(field_name: str, field_type: str, entry: AppConfigEntry) -> No output_path.write_text("\n".join(lines), encoding="utf-8") -def _encase_str_in_quotes(value: object) -> str: - """Creates "value" from value for a str, otherwise return str(value)""" - if isinstance(value, str): - return f'"{value!s}"' - - return f"{value!s}" - - def _markdown_table_cell(value: object) -> str: + value = _escape_value(value) return ( str(value) .replace("&", "&") .replace("<", "<") .replace(">", ">") .replace("|", "|") - .replace("\r\n", "
") - .replace("\r", "
") - .replace("\n", "
") ) + + +def _yaml_comment(input: object) -> str: + text = str(input) + result = "\n".join(f"# {line}" for line in text.splitlines()) or "#" + return result + + +def _yaml_line(key: str, val: object) -> str: + dumped = yaml.safe_dump( + {key: val}, + default_flow_style=False, + sort_keys=False, + allow_unicode=True, + line_break="", + ).rstrip() + + return dumped + + +def _escape_value(input: object) -> object: + if isinstance(input, str): + input = input.replace("```", "\\`\\`\\`") + input = input.replace("\r\n", "\\r\\n") + input = input.replace("\r", "\\r") + input = input.replace("\n", "\\n") + + return input diff --git a/tests/test_write.py b/tests/test_write.py index b59c5ea..4820c7c 100644 --- a/tests/test_write.py +++ b/tests/test_write.py @@ -4,7 +4,6 @@ from appysetty import AppConfigEntry from appysetty.write import ( - _encase_str_in_quotes, write_config_markdown, write_config_yaml_example, write_configuration_documentation, @@ -19,11 +18,6 @@ class Config: password: Annotated[str, AppConfigEntry(is_secret=True)] = "MyPassword" -def test_encase_str_in_quotes(): - assert _encase_str_in_quotes("hello") == '"hello"' - assert _encase_str_in_quotes(123) == "123" - assert _encase_str_in_quotes(True) == "True" - def test_write_config_yaml_example(tmp_path: Path): write_config_yaml_example(Config(), output_dir=tmp_path) @@ -34,9 +28,9 @@ def test_write_config_yaml_example(tmp_path: Path): assert "# Type: int" in output assert "# Type: bool" in output - assert 'host: "localhost"' in output + assert 'host: localhost' in output assert "port: 8080" in output - assert "enabled: True" in output + assert "enabled: true" in output assert "MyPassword" not in output @@ -46,9 +40,9 @@ def test_write_config_yaml_example_accepts_config_type(tmp_path: Path): output = (tmp_path / "config.example.yaml").read_text() - assert 'host: "localhost"' in output + assert 'host: localhost' in output assert "port: 8080" in output - assert "enabled: True" in output + assert "enabled: true" in output def test_write_config_markdown(tmp_path: Path): @@ -105,7 +99,7 @@ def test_write_config_markdown_contains_docker_compose(tmp_path: Path): assert "environment:" in output assert " APP_HOST: localhost" in output assert " APP_PORT: 8080" in output - assert " APP_ENABLED: True" in output + assert " APP_ENABLED: true" in output assert "MyPassword" not in output From cbdb93218fcb15fc8061598ad68f7f520192d819 Mon Sep 17 00:00:00 2001 From: kzoltner Date: Fri, 25 Sep 2026 11:25:46 +0200 Subject: [PATCH 15/21] ruff format --- tests/test_write.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_write.py b/tests/test_write.py index 4820c7c..acefd81 100644 --- a/tests/test_write.py +++ b/tests/test_write.py @@ -18,7 +18,6 @@ class Config: password: Annotated[str, AppConfigEntry(is_secret=True)] = "MyPassword" - def test_write_config_yaml_example(tmp_path: Path): write_config_yaml_example(Config(), output_dir=tmp_path) @@ -28,7 +27,7 @@ def test_write_config_yaml_example(tmp_path: Path): assert "# Type: int" in output assert "# Type: bool" in output - assert 'host: localhost' in output + assert "host: localhost" in output assert "port: 8080" in output assert "enabled: true" in output @@ -40,7 +39,7 @@ def test_write_config_yaml_example_accepts_config_type(tmp_path: Path): output = (tmp_path / "config.example.yaml").read_text() - assert 'host: localhost' in output + assert "host: localhost" in output assert "port: 8080" in output assert "enabled: true" in output From cba3dfb4a913cdcdbce07ecc0f6cb45ecab096ae Mon Sep 17 00:00:00 2001 From: kzoltner Date: Fri, 25 Sep 2026 11:33:42 +0200 Subject: [PATCH 16/21] add tests for invalid inputs --- tests/test_dict_source.py | 10 ++++++++-- tests/test_toml_source.py | 15 +++++++++++++++ tests/test_yaml_source.py | 12 ++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/tests/test_dict_source.py b/tests/test_dict_source.py index 85b2526..4083ba9 100644 --- a/tests/test_dict_source.py +++ b/tests/test_dict_source.py @@ -16,7 +16,7 @@ class Config: class TestReadConfigurationFromDict: - def test_overwrite(self): + def test_dict_source_happy_path(self): inputs = { "host": "example.com", "port": "9000", @@ -31,7 +31,7 @@ def test_overwrite(self): assert config.debug is True assert config.timeout == 2.5 - def test_overwrite_unknown_field_raises(self): + def test_dict_source_unknown_field_raises(self): inputs = {"does_not_exist": "value"} with pytest.raises( @@ -39,3 +39,9 @@ def test_overwrite_unknown_field_raises(self): match="not found", ): read_configuration(Config, DictSource(input=inputs)) + + def test_dict_source_invalid_input(self): + inputs = {"host": 123, "port": "not an int"} + + with pytest.raises(AppConfigError, match="invalid"): + read_configuration(Config, DictSource(input=inputs)) diff --git a/tests/test_toml_source.py b/tests/test_toml_source.py index 68ac69a..9c673fc 100644 --- a/tests/test_toml_source.py +++ b/tests/test_toml_source.py @@ -123,6 +123,21 @@ def test_toml_missing_path_raises(self): TomlSource(toml_path, required=True), ) + def test_toml_invalid_input_raises(self, tmp_path): + config_file = tmp_path / "config.toml" + config_file.write_text( + """ + port = "not-an-int" + """, + encoding="utf-8", + ) + + with pytest.raises(AppConfigError, match="invalid"): + read_configuration( + Config, + TomlSource(config_file, required=True), + ) + def test_toml_invalid_syntax_raises(self, tmp_path): config_file = tmp_path / "config.toml" config_file.write_text( diff --git a/tests/test_yaml_source.py b/tests/test_yaml_source.py index 002d894..f3d2d6d 100644 --- a/tests/test_yaml_source.py +++ b/tests/test_yaml_source.py @@ -134,6 +134,18 @@ def test_yaml_raises_for_invalid_key(self, tmp_path): with pytest.raises(AppConfigError): read_configuration(Config, YamlSource(config_file)) + def test_yaml_raises_for_invalid_input(self, tmp_path): + config_file = tmp_path / "config.yaml" + config_file.write_text( + """ + port: "not-an-int" + """, + encoding="utf-8", + ) + + with pytest.raises(AppConfigError): + read_configuration(Config, YamlSource(config_file)) + def test_yaml_missing_path_raises(self): yaml_path = "/does/not/exist/config.yaml" From 8bd775b94c2c6b0af061c7b0e6afdd3ac28134db Mon Sep 17 00:00:00 2001 From: kzoltner Date: Fri, 25 Sep 2026 12:32:45 +0200 Subject: [PATCH 17/21] add missing exports --- src/appysetty/__init__.py | 9 +- tests/test_dataclass_input.py | 172 ++++++++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 tests/test_dataclass_input.py diff --git a/src/appysetty/__init__.py b/src/appysetty/__init__.py index 4fcfaa0..4919c57 100644 --- a/src/appysetty/__init__.py +++ b/src/appysetty/__init__.py @@ -1,4 +1,9 @@ -from appysetty.model import AppConfigEntry, AppConfigSource +from appysetty.model import ( + AppConfigEntry, + AppConfigError, + AppConfigSource, + AppConfigWarning, +) from appysetty.read import read_configuration from appysetty.source import DictSource, EnvSource, TomlSource, YamlSource from appysetty.write import ( @@ -9,7 +14,9 @@ __all__ = [ "AppConfigEntry", + "AppConfigError", "AppConfigSource", + "AppConfigWarning", "DictSource", "EnvSource", "TomlSource", diff --git a/tests/test_dataclass_input.py b/tests/test_dataclass_input.py new file mode 100644 index 0000000..3b3b6f4 --- /dev/null +++ b/tests/test_dataclass_input.py @@ -0,0 +1,172 @@ +from dataclasses import dataclass, field + +import pytest + +from appysetty import AppConfigError, AppConfigWarning, DictSource, read_configuration + +# --- Test configs ----------------------------------------------------------- + + +@dataclass +class RequiredConfig: + host: str + port: int + + +@dataclass +class DefaultConfig: + host: str = "localhost" + port: int = 8080 + + +@dataclass +class MixedConfig: + host: str + port: int = 8080 + + +@dataclass +class DerivedConfig: + host: str + port: int = 8080 + url: str = field(init=False) + + def __post_init__(self): + self.url = f"http://{self.host}:{self.port}" + + +@dataclass +class FactoryConfig: + tags: list[str] = field(default_factory=list) + + +class NotAConfig: + pass + + +# --- Tests ------------------------------------------------------------------ +DefaultDictSource = DictSource( + { + "host": "localhost", + "port": "8080", + } +) + + +def test_accepts_dataclass_class(): + config = read_configuration( + RequiredConfig, + sources=DefaultDictSource, # source providing host and port + ) + + assert isinstance(config, RequiredConfig) + + +def test_accepts_dataclass_instance(): + original = RequiredConfig(host="localhost", port=8080) + + with pytest.warns(AppConfigWarning): + config = read_configuration(original) + + assert isinstance(config, RequiredConfig) + assert config.host == "localhost" + assert config.port == 8080 + + +def test_instance_is_copied_not_returned(): + original = DefaultConfig(host="example.com", port=1234) + + with pytest.warns(AppConfigWarning): + config = read_configuration(original) + + assert config is not original + assert config == original + + +def test_class_does_not_need_zero_argument_constructor(): + config = read_configuration( + RequiredConfig, + sources=DefaultDictSource, # providing both required fields + ) + + assert config.host == "localhost" + assert config.port == 8080 + + +def test_class_defaults_are_used(): + with pytest.warns(AppConfigWarning): + config = read_configuration(DefaultConfig) + + assert config.host == "localhost" + assert config.port == 8080 + + +def test_instance_values_override_class_defaults(): + original = DefaultConfig( + host="example.com", + port=9000, + ) + + with pytest.warns(AppConfigWarning): + config = read_configuration(original) + + assert config.host == "example.com" + assert config.port == 9000 + + +def test_required_field_without_default_is_missing(): + with pytest.raises(AppConfigError), pytest.warns(AppConfigWarning): + read_configuration(RequiredConfig) + + +def test_init_false_field_is_not_treated_as_input(): + config = read_configuration( + DerivedConfig, + sources=DefaultDictSource, + ) + + assert config.url == f"http://{config.host}:{config.port}" + + +def test_default_factory_is_used(): + with pytest.warns(AppConfigWarning): + config = read_configuration(FactoryConfig) + + assert config.tags == [] + + +def test_dataclass_class_with_required_fields_does_not_need_zero_arg_constructor(): + @dataclass + class Config: + host: str + port: int + + config = read_configuration( + Config, + sources=DictSource({"host": "example.com", "port": "443"}), + ) + + assert config.host == "example.com" + assert config.port == 443 + + +def test_dataclass_class_with_missing_values_raises(): + @dataclass + class Config: + host: str + port: int + + with pytest.raises(AppConfigError), pytest.warns(AppConfigWarning): + read_configuration( + Config, + ) + + +def test_rejects_non_dataclass_class(): + with pytest.raises(AppConfigError): + read_configuration(NotAConfig) + + +def test_rejects_non_dataclass_instance(): + with pytest.raises(AppConfigError): + read_configuration(NotAConfig()) From bc3dc742e0c6442e2865bc0f15d72162f4b2a9dd Mon Sep 17 00:00:00 2001 From: kzoltner Date: Fri, 25 Sep 2026 12:33:15 +0200 Subject: [PATCH 18/21] improved handling of "non-perfect" dataclasses --- src/appysetty/read.py | 58 +++++++++++++++++++++++++++++-------------- tests/test_parse.py | 5 +++- tests/test_read.py | 3 +-- 3 files changed, 44 insertions(+), 22 deletions(-) diff --git a/src/appysetty/read.py b/src/appysetty/read.py index 4f63828..ea267ff 100644 --- a/src/appysetty/read.py +++ b/src/appysetty/read.py @@ -41,12 +41,13 @@ def read_configuration[T]( elif isinstance(sources, AppConfigSource): sources = [sources] - if isinstance(config, type): - cfg_type = config - cfg = config() - else: - cfg_type = type(config) - cfg = config + cfg_type = config if isinstance(config, type) else type(config) + + # the second check makes sure `config` is handled as dataclass later on + if not is_dataclass(cfg_type) or not is_dataclass(config): + raise AppConfigError( + "The provided class or type must be annotated with @dataclass" + ) if not sources: warnings.warn( @@ -55,24 +56,45 @@ def read_configuration[T]( stacklevel=2, ) - type_hints = _get_config_type_hints(cfg) + type_hints = _get_config_type_hints(cfg_type) + + values = {} - values = {field_name: getattr(cfg, field_name) for field_name in type_hints} + for field in dataclasses.fields(cfg_type): + if not field.init: + continue + + if field.default is not dataclasses.MISSING: + values[field.name] = field.default + elif field.default_factory is not dataclasses.MISSING: + values[field.name] = field.default_factory() + + if not isinstance(config, type): + for field in dataclasses.fields(config): + if not field.init: + continue + + values[field.name] = getattr(config, field.name) for source in sources: next_values = source.load(type_hints, trim_strings) - unknown = next_values.keys() - type_hints.keys() + unknown_keys = next_values.keys() - type_hints.keys() - if unknown: + if unknown_keys: raise AppConfigError( f"{type(source).__name__} returned unknown fields that are not part of configuration " - f"fields: {', '.join(sorted(unknown))}" + f"fields: {', '.join(sorted(unknown_keys))}" ) values.update(next_values) - return cast(T, cfg_type(**values)) + try: + result = cast(T, cfg_type(**values)) + except TypeError as e: + raise AppConfigError(f"Could not construct {cfg_type.__name__}: {e}") from e + + return result def visit_config_entries[T](config: Any, visitor: AppConfigEntryVisitor) -> None: @@ -126,22 +148,20 @@ def _get_config_entry(value_type: object) -> AppConfigEntry | None: def _get_config_type_hints( cfg: Any, ) -> dict[str, Any]: - """Filters type_hints output by only selecting fields that also are paret of dataclasses.fields()""" + """Return type hints only for dataclass fields that participate in __init__.""" if not is_dataclass(cfg): raise AppConfigError( - "the provided config class must be annotated with @dataclass" + "the provided class or type must be annotated with @dataclass" ) - type_hints = get_type_hints( - cfg if isinstance(cfg, type) else type(cfg), include_extras=True - ) + cfg_type = cfg if isinstance(cfg, type) else type(cfg) + type_hints = get_type_hints(cfg_type, include_extras=True) dataclass_fields = dataclasses.fields(cfg) filtered_type_hints: dict[str, Any] = {} for dataclass_field in dataclass_fields: - # TODO: actually filter - if dataclass_field.name in type_hints: + if dataclass_field.init and dataclass_field.name in type_hints: filtered_type_hints[dataclass_field.name] = type_hints[dataclass_field.name] return filtered_type_hints diff --git a/tests/test_parse.py b/tests/test_parse.py index 7870564..5c6adda 100644 --- a/tests/test_parse.py +++ b/tests/test_parse.py @@ -83,7 +83,10 @@ def test_config_instance_can_be_passed(self): ) with pytest.warns(AppConfigWarning, match="No configuration sources"): - config = read_configuration(original, []) + config = read_configuration(original) + + print(config) + print(original) assert config == original diff --git a/tests/test_read.py b/tests/test_read.py index 3a4ee38..6163f4a 100644 --- a/tests/test_read.py +++ b/tests/test_read.py @@ -44,9 +44,8 @@ class NonDataclassConfig: with ( pytest.raises(AppConfigError), - pytest.warns(AppConfigWarning, match="No configuration sources"), ): - read_configuration(NonDataclassConfig, []) + read_configuration(NonDataclassConfig) def test_sources_are_applied_in_order(self, monkeypatch, tmp_path): @dataclass From 08812b7071c43d60f2a3062cef9a99e176230809 Mon Sep 17 00:00:00 2001 From: kzoltner Date: Fri, 25 Sep 2026 12:38:03 +0200 Subject: [PATCH 19/21] stringify before len() --- src/appysetty/read.py | 2 +- src/appysetty/write.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/appysetty/read.py b/src/appysetty/read.py index ea267ff..057b0c6 100644 --- a/src/appysetty/read.py +++ b/src/appysetty/read.py @@ -125,7 +125,7 @@ def visit_config_strings[T](config: Any, visitor: AppConfigVisitor) -> None: field_value = str(getattr(config, field_name)) if field_metadata is not None and field_metadata.is_secret: - field_value = f"Masked[len:{len(field_value)}]" + field_value = f"Masked[len:{len(str(field_value))}]" if get_origin(field_type) is Annotated: field_type = get_args(field_type)[0] diff --git a/src/appysetty/write.py b/src/appysetty/write.py index abab849..9a9f913 100644 --- a/src/appysetty/write.py +++ b/src/appysetty/write.py @@ -47,7 +47,7 @@ def visit(field_name: str, field_type: str, entry: AppConfigEntry) -> None: default_value = getattr(cfg, field_name) if entry.is_secret: - default_value = f"Masked[len:{len(default_value)}]" + default_value = f"Masked[len:{len(str(default_value))}]" description = entry.description if len(description.strip()) == 0: @@ -87,7 +87,7 @@ def collect_entry(field_name: str, field_type: str, entry: AppConfigEntry) -> No default_value = getattr(cfg, field_name) if entry.is_secret: - default_value = f"Masked[len:{len(default_value)}]" + default_value = f"Masked[len:{len(str(default_value))}]" entries.append( MarkdownInfoEntry( From aea538e4f84c869a5d3b00101c9cf751af9e47a9 Mon Sep 17 00:00:00 2001 From: kzoltner Date: Fri, 25 Sep 2026 12:38:10 +0200 Subject: [PATCH 20/21] example update --- examples/example_output/DefaultConfiguration.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/examples/example_output/DefaultConfiguration.md b/examples/example_output/DefaultConfiguration.md index 88f9096..463d767 100644 --- a/examples/example_output/DefaultConfiguration.md +++ b/examples/example_output/DefaultConfiguration.md @@ -31,11 +31,11 @@ Example `docker run` command using the default values: ```bash docker run \ --e EXAMPLE_APP_HOST='localhost\nbadboy' \ --e EXAMPLE_APP_PORT=8080 \ --e EXAMPLE_APP_DEBUG=False \ --e EXAMPLE_APP_TIMEOUT=5.0 \ --e EXAMPLE_APP_WORKERS=4 \ --e EXAMPLE_APP_API_KEY='Masked[len:8]' \ + -e EXAMPLE_APP_HOST='localhost\nbadboy' \ + -e EXAMPLE_APP_PORT=8080 \ + -e EXAMPLE_APP_DEBUG=False \ + -e EXAMPLE_APP_TIMEOUT=5.0 \ + -e EXAMPLE_APP_WORKERS=4 \ + -e EXAMPLE_APP_API_KEY='Masked[len:8]' \ your-image:latest ``` From 8291247e890ae08b5bfe679ebcc46be5d20ecfd3 Mon Sep 17 00:00:00 2001 From: kzoltner Date: Fri, 25 Sep 2026 12:41:24 +0200 Subject: [PATCH 21/21] clarify trim_string docs --- src/appysetty/read.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/appysetty/read.py b/src/appysetty/read.py index 057b0c6..b4b03bc 100644 --- a/src/appysetty/read.py +++ b/src/appysetty/read.py @@ -28,7 +28,7 @@ def read_configuration[T]( Args: sources: list of sources to read - trim_strings: If set to True, all string values will apply .strip(), removing whitespaces at start and end + trim_strings: If set to True, all string config values will apply .strip(), removing whitespaces at start and end Returns: The resulting application configuration