From ac1eeb0cf275f5cf6f8e350bf74d63210b487a1c Mon Sep 17 00:00:00 2001 From: HEROgold Date: Mon, 2 Mar 2026 01:01:25 +0100 Subject: [PATCH 01/12] Add support for nested configuration --- .github/copilot-instructions.md | 25 +-- docs/examples/custom_data_type.md | 4 +- docs/usage.md | 10 +- examples/argparse_example.py | 2 - examples/basic.py | 2 - examples/custom_data_type.py | 2 - examples/data_types.py | 2 - examples/decorators.py | 2 - examples/enums.py | 2 - examples/file_change_event.py | 2 - examples/list_types.py | 2 - examples/multiple_configs.py | 2 - examples/nested_config.py | 174 +++++++++++++++++ examples/nested_example.ini | 12 ++ examples/nested_example.json | 12 ++ examples/nested_example.toml | 11 ++ examples/nested_example.yaml | 11 ++ examples/optional_values.py | 2 - examples/other_file_types.py | 9 +- examples/pydantic_example.py | 2 - examples/references.py | 3 - examples/url_example.py | 2 - src/confkit/config.py | 30 ++- src/confkit/ext/parsers.py | 150 +++++++++++++-- tests/test_config.py | 7 +- tests/test_config_classvars.py | 26 +-- tests/test_config_decorators.py | 5 +- tests/test_config_detect_parser.py | 5 +- tests/test_metaclass.py | 4 +- tests/test_msgspecparser_no_msgspec.py | 7 + tests/test_multiple_configurations.py | 7 +- tests/test_nested_config.py | 255 +++++++++++++++++++++++++ tests/test_on_file_change.py | 16 +- tests/test_pydantic_models.py | 12 +- tests/test_two_instances.py | 6 +- 35 files changed, 703 insertions(+), 122 deletions(-) create mode 100644 examples/nested_config.py create mode 100644 examples/nested_example.ini create mode 100644 examples/nested_example.json create mode 100644 examples/nested_example.toml create mode 100644 examples/nested_example.yaml create mode 100644 tests/test_nested_config.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1c6c3a3..6b5b5eb 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -11,9 +11,10 @@ confkit is a Python library for type-safe configuration management using descrip - `Config` descriptor (`config.py`): The main descriptor class that handles getting/setting values in config files - `ConfigContainerMeta` (`config.py`): Metaclass that enables setting Config descriptors on class variables - `BaseDataType` and implementations (`data_types.py`): Type converters for different data types -- `ConfkitParser` protocol (`ext/parsers.py`): Protocol defining the parser interface -- `MsgspecParser` (`ext/parsers.py`): Parser for JSON, YAML, and TOML files -- `EnvParser` (`ext/parsers.py`): Parser for environment variables and .env files +- `Parser` facade (`ext/parsers.py`): Unified facade for all configuration file formats (INI, JSON, YAML, TOML, .env) +- `IniParser` (`ext/parsers.py`): Adapter for Python's built-in ConfigParser (INI files) +- `MsgspecParser` (`ext/parsers.py`): Adapter for JSON, YAML, and TOML files using msgspec +- `EnvParser` (`ext/parsers.py`): Adapter for environment variables and .env files - `sentinels.py`: Provides the `UNSET` sentinel value for representing unset values - `exceptions.py`: Custom exceptions for configuration errors - `watcher.py`: File watching functionality to detect config file changes @@ -44,6 +45,8 @@ uv sync --group test ```bash # Run linting ruff check . +# Fix issues using +ruff check . --fix --unsafe-fixes # Update dependencies and run tests uv sync --upgrade --group dev; uv run pytest . ``` @@ -172,29 +175,29 @@ The `with_setting` approach is more type-safe as it references an actual descrip ### Required Initialization -Always initialize Config with a file path before use. The parser can be set explicitly or will be auto-detected based on file extension: +Always initialize Config with a file path before use. The Parser facade automatically detects and uses the appropriate adapter based on file extension: ```python from pathlib import Path from confkit import Config -# Option 1: Auto-detect parser based on file extension -Config.set_file(Path("config.ini")) # Uses ConfigParser +# Simplified approach - parser is auto-detected based on file extension +Config.set_file(Path("config.ini")) # Uses IniParser Config.set_file(Path("config.json")) # Uses MsgspecParser Config.set_file(Path("config.yaml")) # Uses MsgspecParser Config.set_file(Path("config.toml")) # Uses MsgspecParser -Config.set_file(Path(".env")) # Uses EnvParser +Config.set_file(Path(".env")) # Uses EnvParser -# Option 2: Explicitly set parser (not recommended unless it's absolutely required.) -from configparser import ConfigParser -parser = ConfigParser() +# Option 2: Explicitly set parser (not recommended unless absolutely required) +from confkit.ext.parsers import Parser +parser = Parser(Path("config.ini")) Config.set_parser(parser) Config.set_file(Path("config.ini")) ``` ### Supported File Formats -- **INI files** (`.ini`): Uses Python's `ConfigParser`, supports sections +- **INI files** (`.ini`): Uses `IniParser`, supports sections - **JSON files** (`.json`): Uses `MsgspecParser`, requires `msgspec` extra - **YAML files** (`.yaml`, `.yml`): Uses `MsgspecParser`, requires `msgspec` extra - **TOML files** (`.toml`): Uses `MsgspecParser`, requires `msgspec` extra diff --git a/docs/examples/custom_data_type.md b/docs/examples/custom_data_type.md index 135b51f..e64f277 100644 --- a/docs/examples/custom_data_type.md +++ b/docs/examples/custom_data_type.md @@ -11,13 +11,11 @@ Shows how to create and use a custom [`BaseDataType`](pdoc:confkit.BaseDataType) ## Implementation ```python -from configparser import ConfigParser from pathlib import Path from confkit import Config from confkit.data_types import BaseDataType -# Configure parser + file -parser = ConfigParser() +# Configure parser + file (Parser is automatically detected) Config.set_parser(parser) Config.set_file(Path("config.ini")) diff --git a/docs/usage.md b/docs/usage.md index d9a33f4..927125a 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -9,13 +9,10 @@ This page explains how to work with confkit and how the documentation is generat ## Descriptor Quickstart ```python -from configparser import ConfigParser from pathlib import Path from confkit import Config -# 1. Configure confkit parser/file (normally app bootstrap) -parser = ConfigParser() -Config.set_parser(parser) +# 1. Configure confkit file (parser is auto-detected) Config.set_file(Path("config.ini")) # 2. Define your (class)variables with their default values @@ -36,14 +33,11 @@ print(AppConfig.port) 4. Use via `Config(CustomType(default_value))` ```python -from configparser import ConfigParser from pathlib import Path from confkit import Config from confkit.data_types import BaseDataType -# 1. Configure confkit parser/file (normally app bootstrap) -parser = ConfigParser() -Config.set_parser(parser) +# 1. Configure confkit file (parser is auto-detected) Config.set_file(Path("config.ini")) # 2. Define the custom converter, with a convert method (from str -> your_type) diff --git a/examples/argparse_example.py b/examples/argparse_example.py index bf73227..db93f38 100644 --- a/examples/argparse_example.py +++ b/examples/argparse_example.py @@ -16,10 +16,8 @@ from confkit.data_types import List # Setup a standard confkit config file (unrelated to argparse defaults file) -parser_ini = ConfigParser() ini_path = Path("config.ini") Config.write_on_edit = False -Config.set_parser(parser_ini) Config.set_file(ini_path) class AppConfig: diff --git a/examples/basic.py b/examples/basic.py index 5a96d32..8352877 100644 --- a/examples/basic.py +++ b/examples/basic.py @@ -16,8 +16,6 @@ from confkit import Config # Set up the parser and file -parser = ConfigParser() -Config.set_parser(parser) Config.set_file(Path("config.ini")) # Enable automatic writing when config values are changed (this is the default) diff --git a/examples/custom_data_type.py b/examples/custom_data_type.py index fbfbb30..265bb30 100644 --- a/examples/custom_data_type.py +++ b/examples/custom_data_type.py @@ -20,8 +20,6 @@ from confkit import Config from confkit.data_types import BaseDataType -parser = ConfigParser() -Config.set_parser(parser) Config.set_file(Path("config.ini")) class UpperString(BaseDataType[str]): diff --git a/examples/data_types.py b/examples/data_types.py index 0fae466..b8dc9ee 100644 --- a/examples/data_types.py +++ b/examples/data_types.py @@ -16,8 +16,6 @@ from confkit import Config from confkit.data_types import Binary, Boolean, Float, Hex, Integer, Octal, String -parser = ConfigParser() -Config.set_parser(parser) Config.set_file(Path("config.ini")) diff --git a/examples/decorators.py b/examples/decorators.py index 6cc4907..de2386e 100644 --- a/examples/decorators.py +++ b/examples/decorators.py @@ -5,8 +5,6 @@ from confkit.config import Config -parser = ConfigParser() -Config.set_parser(parser) Config.set_file(Path("config.ini")) diff --git a/examples/enums.py b/examples/enums.py index 93dd83b..deeb8e5 100644 --- a/examples/enums.py +++ b/examples/enums.py @@ -20,8 +20,6 @@ from confkit.data_types import IntFlag as ConfigIntFlag from confkit.data_types import Optional, StrEnum as ConfigStrEnum -parser = ConfigParser() -Config.set_parser(parser) Config.set_file(Path("config.ini")) diff --git a/examples/file_change_event.py b/examples/file_change_event.py index 670ea8d..9b3f6e6 100644 --- a/examples/file_change_event.py +++ b/examples/file_change_event.py @@ -11,8 +11,6 @@ from confkit import Config # Set up the parser and file -parser = ConfigParser() -Config.set_parser(parser) Config.set_file(Path("config.ini")) # Enable automatic writing when config values are changed (this is the default) diff --git a/examples/list_types.py b/examples/list_types.py index 786d4cc..4f4a5e8 100644 --- a/examples/list_types.py +++ b/examples/list_types.py @@ -16,8 +16,6 @@ from confkit import Config from confkit.data_types import List, String -parser = ConfigParser() -Config.set_parser(parser) Config.set_file(Path("config.ini")) List.escape_char = "\\" # Set escape character for lists, these are also the default. List.separator = "," # Set separator character for lists, these are also the default. diff --git a/examples/multiple_configs.py b/examples/multiple_configs.py index f64ff64..34d7268 100644 --- a/examples/multiple_configs.py +++ b/examples/multiple_configs.py @@ -8,9 +8,7 @@ class DatabaseConfig(Config[T]): ... class ApiConfig(Config[T]): ... -DatabaseConfig.set_parser(ConfigParser()) DatabaseConfig.set_file(Path("database.ini")) -ApiConfig.set_parser(ConfigParser()) ApiConfig.set_file(Path("api.ini")) class AppConfiguration: diff --git a/examples/nested_config.py b/examples/nested_config.py new file mode 100644 index 0000000..185cde9 --- /dev/null +++ b/examples/nested_config.py @@ -0,0 +1,174 @@ +"""Example of nested configuration support in confkit. + +This example demonstrates how to use nested classes to organize +configuration values hierarchically. This works with: +- JSON files (native nested objects) +- YAML files (native nested mappings) +- TOML files (nested tables) +- INI files (using dot notation in section names) +""" +from confkit.ext.parsers import IniParser + +from pathlib import Path +from typing import TypeVar + +from confkit import Config + +T = TypeVar("T") + +# Example 1: Nested configuration with JSON +print("=== Nested JSON Configuration ===") + + +class JsonConfig(Config[T]): + ... + + +JsonConfig.set_file(Path("nested_example.json")) + + +class DatabaseConfig: + """Database configuration with nested credentials.""" + + host = JsonConfig("localhost") + port = JsonConfig(5432) + name = JsonConfig("myapp_db") + + class Credentials: + """Nested credentials configuration.""" + + username = JsonConfig("admin") + password = JsonConfig("secret123") + use_ssl = JsonConfig(True) + + +# Access nested values +print(f"Database host: {DatabaseConfig.host}") +print(f"Database port: {DatabaseConfig.port}") +print(f"Username: {DatabaseConfig.Credentials.username}") +print(f"Password: {DatabaseConfig.Credentials.password}") +print(f"Use SSL: {DatabaseConfig.Credentials.use_ssl}") + +# Example 2: Multiple levels of nesting with YAML +print("\n=== Multi-Level Nested YAML Configuration ===") + + +class YamlConfig(Config[T]): + ... + + +YamlConfig.set_file(Path("nested_example.yaml")) + + +class ServerConfig: + """Server configuration with multiple nesting levels.""" + + name = YamlConfig("web-server-01") + + class Network: + """Network configuration.""" + + interface = YamlConfig("eth0") + + class IPv4: + """IPv4 settings.""" + + address = YamlConfig("192.168.1.100") + netmask = YamlConfig("255.255.255.0") + gateway = YamlConfig("192.168.1.1") + + class IPv6: + """IPv6 settings.""" + + address = YamlConfig("fe80::1") + prefix = YamlConfig(64) + + +print(f"Server name: {ServerConfig.name}") +print(f"Network interface: {ServerConfig.Network.interface}") +print(f"IPv4 address: {ServerConfig.Network.IPv4.address}") +print(f"IPv4 netmask: {ServerConfig.Network.IPv4.netmask}") +print(f"IPv6 address: {ServerConfig.Network.IPv6.address}") +print(f"IPv6 prefix: {ServerConfig.Network.IPv6.prefix}") + +# Example 3: INI files with dot notation +print("\n=== Nested INI Configuration (using dot notation) ===") + + +class IniConfig(Config[T]): + ... + + +IniConfig._set_parser(IniParser()) +IniConfig.set_file(Path("nested_example.ini")) + + +class AppConfig: + """Application configuration.""" + + version = IniConfig("1.0.0") + debug = IniConfig(False) + + class Logging: + """Logging configuration.""" + + level = IniConfig("INFO") + file = IniConfig("app.log") + + class Rotation: + """Log rotation settings.""" + + max_size = IniConfig(10) # MB + backup_count = IniConfig(5) + + +print(f"App version: {AppConfig.version}") +print(f"Debug mode: {AppConfig.debug}") +print(f"Log level: {AppConfig.Logging.level}") +print(f"Log file: {AppConfig.Logging.file}") +print(f"Max log size: {AppConfig.Logging.Rotation.max_size} MB") +print(f"Backup count: {AppConfig.Logging.Rotation.backup_count}") + +# Example 4: TOML with nested tables +print("\n=== Nested TOML Configuration ===") + + +class TomlConfig(Config[T]): + ... + + +TomlConfig.set_file(Path("nested_example.toml")) + + +class ServiceConfig: + """Service configuration.""" + + name = TomlConfig("my-service") + port = TomlConfig(8080) + + class API: + """API configuration.""" + + version = TomlConfig("v1") + timeout = TomlConfig(30) + + class RateLimit: + """Rate limiting configuration.""" + + enabled = TomlConfig(True) + requests_per_minute = TomlConfig(60) + + +print(f"Service name: {ServiceConfig.name}") +print(f"Service port: {ServiceConfig.port}") +print(f"API version: {ServiceConfig.API.version}") +print(f"API timeout: {ServiceConfig.API.timeout}s") +print(f"Rate limit enabled: {ServiceConfig.API.RateLimit.enabled}") +print(f"Requests per minute: {ServiceConfig.API.RateLimit.requests_per_minute}") + +print("\n=== Configuration files created successfully! ===") +print("Check the following files to see the structure:") +print(" - nested_example.json") +print(" - nested_example.yaml") +print(" - nested_example.ini") +print(" - nested_example.toml") diff --git a/examples/nested_example.ini b/examples/nested_example.ini new file mode 100644 index 0000000..05059ec --- /dev/null +++ b/examples/nested_example.ini @@ -0,0 +1,12 @@ +[AppConfig.Logging.Rotation] +max_size = 10 +backup_count = 5 + +[AppConfig.Logging] +level = INFO +file = app.log + +[AppConfig] +version = 1.0.0 +debug = False + diff --git a/examples/nested_example.json b/examples/nested_example.json new file mode 100644 index 0000000..6c55357 --- /dev/null +++ b/examples/nested_example.json @@ -0,0 +1,12 @@ +{ + "DatabaseConfig": { + "Credentials": { + "username": "admin", + "password": "secret123", + "use_ssl": true + }, + "host": "localhost", + "port": 5432, + "name": "myapp_db" + } +} \ No newline at end of file diff --git a/examples/nested_example.toml b/examples/nested_example.toml new file mode 100644 index 0000000..ed8c4e0 --- /dev/null +++ b/examples/nested_example.toml @@ -0,0 +1,11 @@ +[ServiceConfig] +name = "my-service" +port = 8080 + +[ServiceConfig.API] +version = "v1" +timeout = 30 + +[ServiceConfig.API.RateLimit] +enabled = true +requests_per_minute = 60 diff --git a/examples/nested_example.yaml b/examples/nested_example.yaml new file mode 100644 index 0000000..c0e9977 --- /dev/null +++ b/examples/nested_example.yaml @@ -0,0 +1,11 @@ +ServerConfig: + Network: + IPv4: + address: 192.168.1.100 + netmask: 255.255.255.0 + gateway: 192.168.1.1 + IPv6: + address: fe80::1 + prefix: 64 + interface: eth0 + name: web-server-01 diff --git a/examples/optional_values.py b/examples/optional_values.py index 3c6a384..4d3bd00 100644 --- a/examples/optional_values.py +++ b/examples/optional_values.py @@ -17,8 +17,6 @@ from confkit import Config from confkit.data_types import Integer, Optional, String, StrEnum as ConfigStrEnum -parser = ConfigParser() -Config.set_parser(parser) Config.set_file(Path("config.ini")) diff --git a/examples/other_file_types.py b/examples/other_file_types.py index 838b65f..7df0acf 100644 --- a/examples/other_file_types.py +++ b/examples/other_file_types.py @@ -1,12 +1,12 @@ -"""Examples of using Config.set_parser with MsgspecParser for JSON, TOML, and YAML files. +"""Examples of using Config with JSON, TOML, and YAML files. This requires the 'msgspec' extra to be installed!! +The Parser facade automatically selects the appropriate adapter based on file extension. """ from confkit.data_types import List from typing import TypeVar from pathlib import Path from confkit.config import Config -from confkit.ext.parsers import MsgspecParser T = TypeVar("T") @@ -14,12 +14,9 @@ class JsonConfig(Config[T]): ... class TomlConfig(Config[T]): ... class YamlConfig(Config[T]): ... -# Set up each config class with its own parser and file -JsonConfig.set_parser(MsgspecParser()) +# Set up each config class with its file - parser is auto-detected JsonConfig.set_file(Path("example.json")) -TomlConfig.set_parser(MsgspecParser()) TomlConfig.set_file(Path("example.toml")) -YamlConfig.set_parser(MsgspecParser()) YamlConfig.set_file(Path("example.yaml")) # Define config values for each class diff --git a/examples/pydantic_example.py b/examples/pydantic_example.py index 135c64e..3d88a2a 100644 --- a/examples/pydantic_example.py +++ b/examples/pydantic_example.py @@ -19,8 +19,6 @@ from confkit import Config from confkit.ext.pydantic import apply_model -parser = ConfigParser() -Config.set_parser(parser) Config.set_file(Path("pydantic.ini")) diff --git a/examples/references.py b/examples/references.py index 9c9375b..9d063bc 100644 --- a/examples/references.py +++ b/examples/references.py @@ -10,9 +10,6 @@ class OtherConfig(Config): pass # Set up the parser and file -parser = ConfigParser() -Config.set_parser(parser) -OtherConfig.set_parser(ConfigParser()) Config.set_file(Path(__file__).parent / "ref.ini") OtherConfig.set_file(Path(__file__).parent / "other_ref.ini") diff --git a/examples/url_example.py b/examples/url_example.py index e930166..f99e954 100644 --- a/examples/url_example.py +++ b/examples/url_example.py @@ -5,8 +5,6 @@ from confkit import Config, BaseDataType from urllib.parse import ParseResult, urlparse -parser = ConfigParser() -Config.set_parser(parser) Config.set_file(Path("config.ini")) class URL(BaseDataType[ParseResult]): diff --git a/src/confkit/config.py b/src/confkit/config.py index 1e8338b..6bb368e 100644 --- a/src/confkit/config.py +++ b/src/confkit/config.py @@ -8,11 +8,11 @@ import warnings from abc import abstractmethod -from configparser import ConfigParser from functools import wraps from types import NoneType from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, ParamSpec, TypeVar, overload +from confkit.ext.parsers import IniParser from confkit.watcher import FileWatcher from .data_types import BaseDataType, Optional @@ -130,9 +130,9 @@ def _initialize_data_type(self, default: VT | None | BaseDataType[VT]) -> None: def _read_parser(self) -> None: """Ensure the parser has read the file at initialization. Avoids rewriting the file when settings are already set.""" - if not self._has_read_config: + if not self.__class__._has_read_config: self._parser.read(self._file) - self._has_read_config = True + self.__class__._has_read_config = True def _validate_init(self) -> None: """Validate the config descriptor, ensuring it's properly set up.""" @@ -153,8 +153,8 @@ def _warn_base_class_usage() -> None: warnings.warn(" is the base class. Subclass to avoid unexpected behavior.", stacklevel=2) @classmethod - def set_parser(cls, parser: ConfkitParser) -> None: - """Set the parser for ALL descriptors.""" + def _set_parser(cls, parser: ConfkitParser) -> None: + """Set the parser for ALL descriptor instances (of this type/class).""" if cls is Config: cls._warn_base_class_usage() cls._parser = parser @@ -171,7 +171,7 @@ def _detect_parser(cls) -> None: raise ValueError(msg) match cls._file.suffix.lower(): case ".ini": - cls._parser = ConfigParser() + cls._parser = IniParser() case ".yaml" | ".yml" | ".json" | ".toml": from confkit.ext.parsers import MsgspecParser # noqa: PLC0415 Only import if actually used. cls._parser = MsgspecParser() @@ -234,13 +234,29 @@ def validate_parser(cls) -> None: def __set_name__(self, owner: type, name: str) -> None: """Set the name of the attribute to the name of the descriptor.""" self.name = name - self._section = owner.__name__ + self._section = self._build_section_name(owner) self._setting = name self._ensure_option() cls = self.__class__ self._original_value = cls._parser.get(self._section, self._setting) or self._data_type.default self.private = f"_{self._section}_{self._setting}_{self.name}" + @staticmethod + def _build_section_name(owner: type) -> str: + """Build a section name from the class hierarchy using dot notation. + + Strips out function-local scope markers like . + """ + if qualname := getattr(owner, "__qualname__", None): + split_at = qualname.find(".") + if split_at != -1: + qualname = qualname[split_at + len(".") :] + return ".".join( + part + for part in qualname.split(".") + ) + return owner.__name__ + def _ensure_section(self) -> None: """Ensure the section exists in the config file. Creates one if it doesn't exist.""" if not self._parser.has_section(self._section): diff --git a/src/confkit/ext/parsers.py b/src/confkit/ext/parsers.py index cfb7e85..b8f7ed8 100644 --- a/src/confkit/ext/parsers.py +++ b/src/confkit/ext/parsers.py @@ -1,9 +1,11 @@ """Parsers for Confkit configuration files.""" - from __future__ import annotations import os import sys +from configparser import ConfigParser +from io import TextIOWrapper +from pathlib import Path from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar try: @@ -20,10 +22,16 @@ ) raise ImportError(msg) from exc + if sys.version_info >= (3, 12): from typing import Protocol, override + # TD: Use nested types when Python 3.11 is EOL and we can drop support for it + # otherwise this gets syntax errors. + # type NestedDict = dict[str, NestedDict | str | int | float | bool | None] # noqa: ERA001 + NestedDict = dict[str, Any] else: from typing_extensions import Protocol, override + NestedDict = dict[str, Any] from confkit.sentinels import UNSET @@ -59,6 +67,58 @@ def get(self, section: str, option: str, fallback: str = UNSET) -> str: def set(self, section: str, option: str, value: str) -> None: """Set the value of an option within a section.""" + +class IniParser(ConfkitParser): + """Adapter for ConfigParser that supports dot notation for nested sections.""" + + def __init__(self) -> None: + """Initialize the IniParser with an internal ConfigParser instance.""" + self.parser = ConfigParser() + self._file: Path | None = None + + @override + def read(self, file: Path) -> None: + self.parser.read(file) + + @override + def write(self, io: TextIOWrapper) -> None: + self.parser.write(io) + + @override + def has_section(self, section: str) -> bool: + return self.parser.has_section(section) + + @override + def set_section(self, section: str) -> None: + if not self.parser.has_section(section): + self.parser.add_section(section) + + @override + def set_option(self, option: str) -> None: + # Not used directly; options are set via set() + pass + + @override + def add_section(self, section: str) -> None: + self.parser.add_section(section) + + @override + def has_option(self, section: str, option: str) -> bool: + return self.parser.has_option(section, option) + + @override + def remove_option(self, section: str, option: str) -> None: + self.parser.remove_option(section, option) + + @override + def get(self, section: str, option: str, fallback: str = UNSET) -> str: + return self.parser.get(section, option, fallback=fallback) + + @override + def set(self, section: str, option: str, value: str) -> None: + self.parser.set(section, option, value) + + class EnvParser(ConfkitParser): """A parser for environment variables and .env files. @@ -151,7 +211,6 @@ def set(self, section: str, option: str, value: str) -> None: raise NotImplementedError(msg) - class MsgspecParser(ConfkitParser, Generic[T]): """Unified msgspec-based parser for YAML, JSON, TOML configuration files.""" @@ -177,6 +236,9 @@ def read(self, file: Path) -> None: if parser := self._parsers.get(ext): try: self.data = parser.decode(f.read()) + # Handle None or empty values from YAML/TOML files + if self.data is None or not isinstance(self.data, dict): + self.data = {} except msgspec.DecodeError: self.data = {} return @@ -196,18 +258,47 @@ def write(self, io: TextIOWrapper[_WrappedBuffer]) -> None: msg = f"Unsupported file extension for writing: {ext}" raise ValueError(msg) + def _navigate_to_section(self, section: str, *, create: bool = False) -> NestedDict | None: + """Navigate to a nested section using dot notation. + + Args: + section: Dot-separated section path (e.g., "Parent.Child.GrandChild") + create: If True, create missing intermediate sections + + Returns: + The nested dict at the section path, or None if not found and create=False + + """ + if not section: + return self.data + + parts = section.split(".") + current = self.data + + for part in parts: + if not isinstance(current, dict): + return None + if part not in current: + if create: + current[part] = {} + else: + return None + current = current[part] + + return current if isinstance(current, dict) else None + @override def has_section(self, section: str) -> bool: - return section in self.data + return self._navigate_to_section(section, create=False) is not None @override def set_section(self, section: str) -> None: - if section not in self.data: - self.data[section] = {} + self._navigate_to_section(section, create=True) @override def has_option(self, section: str, option: str) -> bool: - return section in self.data and option in self.data[section] + section_data = self._navigate_to_section(section, create=False) + return section_data is not None and option in section_data @override def add_section(self, section: str) -> None: @@ -215,17 +306,48 @@ def add_section(self, section: str) -> None: @override def get(self, section: str, option: str, fallback: str = UNSET) -> str: - try: - return self.data[section][option] - except KeyError: - return str(fallback) + section_data = self._navigate_to_section(section, create=False) + if section_data is None or option not in section_data: + return str(fallback) if fallback is not UNSET else "" + return str(section_data[option]) @override def set(self, section: str, option: str, value: str) -> None: - self.set_section(section) - self.data[section][option] = value + section_data = self._navigate_to_section(section, create=True) + if section_data is not None: + # Try to preserve the original type by parsing the string value + # This is important for JSON/YAML/TOML which support native types + parsed_value = self._parse_value(value) + section_data[option] = parsed_value + + def _parse_value(self, value: str) -> bool | int | float | str: + """Parse a string value to its appropriate type for structured formats. + + Attempts to convert string values back to their original types: + - "True"/"False" -> bool + - Integer strings -> int + - Float strings -> float + - Everything else remains a string + """ + if value == "True": + return True + if value == "False": + return False + + try: + return int(value) + except ValueError: + pass + + try: + return float(value) + except ValueError: + pass + + return value @override def remove_option(self, section: str, option: str) -> None: - if self.has_option(section, option): - del self.data[section][option] + section_data = self._navigate_to_section(section, create=False) + if section_data is not None and option in section_data: + del section_data[option] diff --git a/tests/test_config.py b/tests/test_config.py index 2f3ebce..00b6d76 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,7 +1,5 @@ """Test suite for the Config class and its descriptors.""" - import enum -from configparser import ConfigParser from enum import auto from pathlib import Path from unittest.mock import patch @@ -29,6 +27,7 @@ String, ) from confkit.exceptions import InvalidConverterError, InvalidDefaultError +from confkit.ext.parsers import IniParser class Config(OG): @@ -37,8 +36,8 @@ class Config(OG): config = Path("test.ini") config.unlink(missing_ok=True) # Remove the file if it exists config.touch() # Create a new empty file for testing -parser = ConfigParser() -Config.set_parser(parser) +parser = IniParser() +Config._set_parser(parser) Config.set_file(config) Config.write_on_edit = True # Enable writing to file during tests diff --git a/tests/test_config_classvars.py b/tests/test_config_classvars.py index 2e58b99..ff27fe8 100644 --- a/tests/test_config_classvars.py +++ b/tests/test_config_classvars.py @@ -2,10 +2,10 @@ This also contains the test cases where specific settings are expected. """ +from confkit.ext.parsers import IniParser import tempfile from collections.abc import Callable -from configparser import ConfigParser from pathlib import Path from typing import Never, ParamSpec, TypeVar @@ -70,9 +70,9 @@ def convert(self, value: str) -> Never: # ty: ignore[invalid-return-type] tmp_file.write("[Test]\nstring = test_value\n") # Set up isolated environment - test_parser = ConfigParser() + test_parser = IniParser() test_parser.read(tmp_path) - Config.set_parser(test_parser) + Config._set_parser(test_parser) Config.set_file(tmp_path) config_instance = Config.__new__(Config) @@ -102,9 +102,9 @@ def validate(self) -> bool: tmp_file.write("[Test]\nstring = test_value\n") # Set up isolated environment - test_parser = ConfigParser() + test_parser = IniParser() test_parser.read(tmp_path) - Config.set_parser(test_parser) + Config._set_parser(test_parser) Config.set_file(tmp_path) config_instance = Config.__new__(Config) @@ -128,9 +128,9 @@ def test_config_optional_type_validation_success() -> None: tmp_file.write("[Test]\nstring = test_value\n") # Set up isolated environment - test_parser = ConfigParser() + test_parser = IniParser() test_parser.read(tmp_path) - Config.set_parser(test_parser) + Config._set_parser(test_parser) Config.set_file(tmp_path) config_instance = Config.__new__(Config) @@ -158,9 +158,9 @@ def convert(self, value: str) -> int: # type: ignore[override] tmp_file.write("[Test]\nnull_int = 123\n") # Set up isolated environment - test_parser = ConfigParser() + test_parser = IniParser() test_parser.read(tmp_path) - Config.set_parser(test_parser) + Config._set_parser(test_parser) Config.set_file(tmp_path) config_instance = Config.__new__(Config) @@ -227,7 +227,7 @@ def test_invalid_default_error() -> None: @config_restore def test_ensure_option_existing_option() -> None: """Test _ensure_option when option already exists (line 202->exit branch).""" - test_parser = ConfigParser() + test_parser = IniParser() test_parser.add_section("TestExistingOption") test_parser.set("TestExistingOption", "existing_setting", "existing_value") @@ -235,7 +235,7 @@ def test_ensure_option_existing_option() -> None: test_config.unlink(missing_ok=True) test_config.touch() - Config.set_parser(test_parser) + Config._set_parser(test_parser) Config.set_file(test_config) class TestExistingOption: @@ -251,7 +251,7 @@ class TestExistingOption: @config_restore def test_set_write_on_edit_disabled() -> None: """Test _set method when write_on_edit is False (line 230->exit branch).""" - test_parser = ConfigParser() + test_parser = IniParser() test_config = Path("no_write_test.ini") test_config.unlink(missing_ok=True) test_config.touch() @@ -261,7 +261,7 @@ def test_set_write_on_edit_disabled() -> None: Config.write_on_edit = False Config.set_file(test_config) - Config.set_parser(test_parser) + Config._set_parser(test_parser) initial_content = test_config.read_text() Config._set("TestSection", "test_setting", "new_value") diff --git a/tests/test_config_decorators.py b/tests/test_config_decorators.py index c34fc5d..6b7e762 100644 --- a/tests/test_config_decorators.py +++ b/tests/test_config_decorators.py @@ -3,9 +3,7 @@ These are usually not safe enough to test using a single file, at the same time.ArithmeticError These get their own test file. """ - from collections.abc import Callable -from configparser import ConfigParser from pathlib import Path from typing import ParamSpec, TypeVar @@ -14,6 +12,7 @@ from hypothesis import strategies as st from confkit.config import Config as OG +from confkit.ext.parsers import IniParser from confkit.sentinels import UNSET F = TypeVar("F") @@ -29,7 +28,7 @@ def inner(*args: P.args, **kwargs: P.kwargs) -> F: new_file = Path(f"{func.__name__}.ini") # ty: ignore[unresolved-attribute] new_file.touch(exist_ok=True) Config._file = new_file - Config._parser = ConfigParser() + Config._parser = IniParser() result = func(*args, **kwargs) Config._file = restores[0] Config._parser = restores[1] diff --git a/tests/test_config_detect_parser.py b/tests/test_config_detect_parser.py index 021954f..3adc033 100644 --- a/tests/test_config_detect_parser.py +++ b/tests/test_config_detect_parser.py @@ -1,11 +1,10 @@ """Test Config.detect_parser behavior for different file extensions.""" -from configparser import ConfigParser from pathlib import Path import pytest from confkit.config import Config as OG -from confkit.ext.parsers import MsgspecParser +from confkit.ext.parsers import IniParser, MsgspecParser from confkit.sentinels import UNSET @@ -16,7 +15,7 @@ def test_detect_parser_ini() -> None: Config._file = Path("test.ini") Config._parser = None Config._detect_parser() - assert isinstance(Config._parser, ConfigParser) + assert isinstance(Config._parser, IniParser) def test_detect_parser_msgspec() -> None: Config._file = Path("test.yaml") diff --git a/tests/test_metaclass.py b/tests/test_metaclass.py index d909019..58eb12b 100644 --- a/tests/test_metaclass.py +++ b/tests/test_metaclass.py @@ -1,15 +1,15 @@ -from configparser import ConfigParser from pathlib import Path from confkit.config import Config as OG from confkit.config import ConfigContainerMeta +from confkit.ext.parsers import IniParser class Config(OG): """A Config class for testing purposes.""" Config.set_file(Path("meta_test.ini")) -Config.set_parser(ConfigParser()) +Config._set_parser(IniParser()) def mock_meta_class() -> None: class Mock(ConfigContainerMeta): diff --git a/tests/test_msgspecparser_no_msgspec.py b/tests/test_msgspecparser_no_msgspec.py index 5bafa58..0f70b54 100644 --- a/tests/test_msgspecparser_no_msgspec.py +++ b/tests/test_msgspecparser_no_msgspec.py @@ -6,6 +6,13 @@ @pytest.mark.order("last") def test_msgspecparser_import_error(monkeypatch: pytest.MonkeyPatch) -> None: + # TD: match error msg to pytest.raises() + _ = ( + r"confkit.ext.parsers requires the optional 'msgspec' extra. " + r"Install it via 'pip install " + r"confkit[msgspec]' or 'uv add confkit[msgspec]'." + r"This is required for json, toml and yaml parsing." + ) # Simulate msgspec not installed monkeypatch.setitem(sys.modules, "msgspec", None) monkeypatch.setitem(sys.modules, "msgspec.json", None) diff --git a/tests/test_multiple_configurations.py b/tests/test_multiple_configurations.py index 95de922..3d78b0e 100644 --- a/tests/test_multiple_configurations.py +++ b/tests/test_multiple_configurations.py @@ -1,12 +1,11 @@ """Tests for multiple configurations.""" - -from configparser import ConfigParser from pathlib import Path import hypothesis from hypothesis import strategies as st from confkit.config import Config +from confkit.ext.parsers import IniParser class Config1(Config): ... @@ -14,11 +13,11 @@ class Config2(Config): ... config_1 = Path("config1_test.ini") Config1.set_file(config_1) -Config1.set_parser(ConfigParser()) +Config1._set_parser(IniParser()) config_2 = Path("config2_test.ini") Config2.set_file(config_2) -Config2.set_parser(ConfigParser()) +Config2._set_parser(IniParser()) class Config3(Config1): ... diff --git a/tests/test_nested_config.py b/tests/test_nested_config.py new file mode 100644 index 0000000..993a0e2 --- /dev/null +++ b/tests/test_nested_config.py @@ -0,0 +1,255 @@ +"""Tests for nested configuration support.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, TypeVar + +import pytest + +from confkit.config import Config +from confkit.ext.parsers import IniParser, MsgspecParser + +if TYPE_CHECKING: + from pathlib import Path + +T = TypeVar("T") + + +@pytest.fixture +def ini_config_file(tmp_path: Path) -> Path: + """Create a temporary INI config file.""" + return tmp_path / "nested_test.ini" + + +@pytest.fixture +def yaml_config_file(tmp_path: Path) -> Path: + """Create a temporary YAML config file.""" + return tmp_path / "nested_test.yaml" + + +@pytest.fixture +def json_config_file(tmp_path: Path) -> Path: + """Create a temporary JSON config file.""" + return tmp_path / "nested_test.json" + + +@pytest.fixture +def toml_config_file(tmp_path: Path) -> Path: + """Create a temporary TOML config file.""" + return tmp_path / "nested_test.toml" + + +def test_nested_ini_config(ini_config_file: Path) -> None: + """Test nested configuration with INI files using dot notation.""" + + class IniConfig(Config[T]): ... + + IniConfig._set_parser(IniParser()) + IniConfig.set_file(ini_config_file) + + class Database: + host = IniConfig("localhost") + port = IniConfig(5432) + + class Credentials: + username = IniConfig("admin") + password = IniConfig("secret") + + # Test reading nested values + assert Database.host == "localhost" + assert Database.port == 5432 + assert Database.Credentials.username == "admin" + assert Database.Credentials.password == "secret" + + # Read the file and verify INI structure uses dot notation + parser = IniParser() + parser.read(ini_config_file) + + # INI files use dot notation for nested sections + assert parser.has_section("Database") + assert parser.get("Database", "host") == "localhost" + assert parser.get("Database", "port") == "5432" + assert parser.has_section("Database.Credentials") + assert parser.get("Database.Credentials", "username") == "admin" + assert parser.get("Database.Credentials", "password") == "secret" + + +def test_nested_yaml_config(yaml_config_file: Path) -> None: + """Test nested configuration with YAML files.""" + + class YamlConfig(Config[T]): ... + + YamlConfig.set_file(yaml_config_file) + + class Database: + host = YamlConfig("localhost") + port = YamlConfig(5432) + + class Credentials: + username = YamlConfig("admin") + password = YamlConfig("secret") + + # Test writing nested values + assert Database.host == "localhost" + assert Database.port == 5432 + assert Database.Credentials.username == "admin" + assert Database.Credentials.password == "secret" + + # Read the file and verify structure + with yaml_config_file.open("r") as f: + import msgspec.yaml + data = msgspec.yaml.decode(f.read()) + + assert data["Database"]["host"] == "localhost" + assert data["Database"]["port"] == 5432 + assert data["Database"]["Credentials"]["username"] == "admin" + assert data["Database"]["Credentials"]["password"] == "secret" + + +def test_nested_json_config(json_config_file: Path) -> None: + """Test nested configuration with JSON files.""" + + class JsonConfig(Config[T]): ... + + JsonConfig.set_file(json_config_file) + + class Server: + name = JsonConfig("web-server") + + class Network: + ip = JsonConfig("127.0.0.1") + + class Ports: + http = JsonConfig(80) + https = JsonConfig(443) + + # Test three-level nesting + assert Server.name == "web-server" + assert Server.Network.ip == "127.0.0.1" + assert Server.Network.Ports.http == 80 + assert Server.Network.Ports.https == 443 + + # Read and verify JSON structure + with json_config_file.open("r") as f: + import msgspec.json + data = msgspec.json.decode(f.read()) + + assert data["Server"]["name"] == "web-server" + assert data["Server"]["Network"]["ip"] == "127.0.0.1" + assert data["Server"]["Network"]["Ports"]["http"] == 80 + assert data["Server"]["Network"]["Ports"]["https"] == 443 + + +def test_nested_toml_config(toml_config_file: Path) -> None: + """Test nested configuration with TOML files.""" + + class TomlConfig(Config[T]): ... + + TomlConfig.set_file(toml_config_file) + + class App: + version = TomlConfig("1.0.0") + + class Settings: + debug = TomlConfig(True) + timeout = TomlConfig(30) + + assert App.version == "1.0.0" + assert App.Settings.debug is True + assert App.Settings.timeout == 30 + + # Read and verify TOML structure + with toml_config_file.open("r") as f: + import msgspec.toml + data = msgspec.toml.decode(f.read()) + + assert data["App"]["version"] == "1.0.0" + assert data["App"]["Settings"]["debug"] is True + assert data["App"]["Settings"]["timeout"] == 30 + + +def test_msgspec_parser_nested_navigation() -> None: + """Test the MsgspecParser's ability to navigate nested structures.""" + parser = MsgspecParser() + parser.data = {} + + # Test creating nested sections + parser.set("Parent.Child.GrandChild", "setting", "value") + + assert parser.has_section("Parent") + assert parser.has_section("Parent.Child") + assert parser.has_section("Parent.Child.GrandChild") + assert parser.has_option("Parent.Child.GrandChild", "setting") + assert parser.get("Parent.Child.GrandChild", "setting") == "value" + + # Test setting values at different levels + parser.set("Parent", "root_setting", "root_value") + parser.set("Parent.Child", "child_setting", "child_value") + + assert parser.get("Parent", "root_setting") == "root_value" + assert parser.get("Parent.Child", "child_setting") == "child_value" + + # Verify data structure + assert parser.data == { + "Parent": { + "root_setting": "root_value", + "Child": { + "child_setting": "child_value", + "GrandChild": { + "setting": "value", + }, + }, + }, + } + + +def test_msgspec_parser_nested_remove_option() -> None: + """Test removing options from nested sections.""" + parser = MsgspecParser() + parser.data = {} + + parser.set("Level1.Level2.Level3", "key", "value") + assert parser.has_option("Level1.Level2.Level3", "key") + + parser.remove_option("Level1.Level2.Level3", "key") + assert not parser.has_option("Level1.Level2.Level3", "key") + + +def test_msgspec_parser_nested_section_not_found() -> None: + """Test behavior when nested section doesn't exist.""" + parser = MsgspecParser() + parser.data = {} + + assert not parser.has_section("NonExistent.Section") + assert not parser.has_option("NonExistent.Section", "key") + assert parser.get("NonExistent.Section", "key", fallback="default") == "default" + + +def test_mixed_nested_and_flat_sections(json_config_file: Path) -> None: + """Test mixing nested and flat sections.""" + + class JsonConfig(Config[T]): ... + + JsonConfig.set_file(json_config_file) + + class FlatSection: + setting1 = JsonConfig("value1") + + class NestedSection: + setting2 = JsonConfig("value2") + + class Inner: + setting3 = JsonConfig("value3") + + assert FlatSection.setting1 == "value1" + assert NestedSection.setting2 == "value2" + assert NestedSection.Inner.setting3 == "value3" + + # Verify structure + with json_config_file.open("r") as f: + import msgspec.json + data = msgspec.json.decode(f.read()) + + assert data["FlatSection"]["setting1"] == "value1" + assert data["NestedSection"]["setting2"] == "value2" + assert data["NestedSection"]["Inner"]["setting3"] == "value3" diff --git a/tests/test_on_file_change.py b/tests/test_on_file_change.py index 6ed3f74..93e9364 100644 --- a/tests/test_on_file_change.py +++ b/tests/test_on_file_change.py @@ -9,20 +9,20 @@ # config class with a custom on_file_change -class TestConfig(Config[Any]): +class ConfigTest(Config[Any]): file_change_events: ClassVar[list[tuple[str, Any, Any]]] = [] def on_file_change(self, origin: str, old: Any, new: Any) -> None: # noqa: ANN401, D102 self.__class__.file_change_events.append((origin, old, new)) config_file = Path("config_test.ini") -TestConfig.set_file(config_file) -TestConfig._watcher.has_changed = lambda: True # Always trigger file_changed. triggering the logging of events -TestConfig.validate_types = False # Disable strict type validation for testing -TestConfig.write_on_edit = True +ConfigTest.set_file(config_file) +ConfigTest._watcher.has_changed = lambda: True # Always trigger file_changed. triggering the logging of events +ConfigTest.validate_types = False # Disable strict type validation for testing +ConfigTest.write_on_edit = True class Test: - setting = TestConfig(0) + setting = ConfigTest(0) prev_value = 0 @@ -32,9 +32,9 @@ def test_on_file_change_get_and_set(value: int) -> None: # This only tests the set event, but we also assert get for sanity. # To test the get event, we would need to write the file, before getting, but after setting. global prev_value # noqa: PLW0603 - TestConfig.file_change_events.clear() + ConfigTest.file_change_events.clear() test = Test() - events = TestConfig.file_change_events + events = ConfigTest.file_change_events assert len(events) == 0 diff --git a/tests/test_pydantic_models.py b/tests/test_pydantic_models.py index 757dfe0..4af4a85 100644 --- a/tests/test_pydantic_models.py +++ b/tests/test_pydantic_models.py @@ -2,7 +2,6 @@ from __future__ import annotations import warnings -from configparser import ConfigParser from typing import TYPE_CHECKING import pytest @@ -10,6 +9,7 @@ from confkit.config import Config from confkit.data_types import List +from confkit.ext.parsers import IniParser from confkit.ext.pydantic import apply_model if TYPE_CHECKING: @@ -23,8 +23,8 @@ @pytest.fixture -def config_environment(tmp_path: Path) -> Generator[ConfigParser]: - parser = ConfigParser() +def config_environment(tmp_path: Path) -> Generator[IniParser]: + parser = IniParser() config_path = tmp_path / "pydantic.ini" config_path.touch() @@ -33,7 +33,7 @@ def config_environment(tmp_path: Path) -> Generator[ConfigParser]: previous_read_state = Config._has_read_config previous_write_state = Config.write_on_edit - Config.set_parser(parser) + Config._set_parser(parser) Config.set_file(config_path) Config._has_read_config = False Config.write_on_edit = True @@ -60,7 +60,7 @@ class FeatureFlagsModel(BaseModel): tags: list[str] -def test_pydantic_model_populates_config(config_environment: ConfigParser) -> None: +def test_pydantic_model_populates_config(config_environment: IniParser) -> None: parser = config_environment class ServiceConfig: @@ -91,7 +91,7 @@ class ServiceConfig: assert parser.get("ServiceConfig", "port") == str(payload.port) -def test_pydantic_prevalidation_handles_type_casts(config_environment: ConfigParser) -> None: +def test_pydantic_prevalidation_handles_type_casts(config_environment: IniParser) -> None: parser = config_environment class FeatureConfig: diff --git a/tests/test_two_instances.py b/tests/test_two_instances.py index 4c9e6ea..55e1786 100644 --- a/tests/test_two_instances.py +++ b/tests/test_two_instances.py @@ -1,8 +1,8 @@ -from configparser import ConfigParser from pathlib import Path from tempfile import TemporaryDirectory from confkit.config import Config +from confkit.ext.parsers import IniParser def test_two_instances_share_values_and_on_file_change_called() -> None: @@ -14,9 +14,9 @@ def test_two_instances_share_values_and_on_file_change_called() -> None: with TemporaryDirectory() as tmpdir: config_file = Path(tmpdir) / "test.ini" - parser = ConfigParser() + parser = IniParser() # Use the global Config parser/file for tests (same pattern as other tests) - Config.set_parser(parser) + Config._set_parser(parser) Config.set_file(config_file) Config.write_on_edit = True From 47295dad4b44d43842c3ea5eb418f6d918f9b45f Mon Sep 17 00:00:00 2001 From: Martijn Wieringa Date: Mon, 2 Mar 2026 18:52:02 +0100 Subject: [PATCH 02/12] Update docs/examples/custom_data_type.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/examples/custom_data_type.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/examples/custom_data_type.md b/docs/examples/custom_data_type.md index e64f277..4086f39 100644 --- a/docs/examples/custom_data_type.md +++ b/docs/examples/custom_data_type.md @@ -15,8 +15,7 @@ from pathlib import Path from confkit import Config from confkit.data_types import BaseDataType -# Configure parser + file (Parser is automatically detected) -Config.set_parser(parser) +# Configure config file (parser is automatically detected from extension) Config.set_file(Path("config.ini")) class UpperString(BaseDataType[str]): From bfab9a4d643a37f9c3ca8075fbb065ca39151f16 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 18:57:44 +0100 Subject: [PATCH 03/12] Fix docs: remove non-existent `Parser`/`set_parser` references (#53) * Initial plan * Fix documentation: remove non-existent Parser/set_parser references Co-authored-by: HEROgold <21345384+HEROgold@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: HEROgold <21345384+HEROgold@users.noreply.github.com> --- .github/copilot-instructions.md | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 6b5b5eb..eb5e248 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -175,24 +175,17 @@ The `with_setting` approach is more type-safe as it references an actual descrip ### Required Initialization -Always initialize Config with a file path before use. The Parser facade automatically detects and uses the appropriate adapter based on file extension: +Always initialize Config with a file path before use. The parser is auto-detected based on file extension: ```python from pathlib import Path from confkit import Config -# Simplified approach - parser is auto-detected based on file extension +# Parser is auto-detected based on file extension Config.set_file(Path("config.ini")) # Uses IniParser Config.set_file(Path("config.json")) # Uses MsgspecParser Config.set_file(Path("config.yaml")) # Uses MsgspecParser Config.set_file(Path("config.toml")) # Uses MsgspecParser -Config.set_file(Path(".env")) # Uses EnvParser - -# Option 2: Explicitly set parser (not recommended unless absolutely required) -from confkit.ext.parsers import Parser -parser = Parser(Path("config.ini")) -Config.set_parser(parser) -Config.set_file(Path("config.ini")) ``` ### Supported File Formats @@ -201,7 +194,7 @@ Config.set_file(Path("config.ini")) - **JSON files** (`.json`): Uses `MsgspecParser`, requires `msgspec` extra - **YAML files** (`.yaml`, `.yml`): Uses `MsgspecParser`, requires `msgspec` extra - **TOML files** (`.toml`): Uses `MsgspecParser`, requires `msgspec` extra -- **Environment files** (`.env`): Uses `EnvParser`, no sections (flat key-value pairs) +- **Environment files** (`.env`): Uses `EnvParser`, no sections (flat key-value pairs) — not auto-detected by `set_file` ### List Type Handling From 068db574b772ff7c61ef0f2e2f434f033c45d653 Mon Sep 17 00:00:00 2001 From: HEROgold Date: Mon, 2 Mar 2026 18:50:03 +0100 Subject: [PATCH 04/12] mark as deprecation instead of private method --- examples/nested_config.py | 2 +- src/confkit/config.py | 5 +- tests/test_config.py | 2 +- tests/test_config_classvars.py | 12 ++--- tests/test_metaclass.py | 2 +- tests/test_multiple_configurations.py | 4 +- tests/test_nested_config.py | 72 ++++++++++++++++++++++++++- tests/test_pydantic_models.py | 2 +- tests/test_two_instances.py | 2 +- 9 files changed, 88 insertions(+), 15 deletions(-) diff --git a/examples/nested_config.py b/examples/nested_config.py index 185cde9..7077901 100644 --- a/examples/nested_config.py +++ b/examples/nested_config.py @@ -99,7 +99,7 @@ class IniConfig(Config[T]): ... -IniConfig._set_parser(IniParser()) +IniConfig.set_parser(IniParser()) IniConfig.set_file(Path("nested_example.ini")) diff --git a/src/confkit/config.py b/src/confkit/config.py index 6bb368e..b1a7ad3 100644 --- a/src/confkit/config.py +++ b/src/confkit/config.py @@ -12,6 +12,8 @@ from types import NoneType from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, ParamSpec, TypeVar, overload +from typing_extensions import deprecated + from confkit.ext.parsers import IniParser from confkit.watcher import FileWatcher @@ -153,7 +155,8 @@ def _warn_base_class_usage() -> None: warnings.warn(" is the base class. Subclass to avoid unexpected behavior.", stacklevel=2) @classmethod - def _set_parser(cls, parser: ConfkitParser) -> None: + @deprecated("Avoid using set_parser. Confkit will automatically assign a parser based on the file extension. In 2.0 this will be a private method.") + def set_parser(cls, parser: ConfkitParser) -> None: """Set the parser for ALL descriptor instances (of this type/class).""" if cls is Config: cls._warn_base_class_usage() diff --git a/tests/test_config.py b/tests/test_config.py index 00b6d76..60dfa94 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -37,7 +37,7 @@ class Config(OG): config.unlink(missing_ok=True) # Remove the file if it exists config.touch() # Create a new empty file for testing parser = IniParser() -Config._set_parser(parser) +Config.set_parser(parser) Config.set_file(config) Config.write_on_edit = True # Enable writing to file during tests diff --git a/tests/test_config_classvars.py b/tests/test_config_classvars.py index ff27fe8..6ce8d37 100644 --- a/tests/test_config_classvars.py +++ b/tests/test_config_classvars.py @@ -72,7 +72,7 @@ def convert(self, value: str) -> Never: # ty: ignore[invalid-return-type] # Set up isolated environment test_parser = IniParser() test_parser.read(tmp_path) - Config._set_parser(test_parser) + Config.set_parser(test_parser) Config.set_file(tmp_path) config_instance = Config.__new__(Config) @@ -104,7 +104,7 @@ def validate(self) -> bool: # Set up isolated environment test_parser = IniParser() test_parser.read(tmp_path) - Config._set_parser(test_parser) + Config.set_parser(test_parser) Config.set_file(tmp_path) config_instance = Config.__new__(Config) @@ -130,7 +130,7 @@ def test_config_optional_type_validation_success() -> None: # Set up isolated environment test_parser = IniParser() test_parser.read(tmp_path) - Config._set_parser(test_parser) + Config.set_parser(test_parser) Config.set_file(tmp_path) config_instance = Config.__new__(Config) @@ -160,7 +160,7 @@ def convert(self, value: str) -> int: # type: ignore[override] # Set up isolated environment test_parser = IniParser() test_parser.read(tmp_path) - Config._set_parser(test_parser) + Config.set_parser(test_parser) Config.set_file(tmp_path) config_instance = Config.__new__(Config) @@ -235,7 +235,7 @@ def test_ensure_option_existing_option() -> None: test_config.unlink(missing_ok=True) test_config.touch() - Config._set_parser(test_parser) + Config.set_parser(test_parser) Config.set_file(test_config) class TestExistingOption: @@ -261,7 +261,7 @@ def test_set_write_on_edit_disabled() -> None: Config.write_on_edit = False Config.set_file(test_config) - Config._set_parser(test_parser) + Config.set_parser(test_parser) initial_content = test_config.read_text() Config._set("TestSection", "test_setting", "new_value") diff --git a/tests/test_metaclass.py b/tests/test_metaclass.py index 58eb12b..43f12cd 100644 --- a/tests/test_metaclass.py +++ b/tests/test_metaclass.py @@ -9,7 +9,7 @@ class Config(OG): """A Config class for testing purposes.""" Config.set_file(Path("meta_test.ini")) -Config._set_parser(IniParser()) +Config.set_parser(IniParser()) def mock_meta_class() -> None: class Mock(ConfigContainerMeta): diff --git a/tests/test_multiple_configurations.py b/tests/test_multiple_configurations.py index 3d78b0e..417d226 100644 --- a/tests/test_multiple_configurations.py +++ b/tests/test_multiple_configurations.py @@ -13,11 +13,11 @@ class Config2(Config): ... config_1 = Path("config1_test.ini") Config1.set_file(config_1) -Config1._set_parser(IniParser()) +Config1.set_parser(IniParser()) config_2 = Path("config2_test.ini") Config2.set_file(config_2) -Config2._set_parser(IniParser()) +Config2.set_parser(IniParser()) class Config3(Config1): ... diff --git a/tests/test_nested_config.py b/tests/test_nested_config.py index 993a0e2..6010366 100644 --- a/tests/test_nested_config.py +++ b/tests/test_nested_config.py @@ -44,7 +44,7 @@ def test_nested_ini_config(ini_config_file: Path) -> None: class IniConfig(Config[T]): ... - IniConfig._set_parser(IniParser()) + IniConfig.set_parser(IniParser()) IniConfig.set_file(ini_config_file) class Database: @@ -253,3 +253,73 @@ class Inner: assert data["FlatSection"]["setting1"] == "value1" assert data["NestedSection"]["setting2"] == "value2" assert data["NestedSection"]["Inner"]["setting3"] == "value3" + + +def test_msgspec_parser_path_conflict_scalar_before_dict() -> None: + """Test that ConfigPathConflictError is raised when a scalar blocks a section path.""" + from confkit.exceptions import ConfigPathConflictError + + parser = MsgspecParser() + parser.data = {} + + # Set a scalar value at "Parent.target" + parser.set("Parent", "target", "value") + assert parser.data == {"Parent": {"target": "value"}} + + # Try to set a value at "Parent.target.Child" - "target" is a scalar, not a section + # This should raise ConfigPathConflictError + with pytest.raises(ConfigPathConflictError) as exc_info: + parser.set("Parent.target.Child", "key", "value") + + assert "Path conflict" in str(exc_info.value) + assert "Parent.target" in str(exc_info.value) + assert "scalar value" in str(exc_info.value) + + +def test_msgspec_parser_path_conflict_deep_nesting() -> None: + """Test path conflict detection in deeply nested structures.""" + from confkit.exceptions import ConfigPathConflictError + + parser = MsgspecParser() + parser.data = {} + + # Create a deeply nested structure with a scalar at the end + parser.set("Level1.Level2", "Level3", "scalar_value") + # Result: {"Level1": {"Level2": {"Level3": "scalar_value"}}} + + # Try to treat the scalar "Level3" as a section + with pytest.raises(ConfigPathConflictError) as exc_info: + parser.set("Level1.Level2.Level3.Level4", "key", "value") + + assert "Path conflict" in str(exc_info.value) + assert "scalar value" in str(exc_info.value) + + +def test_msgspec_parser_set_section_conflicts() -> None: + """Test that set_section also detects path conflicts.""" + from confkit.exceptions import ConfigPathConflictError + + parser = MsgspecParser() + parser.data = {} + + # Set a scalar value + parser.set("Parent", "value", "scalar") + + # Try to create a section where the scalar is + with pytest.raises(ConfigPathConflictError): + parser.set_section("Parent.value.Child") + + +def test_msgspec_parser_normal_nested_still_works() -> None: + """Ensure normal nested operations still work after the fix.""" + parser = MsgspecParser() + parser.data = {} + + # This should work fine - no conflicts + parser.set("A.B.C", "key", "value") + parser.set("A.B", "key2", "value2") + parser.set("A", "key3", "value3") + + assert parser.get("A.B.C", "key") == "value" + assert parser.get("A.B", "key2") == "value2" + assert parser.get("A", "key3") == "value3" diff --git a/tests/test_pydantic_models.py b/tests/test_pydantic_models.py index 4af4a85..2008fbd 100644 --- a/tests/test_pydantic_models.py +++ b/tests/test_pydantic_models.py @@ -33,7 +33,7 @@ def config_environment(tmp_path: Path) -> Generator[IniParser]: previous_read_state = Config._has_read_config previous_write_state = Config.write_on_edit - Config._set_parser(parser) + Config.set_parser(parser) Config.set_file(config_path) Config._has_read_config = False Config.write_on_edit = True diff --git a/tests/test_two_instances.py b/tests/test_two_instances.py index 55e1786..b59fb60 100644 --- a/tests/test_two_instances.py +++ b/tests/test_two_instances.py @@ -16,7 +16,7 @@ def test_two_instances_share_values_and_on_file_change_called() -> None: parser = IniParser() # Use the global Config parser/file for tests (same pattern as other tests) - Config._set_parser(parser) + Config.set_parser(parser) Config.set_file(config_file) Config.write_on_edit = True From 040e450fc589bf8b05baaec051e42db1291afb04 Mon Sep 17 00:00:00 2001 From: HEROgold Date: Mon, 2 Mar 2026 19:00:29 +0100 Subject: [PATCH 05/12] Raise ConfigPathConflictError when trying to path through a scalar. --- src/confkit/__init__.py | 3 ++- src/confkit/exceptions.py | 9 +++++++++ src/confkit/ext/parsers.py | 32 ++++++++++++++++++++++++++++++-- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/confkit/__init__.py b/src/confkit/__init__.py index 6f3811f..9f6ab31 100644 --- a/src/confkit/__init__.py +++ b/src/confkit/__init__.py @@ -28,7 +28,7 @@ TimeDelta, Tuple, ) -from .exceptions import InvalidConverterError, InvalidDefaultError +from .exceptions import ConfigPathConflictError, InvalidConverterError, InvalidDefaultError __all__ = [ "BaseDataType", @@ -36,6 +36,7 @@ "Boolean", "Config", "ConfigContainerMeta", + "ConfigPathConflictError", "Date", "DateTime", "Dict", diff --git a/src/confkit/exceptions.py b/src/confkit/exceptions.py index 76a06d8..1137043 100644 --- a/src/confkit/exceptions.py +++ b/src/confkit/exceptions.py @@ -6,3 +6,12 @@ class InvalidDefaultError(ValueError): class InvalidConverterError(ValueError): """Raised when the converter is not set or invalid.""" + + +class ConfigPathConflictError(ValueError): + """Raised when a configuration path conflicts with an existing scalar value. + + This occurs when attempting to treat a scalar value as a section (dict). + For example, if "Parent.Value" is a scalar, attempting to set "Parent.Value.Child" + would cause this error. + """ diff --git a/src/confkit/ext/parsers.py b/src/confkit/ext/parsers.py index b8f7ed8..ccdfb5d 100644 --- a/src/confkit/ext/parsers.py +++ b/src/confkit/ext/parsers.py @@ -8,6 +8,8 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar +from confkit.exceptions import ConfigPathConflictError + try: import msgspec import msgspec.json @@ -263,11 +265,17 @@ def _navigate_to_section(self, section: str, *, create: bool = False) -> NestedD Args: section: Dot-separated section path (e.g., "Parent.Child.GrandChild") - create: If True, create missing intermediate sections + create: If True, create missing intermediate sections and raise an error + if an intermediate path element is a scalar instead of a dict Returns: The nested dict at the section path, or None if not found and create=False + Raises: + ConfigPathConflictError: When create=True and an intermediate path element + is a scalar value instead of a dict. This prevents + silent data loss from overwriting scalars. + """ if not section: return self.data @@ -275,8 +283,16 @@ def _navigate_to_section(self, section: str, *, create: bool = False) -> NestedD parts = section.split(".") current = self.data - for part in parts: + for i, part in enumerate(parts): if not isinstance(current, dict): + if create: + path_so_far = ".".join(parts[:i]) + msg = ( + f"Cannot navigate to section '{section}': " + f"'{path_so_far}' is a scalar value, not a section. " + f"Path conflict at '{part}'." + ) + raise ConfigPathConflictError(msg) return None if part not in current: if create: @@ -284,6 +300,17 @@ def _navigate_to_section(self, section: str, *, create: bool = False) -> NestedD else: return None current = current[part] + # Check if we hit a scalar in the middle of the path + if i < len(parts) - 1 and not isinstance(current, dict): + if create: + path_so_far = ".".join(parts[: i + 1]) + msg = ( + f"Cannot navigate to section '{section}': " + f"'{path_so_far}' is a scalar value, not a section. " + f"Path conflict at '{parts[i + 1]}'." + ) + raise ConfigPathConflictError(msg) + return None return current if isinstance(current, dict) else None @@ -313,6 +340,7 @@ def get(self, section: str, option: str, fallback: str = UNSET) -> str: @override def set(self, section: str, option: str, value: str) -> None: + # This will raise ConfigPathConflictError if an intermediate path is a scalar section_data = self._navigate_to_section(section, create=True) if section_data is not None: # Try to preserve the original type by parsing the string value From cd50efaac2a0cdd5d523f82234112c8552fa6566 Mon Sep 17 00:00:00 2001 From: HEROgold Date: Mon, 2 Mar 2026 19:05:02 +0100 Subject: [PATCH 06/12] fix ruff issue --- src/confkit/config.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/confkit/config.py b/src/confkit/config.py index b1a7ad3..2f0a47e 100644 --- a/src/confkit/config.py +++ b/src/confkit/config.py @@ -132,9 +132,10 @@ def _initialize_data_type(self, default: VT | None | BaseDataType[VT]) -> None: def _read_parser(self) -> None: """Ensure the parser has read the file at initialization. Avoids rewriting the file when settings are already set.""" - if not self.__class__._has_read_config: + cls = self.__class__ + if not cls._has_read_config: self._parser.read(self._file) - self.__class__._has_read_config = True + cls._has_read_config = True def _validate_init(self) -> None: """Validate the config descriptor, ensuring it's properly set up.""" From a4d8c40cc8c1203c228080c647504bb53318bc17 Mon Sep 17 00:00:00 2001 From: HEROgold Date: Mon, 2 Mar 2026 19:05:22 +0100 Subject: [PATCH 07/12] ignore line len on deprecation warning --- src/confkit/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/confkit/config.py b/src/confkit/config.py index 2f0a47e..0ae8769 100644 --- a/src/confkit/config.py +++ b/src/confkit/config.py @@ -156,7 +156,7 @@ def _warn_base_class_usage() -> None: warnings.warn(" is the base class. Subclass to avoid unexpected behavior.", stacklevel=2) @classmethod - @deprecated("Avoid using set_parser. Confkit will automatically assign a parser based on the file extension. In 2.0 this will be a private method.") + @deprecated("Avoid using set_parser. Confkit will automatically assign a parser based on the file extension. In 2.0 this will be a private method.") # noqa: E501 def set_parser(cls, parser: ConfkitParser) -> None: """Set the parser for ALL descriptor instances (of this type/class).""" if cls is Config: From f72ea93f605117cd4d6dec5b2fc748138fe5c27a Mon Sep 17 00:00:00 2001 From: HEROgold Date: Mon, 2 Mar 2026 19:08:13 +0100 Subject: [PATCH 08/12] ruff fixes --- ruff.toml | 2 +- tests/test_config_classvars.py | 3 +-- tests/test_nested_config.py | 16 +++++----------- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/ruff.toml b/ruff.toml index 5484d43..1991169 100644 --- a/ruff.toml +++ b/ruff.toml @@ -4,7 +4,7 @@ line-length = 128 target-version = "py311" [lint.per-file-ignores] -"test_*.py" = ["S101", "D103", "SLF001", "D100","D101", "FBT003", "PLR2004", "N814"] +"test_*.py" = ["S101", "D103", "SLF001", "D100","D101", "FBT003", "PLR2004", "N814", "S105"] "examples/*.py" = ["ALL"] [format] diff --git a/tests/test_config_classvars.py b/tests/test_config_classvars.py index 6ce8d37..6d58d94 100644 --- a/tests/test_config_classvars.py +++ b/tests/test_config_classvars.py @@ -2,8 +2,6 @@ This also contains the test cases where specific settings are expected. """ -from confkit.ext.parsers import IniParser - import tempfile from collections.abc import Callable from pathlib import Path @@ -16,6 +14,7 @@ from confkit.config import Config as OG from confkit.data_types import BaseDataType, Optional, String from confkit.exceptions import InvalidConverterError, InvalidDefaultError +from confkit.ext.parsers import IniParser from confkit.sentinels import UNSET F = TypeVar("F") diff --git a/tests/test_nested_config.py b/tests/test_nested_config.py index 6010366..b8deb48 100644 --- a/tests/test_nested_config.py +++ b/tests/test_nested_config.py @@ -4,9 +4,13 @@ from typing import TYPE_CHECKING, TypeVar +import msgspec.json +import msgspec.toml +import msgspec.yaml import pytest from confkit.config import Config +from confkit.exceptions import ConfigPathConflictError from confkit.ext.parsers import IniParser, MsgspecParser if TYPE_CHECKING: @@ -97,7 +101,6 @@ class Credentials: # Read the file and verify structure with yaml_config_file.open("r") as f: - import msgspec.yaml data = msgspec.yaml.decode(f.read()) assert data["Database"]["host"] == "localhost" @@ -131,7 +134,6 @@ class Ports: # Read and verify JSON structure with json_config_file.open("r") as f: - import msgspec.json data = msgspec.json.decode(f.read()) assert data["Server"]["name"] == "web-server" @@ -160,7 +162,6 @@ class Settings: # Read and verify TOML structure with toml_config_file.open("r") as f: - import msgspec.toml data = msgspec.toml.decode(f.read()) assert data["App"]["version"] == "1.0.0" @@ -247,7 +248,6 @@ class Inner: # Verify structure with json_config_file.open("r") as f: - import msgspec.json data = msgspec.json.decode(f.read()) assert data["FlatSection"]["setting1"] == "value1" @@ -257,8 +257,6 @@ class Inner: def test_msgspec_parser_path_conflict_scalar_before_dict() -> None: """Test that ConfigPathConflictError is raised when a scalar blocks a section path.""" - from confkit.exceptions import ConfigPathConflictError - parser = MsgspecParser() parser.data = {} @@ -278,14 +276,12 @@ def test_msgspec_parser_path_conflict_scalar_before_dict() -> None: def test_msgspec_parser_path_conflict_deep_nesting() -> None: """Test path conflict detection in deeply nested structures.""" - from confkit.exceptions import ConfigPathConflictError - parser = MsgspecParser() parser.data = {} # Create a deeply nested structure with a scalar at the end parser.set("Level1.Level2", "Level3", "scalar_value") - # Result: {"Level1": {"Level2": {"Level3": "scalar_value"}}} + # Result {"Level1": {"Level2": {"Level3": "scalar_value"}}} # Try to treat the scalar "Level3" as a section with pytest.raises(ConfigPathConflictError) as exc_info: @@ -297,8 +293,6 @@ def test_msgspec_parser_path_conflict_deep_nesting() -> None: def test_msgspec_parser_set_section_conflicts() -> None: """Test that set_section also detects path conflicts.""" - from confkit.exceptions import ConfigPathConflictError - parser = MsgspecParser() parser.data = {} From 930d2724fc68aebad90fd7c4f03c224c7740e6d6 Mon Sep 17 00:00:00 2001 From: HEROgold Date: Mon, 2 Mar 2026 19:17:51 +0100 Subject: [PATCH 09/12] add missing .env parse detection --- src/confkit/config.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/confkit/config.py b/src/confkit/config.py index 0ae8769..ebaa259 100644 --- a/src/confkit/config.py +++ b/src/confkit/config.py @@ -14,7 +14,7 @@ from typing_extensions import deprecated -from confkit.ext.parsers import IniParser +from confkit.ext.parsers import EnvParser, IniParser from confkit.watcher import FileWatcher from .data_types import BaseDataType, Optional @@ -179,6 +179,8 @@ def _detect_parser(cls) -> None: case ".yaml" | ".yml" | ".json" | ".toml": from confkit.ext.parsers import MsgspecParser # noqa: PLC0415 Only import if actually used. cls._parser = MsgspecParser() + case ".env": + cls._parser = EnvParser() case _: msg = f"Unsupported config file extension: {cls._file.suffix.lower()}" raise ValueError(msg) From e0a2255d781ecb7af6c553f353ac757bfe8b59c2 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 19:57:53 +0100 Subject: [PATCH 10/12] Separate built-in parsers from msgspec optional dependency; fix MsgspecParser type handling and conflict detection (#54) * Initial plan * Move built-in parsers to confkit.parsers, fix type handling and conflict detection Co-authored-by: HEROgold <21345384+HEROgold@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: HEROgold <21345384+HEROgold@users.noreply.github.com> --- .github/copilot-instructions.md | 6 +- examples/nested_config.py | 3 - src/confkit/config.py | 7 +- src/confkit/ext/parsers.py | 271 +++++-------------------- src/confkit/parsers.py | 197 ++++++++++++++++++ tests/test_config.py | 2 +- tests/test_config_classvars.py | 2 +- tests/test_config_decorators.py | 2 +- tests/test_config_detect_parser.py | 3 +- tests/test_env_parser.py | 2 +- tests/test_metaclass.py | 2 +- tests/test_msgspecparser_no_msgspec.py | 14 +- tests/test_multiple_configurations.py | 2 +- tests/test_nested_config.py | 3 +- tests/test_pydantic_models.py | 2 +- tests/test_two_instances.py | 2 +- 16 files changed, 274 insertions(+), 246 deletions(-) create mode 100644 src/confkit/parsers.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index eb5e248..ba77574 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -11,10 +11,10 @@ confkit is a Python library for type-safe configuration management using descrip - `Config` descriptor (`config.py`): The main descriptor class that handles getting/setting values in config files - `ConfigContainerMeta` (`config.py`): Metaclass that enables setting Config descriptors on class variables - `BaseDataType` and implementations (`data_types.py`): Type converters for different data types -- `Parser` facade (`ext/parsers.py`): Unified facade for all configuration file formats (INI, JSON, YAML, TOML, .env) -- `IniParser` (`ext/parsers.py`): Adapter for Python's built-in ConfigParser (INI files) +- `ConfkitParser` protocol (`parsers.py`): Defines the unified parser interface for all configuration file formats (INI, JSON, YAML, TOML, .env) +- `IniParser` (`parsers.py`): Adapter for Python's built-in ConfigParser (INI files) - `MsgspecParser` (`ext/parsers.py`): Adapter for JSON, YAML, and TOML files using msgspec -- `EnvParser` (`ext/parsers.py`): Adapter for environment variables and .env files +- `EnvParser` (`parsers.py`): Adapter for environment variables and .env files - `sentinels.py`: Provides the `UNSET` sentinel value for representing unset values - `exceptions.py`: Custom exceptions for configuration errors - `watcher.py`: File watching functionality to detect config file changes diff --git a/examples/nested_config.py b/examples/nested_config.py index 7077901..84dc495 100644 --- a/examples/nested_config.py +++ b/examples/nested_config.py @@ -7,8 +7,6 @@ - TOML files (nested tables) - INI files (using dot notation in section names) """ -from confkit.ext.parsers import IniParser - from pathlib import Path from typing import TypeVar @@ -99,7 +97,6 @@ class IniConfig(Config[T]): ... -IniConfig.set_parser(IniParser()) IniConfig.set_file(Path("nested_example.ini")) diff --git a/src/confkit/config.py b/src/confkit/config.py index ebaa259..e85d180 100644 --- a/src/confkit/config.py +++ b/src/confkit/config.py @@ -14,7 +14,7 @@ from typing_extensions import deprecated -from confkit.ext.parsers import EnvParser, IniParser +from confkit.parsers import EnvParser, IniParser from confkit.watcher import FileWatcher from .data_types import BaseDataType, Optional @@ -25,7 +25,7 @@ from collections.abc import Callable from pathlib import Path - from confkit.ext.parsers import ConfkitParser + from confkit.parsers import ConfkitParser # Type variables for Python 3.10+ (pre-PEP 695) compatibility VT = TypeVar("VT") @@ -311,8 +311,7 @@ def _set(cls, section: str, setting: str, value: VT | BaseDataType[VT] | BaseDat if not cls._parser.has_section(section): cls._parser.add_section(section) - sanitized_str = cls._sanitize_str(str(value)) - cls._parser.set(section, setting, sanitized_str) + cls._parser.set(section, setting, value) if cls.write_on_edit: cls.write() diff --git a/src/confkit/ext/parsers.py b/src/confkit/ext/parsers.py index ccdfb5d..438e60f 100644 --- a/src/confkit/ext/parsers.py +++ b/src/confkit/ext/parsers.py @@ -1,14 +1,23 @@ -"""Parsers for Confkit configuration files.""" +"""Optional msgspec-based parsers for Confkit configuration files. + +This module requires the ``msgspec`` optional extra: + + pip install confkit[msgspec] + uv add confkit[msgspec] + +Importing this module without ``msgspec`` installed will raise an +``ImportError`` immediately. Built-in parsers (``IniParser``, +``EnvParser``, ``ConfkitParser``) that have no optional dependencies +live in ``confkit.parsers``. +""" from __future__ import annotations -import os import sys -from configparser import ConfigParser -from io import TextIOWrapper -from pathlib import Path from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar +from confkit.data_types import BaseDataType from confkit.exceptions import ConfigPathConflictError +from confkit.parsers import ConfkitParser try: import msgspec @@ -26,13 +35,13 @@ if sys.version_info >= (3, 12): - from typing import Protocol, override + from typing import override # TD: Use nested types when Python 3.11 is EOL and we can drop support for it # otherwise this gets syntax errors. # type NestedDict = dict[str, NestedDict | str | int | float | bool | None] # noqa: ERA001 NestedDict = dict[str, Any] else: - from typing_extensions import Protocol, override + from typing_extensions import override NestedDict = dict[str, Any] from confkit.sentinels import UNSET @@ -45,173 +54,6 @@ T = TypeVar("T") -class ConfkitParser(Protocol): - """A protocol for Confkit parsers.""" - - def read(self, file: Path) -> None: - """Read the configuration from a file.""" - def write(self, io: TextIOWrapper[_WrappedBuffer]) -> None: - """Write the configuration to a file-like object.""" - def has_section(self, section: str) -> bool: - """Check if a section exists.""" - def set_section(self, section: str) -> None: - """Set a section.""" - def set_option(self, option: str) -> None: - """Set an option.""" - def add_section(self, section: str) -> None: - """Add a section.""" - def has_option(self, section: str, option: str) -> bool: - """Check if an option exists within a section.""" - def remove_option(self, section: str, option: str) -> None: - """Remove an option from a section.""" - def get(self, section: str, option: str, fallback: str = UNSET) -> str: - """Get the value of an option within a section, with an optional fallback.""" - def set(self, section: str, option: str, value: str) -> None: - """Set the value of an option within a section.""" - - -class IniParser(ConfkitParser): - """Adapter for ConfigParser that supports dot notation for nested sections.""" - - def __init__(self) -> None: - """Initialize the IniParser with an internal ConfigParser instance.""" - self.parser = ConfigParser() - self._file: Path | None = None - - @override - def read(self, file: Path) -> None: - self.parser.read(file) - - @override - def write(self, io: TextIOWrapper) -> None: - self.parser.write(io) - - @override - def has_section(self, section: str) -> bool: - return self.parser.has_section(section) - - @override - def set_section(self, section: str) -> None: - if not self.parser.has_section(section): - self.parser.add_section(section) - - @override - def set_option(self, option: str) -> None: - # Not used directly; options are set via set() - pass - - @override - def add_section(self, section: str) -> None: - self.parser.add_section(section) - - @override - def has_option(self, section: str, option: str) -> bool: - return self.parser.has_option(section, option) - - @override - def remove_option(self, section: str, option: str) -> None: - self.parser.remove_option(section, option) - - @override - def get(self, section: str, option: str, fallback: str = UNSET) -> str: - return self.parser.get(section, option, fallback=fallback) - - @override - def set(self, section: str, option: str, value: str) -> None: - self.parser.set(section, option, value) - - -class EnvParser(ConfkitParser): - """A parser for environment variables and .env files. - - This parser operates without sections - all configuration is stored as flat key-value pairs. - Values are read from environment variables and optionally persisted to a .env file. - """ - - def __init__(self) -> None: # noqa: D107 - self.data: dict[str, str] = {} - - @override - def read(self, file: Path) -> None: - """Precedence, from lowest to highest. - - - config file - - environment vars - """ - self.data = dict(os.environ) - - if not file.exists(): - return - - with file.open("r", encoding="utf-8") as f: - for i in f: - line = i.strip() - if not line or line.startswith("#"): - continue - - match line.split("=", 1): - case [key, value]: - if key not in os.environ: - # Strip quotes from values - value = value.strip() - if (value.startswith('"') and value.endswith('"')) or \ - (value.startswith("'") and value.endswith("'")): - value = value[1:-1] - self.data[key.strip()] = value - - @override - def remove_option(self, section: str, option: str) -> None: - """Remove an option (section is ignored).""" - if option in self.data: - del self.data[option] - - @override - def get(self, section: str, option: str, fallback: str = UNSET) -> str: - """Get the value of an option (section is ignored).""" - if option in self.data: - return self.data[option] - if fallback is not UNSET: - return str(fallback) - return "" - - @override - def has_option(self, section: str, option: str) -> bool: - """Check if an option exists (section is ignored).""" - return option in self.data - - @override - def has_section(self, section: str) -> bool: - """EnvParser has no sections, always returns True for compatibility.""" - return True - - @override - def write(self, io: TextIOWrapper[_WrappedBuffer]) -> None: - """Write configuration to a .env file.""" - msg = "EnvParser does not support writing to .env" - raise NotImplementedError(msg) - - @override - def set_section(self, section: str) -> None: - """EnvParser has no sections, this is a no-op.""" - pass # noqa: PIE790 - - @override - def set_option(self, option: str) -> None: - """Set an option (not used in EnvParser).""" - msg = "EnvParser does not support set_option" - raise NotImplementedError(msg) - - @override - def add_section(self, section: str) -> None: - """EnvParser has no sections, this is a no-op.""" - pass # noqa: PIE790 - - @override - def set(self, section: str, option: str, value: str) -> None: - """Set the value of an option (section is ignored).""" - msg = "EnvParser does not support set" - raise NotImplementedError(msg) - class MsgspecParser(ConfkitParser, Generic[T]): """Unified msgspec-based parser for YAML, JSON, TOML configuration files.""" @@ -266,15 +108,15 @@ def _navigate_to_section(self, section: str, *, create: bool = False) -> NestedD Args: section: Dot-separated section path (e.g., "Parent.Child.GrandChild") create: If True, create missing intermediate sections and raise an error - if an intermediate path element is a scalar instead of a dict + if any path element is a scalar instead of a dict. Returns: The nested dict at the section path, or None if not found and create=False Raises: - ConfigPathConflictError: When create=True and an intermediate path element - is a scalar value instead of a dict. This prevents - silent data loss from overwriting scalars. + ConfigPathConflictError: When create=True and any path element (including + the final one) is a scalar value instead of a dict. + This prevents silent data loss from overwriting scalars. """ if not section: @@ -300,19 +142,26 @@ def _navigate_to_section(self, section: str, *, create: bool = False) -> NestedD else: return None current = current[part] - # Check if we hit a scalar in the middle of the path - if i < len(parts) - 1 and not isinstance(current, dict): + # Check if we hit a scalar anywhere in the path (including final element when create=True) + if not isinstance(current, dict): if create: path_so_far = ".".join(parts[: i + 1]) - msg = ( - f"Cannot navigate to section '{section}': " - f"'{path_so_far}' is a scalar value, not a section. " - f"Path conflict at '{parts[i + 1]}'." - ) + is_final = i == len(parts) - 1 + if is_final: + msg = ( + f"Cannot navigate to section '{section}': " + f"'{path_so_far}' is a scalar value, not a section." + ) + else: + msg = ( + f"Cannot navigate to section '{section}': " + f"'{path_so_far}' is a scalar value, not a section. " + f"Path conflict at '{parts[i + 1]}'." + ) raise ConfigPathConflictError(msg) return None - return current if isinstance(current, dict) else None + return current # guaranteed to be a dict here @override def has_section(self, section: str) -> bool: @@ -339,40 +188,24 @@ def get(self, section: str, option: str, fallback: str = UNSET) -> str: return str(section_data[option]) @override - def set(self, section: str, option: str, value: str) -> None: - # This will raise ConfigPathConflictError if an intermediate path is a scalar + def set(self, section: str, option: str, value: object) -> None: + # Raises ConfigPathConflictError if any path element is a scalar. section_data = self._navigate_to_section(section, create=True) - if section_data is not None: - # Try to preserve the original type by parsing the string value - # This is important for JSON/YAML/TOML which support native types - parsed_value = self._parse_value(value) - section_data[option] = parsed_value - - def _parse_value(self, value: str) -> bool | int | float | str: - """Parse a string value to its appropriate type for structured formats. - - Attempts to convert string values back to their original types: - - "True"/"False" -> bool - - Integer strings -> int - - Float strings -> float - - Everything else remains a string - """ - if value == "True": - return True - if value == "False": - return False - - try: - return int(value) - except ValueError: - pass - - try: - return float(value) - except ValueError: - pass - - return value + # _navigate_to_section always raises ConfigPathConflictError when create=True + # and the path is blocked, so section_data is guaranteed to be a dict here. + assert section_data is not None # noqa: S101 + if isinstance(value, BaseDataType): + native = value.value + # BaseDataType.__str__ returns str(self.value) by default. + # Subclasses with custom string representations (e.g. Hex returns "0xa", + # Octal returns "0o10") override __str__, causing str(native) != str(value). + # In those cases, store the custom string so convert() can round-trip + # correctly on the next read. For standard types the native Python value + # is stored directly, preserving native JSON/YAML/TOML types. + stored = native if str(native) == str(value) else str(value) + else: + stored = value + section_data[option] = stored @override def remove_option(self, section: str, option: str) -> None: diff --git a/src/confkit/parsers.py b/src/confkit/parsers.py new file mode 100644 index 0000000..b8a3d6f --- /dev/null +++ b/src/confkit/parsers.py @@ -0,0 +1,197 @@ +"""Built-in parsers for Confkit configuration files. + +This module provides parsers that have no optional dependencies: +- ``ConfkitParser``: Protocol defining the unified parser interface +- ``IniParser``: Adapter for Python's built-in ``ConfigParser`` (INI files) +- ``EnvParser``: Adapter for environment variables and ``.env`` files + +Parsers that require optional extras (e.g. ``MsgspecParser`` for JSON/YAML/TOML) +live in ``confkit.ext.parsers``. +""" +from __future__ import annotations + +import os +import sys +from configparser import ConfigParser +from typing import TYPE_CHECKING + +from confkit.sentinels import UNSET + +if sys.version_info >= (3, 12): + from typing import Protocol, override +else: + from typing_extensions import Protocol, override + +if TYPE_CHECKING: + from io import TextIOWrapper, _WrappedBuffer + from pathlib import Path + + +class ConfkitParser(Protocol): + """A protocol for Confkit parsers.""" + + def read(self, file: Path) -> None: + """Read the configuration from a file.""" + def write(self, io: TextIOWrapper[_WrappedBuffer]) -> None: + """Write the configuration to a file-like object.""" + def has_section(self, section: str) -> bool: + """Check if a section exists.""" + def set_section(self, section: str) -> None: + """Set a section.""" + def set_option(self, option: str) -> None: + """Set an option.""" + def add_section(self, section: str) -> None: + """Add a section.""" + def has_option(self, section: str, option: str) -> bool: + """Check if an option exists within a section.""" + def remove_option(self, section: str, option: str) -> None: + """Remove an option from a section.""" + def get(self, section: str, option: str, fallback: str = UNSET) -> str: + """Get the value of an option within a section, with an optional fallback.""" + def set(self, section: str, option: str, value: object) -> None: + """Set the value of an option within a section.""" + + +class IniParser(ConfkitParser): + """Adapter for ConfigParser that supports dot notation for nested sections.""" + + def __init__(self) -> None: + """Initialize the IniParser with an internal ConfigParser instance.""" + self.parser = ConfigParser() + self._file: Path | None = None + + @override + def read(self, file: Path) -> None: + self.parser.read(file) + + @override + def write(self, io: TextIOWrapper) -> None: + self.parser.write(io) + + @override + def has_section(self, section: str) -> bool: + return self.parser.has_section(section) + + @override + def set_section(self, section: str) -> None: + if not self.parser.has_section(section): + self.parser.add_section(section) + + @override + def set_option(self, option: str) -> None: + # Not used directly; options are set via set() + pass + + @override + def add_section(self, section: str) -> None: + self.parser.add_section(section) + + @override + def has_option(self, section: str, option: str) -> bool: + return self.parser.has_option(section, option) + + @override + def remove_option(self, section: str, option: str) -> None: + self.parser.remove_option(section, option) + + @override + def get(self, section: str, option: str, fallback: str = UNSET) -> str: + return self.parser.get(section, option, fallback=fallback) + + @override + def set(self, section: str, option: str, value: object) -> None: + # ConfigParser requires strings; escape % signs for interpolation + str_value = str(value).replace("%", "%%") + self.parser.set(section, option, str_value) + + +class EnvParser(ConfkitParser): + """A parser for environment variables and .env files. + + This parser operates without sections - all configuration is stored as flat key-value pairs. + Values are read from environment variables and optionally persisted to a .env file. + """ + + def __init__(self) -> None: # noqa: D107 + self.data: dict[str, str] = {} + + @override + def read(self, file: Path) -> None: + """Precedence, from lowest to highest. + + - config file + - environment vars + """ + self.data = dict(os.environ) + + if not file.exists(): + return + + with file.open("r", encoding="utf-8") as f: + for i in f: + line = i.strip() + if not line or line.startswith("#"): + continue + + match line.split("=", 1): + case [key, value]: + if key not in os.environ: + # Strip quotes from values + value = value.strip() + if (value.startswith('"') and value.endswith('"')) or \ + (value.startswith("'") and value.endswith("'")): + value = value[1:-1] + self.data[key.strip()] = value + + @override + def remove_option(self, section: str, option: str) -> None: + """Remove an option (section is ignored).""" + if option in self.data: + del self.data[option] + + @override + def get(self, section: str, option: str, fallback: str = UNSET) -> str: + """Get the value of an option (section is ignored).""" + if option in self.data: + return self.data[option] + if fallback is not UNSET: + return str(fallback) + return "" + + @override + def has_option(self, section: str, option: str) -> bool: + """Check if an option exists (section is ignored).""" + return option in self.data + + @override + def has_section(self, section: str) -> bool: + """EnvParser has no sections, always returns True for compatibility.""" + return True + + @override + def write(self, io: TextIOWrapper[_WrappedBuffer]) -> None: + """Write configuration to a .env file.""" + msg = "EnvParser does not support writing to .env" + raise NotImplementedError(msg) + + @override + def set_section(self, section: str) -> None: + """EnvParser has no sections, this is a no-op.""" + pass # noqa: PIE790 + + @override + def set_option(self, option: str) -> None: + """Set an option (not used in EnvParser).""" + msg = "EnvParser does not support set_option" + raise NotImplementedError(msg) + + @override + def add_section(self, section: str) -> None: + """EnvParser has no sections, this is a no-op.""" + pass # noqa: PIE790 + + @override + def set(self, section: str, option: str, value: object) -> None: + """Set the value of an option (section is ignored).""" + msg = "EnvParser does not support set" + raise NotImplementedError(msg) diff --git a/tests/test_config.py b/tests/test_config.py index 60dfa94..98817db 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -27,7 +27,7 @@ String, ) from confkit.exceptions import InvalidConverterError, InvalidDefaultError -from confkit.ext.parsers import IniParser +from confkit.parsers import IniParser class Config(OG): diff --git a/tests/test_config_classvars.py b/tests/test_config_classvars.py index 6d58d94..c46a2a2 100644 --- a/tests/test_config_classvars.py +++ b/tests/test_config_classvars.py @@ -14,7 +14,7 @@ from confkit.config import Config as OG from confkit.data_types import BaseDataType, Optional, String from confkit.exceptions import InvalidConverterError, InvalidDefaultError -from confkit.ext.parsers import IniParser +from confkit.parsers import IniParser from confkit.sentinels import UNSET F = TypeVar("F") diff --git a/tests/test_config_decorators.py b/tests/test_config_decorators.py index 6b7e762..dd55d18 100644 --- a/tests/test_config_decorators.py +++ b/tests/test_config_decorators.py @@ -12,7 +12,7 @@ from hypothesis import strategies as st from confkit.config import Config as OG -from confkit.ext.parsers import IniParser +from confkit.parsers import IniParser from confkit.sentinels import UNSET F = TypeVar("F") diff --git a/tests/test_config_detect_parser.py b/tests/test_config_detect_parser.py index 3adc033..cd5b622 100644 --- a/tests/test_config_detect_parser.py +++ b/tests/test_config_detect_parser.py @@ -4,7 +4,8 @@ import pytest from confkit.config import Config as OG -from confkit.ext.parsers import IniParser, MsgspecParser +from confkit.ext.parsers import MsgspecParser +from confkit.parsers import IniParser from confkit.sentinels import UNSET diff --git a/tests/test_env_parser.py b/tests/test_env_parser.py index 8a475a2..13ff9b0 100644 --- a/tests/test_env_parser.py +++ b/tests/test_env_parser.py @@ -7,7 +7,7 @@ import pytest -from confkit.ext.parsers import EnvParser +from confkit.parsers import EnvParser if TYPE_CHECKING: from pathlib import Path diff --git a/tests/test_metaclass.py b/tests/test_metaclass.py index 43f12cd..910178f 100644 --- a/tests/test_metaclass.py +++ b/tests/test_metaclass.py @@ -2,7 +2,7 @@ from confkit.config import Config as OG from confkit.config import ConfigContainerMeta -from confkit.ext.parsers import IniParser +from confkit.parsers import IniParser class Config(OG): diff --git a/tests/test_msgspecparser_no_msgspec.py b/tests/test_msgspecparser_no_msgspec.py index 0f70b54..7bf453b 100644 --- a/tests/test_msgspecparser_no_msgspec.py +++ b/tests/test_msgspecparser_no_msgspec.py @@ -1,4 +1,5 @@ """Test MsgspecParser behavior when msgspec is not installed.""" +import re import sys import pytest @@ -6,12 +7,11 @@ @pytest.mark.order("last") def test_msgspecparser_import_error(monkeypatch: pytest.MonkeyPatch) -> None: - # TD: match error msg to pytest.raises() - _ = ( - r"confkit.ext.parsers requires the optional 'msgspec' extra. " - r"Install it via 'pip install " - r"confkit[msgspec]' or 'uv add confkit[msgspec]'." - r"This is required for json, toml and yaml parsing." + expected_msg = ( + "confkit.ext.parsers requires the optional 'msgspec' extra. " + "Install it via 'pip install " + "confkit[msgspec]' or 'uv add confkit[msgspec]'." + "This is required for json, toml and yaml parsing." ) # Simulate msgspec not installed monkeypatch.setitem(sys.modules, "msgspec", None) @@ -19,6 +19,6 @@ def test_msgspecparser_import_error(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setitem(sys.modules, "msgspec.toml", None) monkeypatch.setitem(sys.modules, "msgspec.yaml", None) sys.modules.pop("confkit.ext.parsers", None) - with pytest.raises(ImportError): + with pytest.raises(ImportError, match=re.escape(expected_msg)): import confkit.ext.parsers # noqa: F401, PLC0415 diff --git a/tests/test_multiple_configurations.py b/tests/test_multiple_configurations.py index 417d226..b0925be 100644 --- a/tests/test_multiple_configurations.py +++ b/tests/test_multiple_configurations.py @@ -5,7 +5,7 @@ from hypothesis import strategies as st from confkit.config import Config -from confkit.ext.parsers import IniParser +from confkit.parsers import IniParser class Config1(Config): ... diff --git a/tests/test_nested_config.py b/tests/test_nested_config.py index b8deb48..0fcb8bc 100644 --- a/tests/test_nested_config.py +++ b/tests/test_nested_config.py @@ -11,7 +11,8 @@ from confkit.config import Config from confkit.exceptions import ConfigPathConflictError -from confkit.ext.parsers import IniParser, MsgspecParser +from confkit.ext.parsers import MsgspecParser +from confkit.parsers import IniParser if TYPE_CHECKING: from pathlib import Path diff --git a/tests/test_pydantic_models.py b/tests/test_pydantic_models.py index 2008fbd..3d0e5a0 100644 --- a/tests/test_pydantic_models.py +++ b/tests/test_pydantic_models.py @@ -9,7 +9,7 @@ from confkit.config import Config from confkit.data_types import List -from confkit.ext.parsers import IniParser +from confkit.parsers import IniParser from confkit.ext.pydantic import apply_model if TYPE_CHECKING: diff --git a/tests/test_two_instances.py b/tests/test_two_instances.py index b59fb60..9cdb2f0 100644 --- a/tests/test_two_instances.py +++ b/tests/test_two_instances.py @@ -2,7 +2,7 @@ from tempfile import TemporaryDirectory from confkit.config import Config -from confkit.ext.parsers import IniParser +from confkit.parsers import IniParser def test_two_instances_share_values_and_on_file_change_called() -> None: From 67933e6b4b0d3ae603a41f2471c8d5477ffa480a Mon Sep 17 00:00:00 2001 From: HEROgold Date: Mon, 2 Mar 2026 19:53:33 +0100 Subject: [PATCH 11/12] fix docstr --- src/confkit/config.py | 5 ----- src/confkit/ext/parsers.py | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/confkit/config.py b/src/confkit/config.py index e85d180..720bfc5 100644 --- a/src/confkit/config.py +++ b/src/confkit/config.py @@ -300,11 +300,6 @@ def __set__(self, obj: object, value: VT) -> None: cls._set(self._section, self._setting, self._data_type) setattr(obj, self.private, value) - @staticmethod - def _sanitize_str(value: str) -> str: - """Escape the percent sign in the value.""" - return value.replace("%", "%%") - @classmethod def _set(cls, section: str, setting: str, value: VT | BaseDataType[VT] | BaseDataType[VT | None]) -> None: """Set a config value, and write it to the file.""" diff --git a/src/confkit/ext/parsers.py b/src/confkit/ext/parsers.py index 438e60f..fff30f1 100644 --- a/src/confkit/ext/parsers.py +++ b/src/confkit/ext/parsers.py @@ -184,7 +184,7 @@ def add_section(self, section: str) -> None: def get(self, section: str, option: str, fallback: str = UNSET) -> str: section_data = self._navigate_to_section(section, create=False) if section_data is None or option not in section_data: - return str(fallback) if fallback is not UNSET else "" + return str(fallback) if fallback is not UNSET else UNSET return str(section_data[option]) @override From 459fdcf0d546345ea945f4a6891e990256c4b407 Mon Sep 17 00:00:00 2001 From: HEROgold Date: Mon, 2 Mar 2026 20:04:52 +0100 Subject: [PATCH 12/12] fix ruff --- tests/test_pydantic_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_pydantic_models.py b/tests/test_pydantic_models.py index 3d0e5a0..bad28e0 100644 --- a/tests/test_pydantic_models.py +++ b/tests/test_pydantic_models.py @@ -9,8 +9,8 @@ from confkit.config import Config from confkit.data_types import List -from confkit.parsers import IniParser from confkit.ext.pydantic import apply_model +from confkit.parsers import IniParser if TYPE_CHECKING: from collections.abc import Generator