From 4167ec5b351d10080ef8eae809f8a9f033288507 Mon Sep 17 00:00:00 2001 From: CrossNox Date: Thu, 17 Jul 2025 10:47:59 -0300 Subject: [PATCH 01/21] add typing --- mypy.ini | 13 ++++- setup.py | 3 + youconfigme/__init__.py | 9 +++ youconfigme/getpass.py | 12 +++- youconfigme/py.typed | 0 youconfigme/youconfigme.py | 113 ++++++++++++++++++++++++++---------- youconfigme/youconfigme.pyi | 84 +++++++++++++++++++++++++++ 7 files changed, 202 insertions(+), 32 deletions(-) create mode 100644 youconfigme/py.typed create mode 100644 youconfigme/youconfigme.pyi diff --git a/mypy.ini b/mypy.ini index 461b55e..ab8d791 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,4 +1,15 @@ [mypy] +python_version = 3.10 +warn_return_any = True +warn_unused_configs = True +disallow_untyped_defs = True +disallow_incomplete_defs = True +check_untyped_defs = True +disallow_any_generics = True +warn_redundant_casts = True +warn_unused_ignores = True +warn_unreachable = True +strict_equality = True +[mypy-toml.*] ignore_missing_imports = True -follow_imports = skip diff --git a/setup.py b/setup.py index 7834c9b..eb20301 100644 --- a/setup.py +++ b/setup.py @@ -43,5 +43,8 @@ def read_readme(): ], }, packages=["youconfigme"], + package_data={ + "youconfigme": ["py.typed", "*.pyi"], + }, classifiers=["Programming Language :: Python :: 3"], ) diff --git a/youconfigme/__init__.py b/youconfigme/__init__.py index 5243942..59fa037 100644 --- a/youconfigme/__init__.py +++ b/youconfigme/__init__.py @@ -1,6 +1,15 @@ """Entrypoint to make relevant classes available at the top level.""" +from typing import TYPE_CHECKING + from .youconfigme import AutoConfig, Config, ConfigItemNotFound, ConfigSection +if TYPE_CHECKING: + # Re-export for type checkers + from .youconfigme import AutoConfig as AutoConfig + from .youconfigme import Config as Config + from .youconfigme import ConfigItemNotFound as ConfigItemNotFound + from .youconfigme import ConfigSection as ConfigSection + __version__ = "1.0.0" __all__ = ["AutoConfig", "Config", "ConfigItemNotFound", "ConfigSection"] diff --git a/youconfigme/getpass.py b/youconfigme/getpass.py index c2e24fc..e8fd5c0 100644 --- a/youconfigme/getpass.py +++ b/youconfigme/getpass.py @@ -1,9 +1,19 @@ """Get values from GNU pass.""" import subprocess +from typing import Optional -def get_pass(key, section=None): +def get_pass(key: str, section: Optional[str] = None) -> str: + """Get password from GNU pass. + + Args: + key: The key to retrieve + section: Optional section prefix + + Returns: + The password value as a string + """ if section is not None: key = f"{section}/{key}" res = subprocess.run(["pass", key], capture_output=True, check=True) diff --git a/youconfigme/py.typed b/youconfigme/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/youconfigme/youconfigme.py b/youconfigme/youconfigme.py index 11ccee3..91c2862 100644 --- a/youconfigme/youconfigme.py +++ b/youconfigme/youconfigme.py @@ -10,20 +10,22 @@ import sys from configparser import ConfigParser from pathlib import Path +from typing import (Any, Callable, Dict, List, Mapping, Optional, TypeVar, + Union, overload) import toml as libtoml from youconfigme.getpass import get_pass -def config_logger(name): +def config_logger(name: str) -> logging.Logger: """Set a new logger. Args: name (str): name for the logger Returns: - logging.RootLogger: the configured logger + logging.Logger: the configured logger """ loglevel = ( os.environ.get("YOUCONFIGME_LOGLEVEL", os.environ.get("YCM_LOGLEVEL", "error")) @@ -61,6 +63,8 @@ def config_logger(name): DEFAULT_SEP = "_" # ENV_FILE = 'settings.env' +T = TypeVar("T") + class ConfigItemNotFound(Exception): """The config item could not be found.""" @@ -75,7 +79,13 @@ class ConfigAttribute: 3) default value """ - def __init__(self, name, value, section_name, sep=DEFAULT_SEP): + def __init__( + self, + name: str, + value: Optional[Any], + section_name: Optional[str], + sep: str = DEFAULT_SEP, + ) -> None: """Create a new attribute. Args: @@ -98,15 +108,45 @@ def __init__(self, name, value, section_name, sep=DEFAULT_SEP): if self.env is not None: self.env = str(self.env) - def __call__(self, default=None, cast=None, from_pass=False): + @overload + def __call__( + self, default: None = None, cast: None = None, from_pass: bool = False + ) -> str: + ... + + @overload + def __call__(self, default: T, cast: None = None, from_pass: bool = False) -> str: + ... + + @overload + def __call__( + self, + default: None = None, + cast: Callable[[str], T] = ..., + from_pass: bool = False, + ) -> T: + ... + + @overload + def __call__( + self, default: Any, cast: Callable[[str], T] = ..., from_pass: bool = False + ) -> T: + ... + + def __call__( + self, + default: Optional[Any] = None, + cast: Optional[Callable[[str], Any]] = None, + from_pass: bool = False, + ) -> Any: """Call the item. Follows the order of lookup. - Args: default (str): default value if item not found cast (callable): how to cast the item + from_pass (bool): whether to retrieve value from pass Returns: Any: A str or casted item @@ -117,7 +157,7 @@ def __call__(self, default=None, cast=None, from_pass=False): elif self.value is not None: retval = self.value elif default is not None: - retval = default + retval = str(default) else: err_str = f"Configuration item {self.name}" if self.section_name is not None: @@ -130,7 +170,7 @@ def __call__(self, default=None, cast=None, from_pass=False): return (cast or str)(retval) - def __getattr__(self, name): + def __getattr__(self, name: str) -> None: """Get attr that does not exist.""" raise ConfigItemNotFound(f"section {name} not found") @@ -138,7 +178,9 @@ def __getattr__(self, name): class ConfigSection: """A section from a Config item.""" - def __init__(self, name, items, sep=DEFAULT_SEP): + def __init__( + self, name: str, items: Optional[Mapping[str, Any]], sep: str = DEFAULT_SEP + ) -> None: """Create a new ConfigSection. Args: @@ -151,17 +193,19 @@ def __init__(self, name, items, sep=DEFAULT_SEP): self.sep = sep self.prefix = f"{self.name}{self.sep}".upper() - def __getattr__(self, val): + def __getattr__(self, val: str) -> ConfigAttribute: """Get a new attribute.""" return ConfigAttribute(val, self.items.get(val), self.name, sep=self.sep) - def __call__(self, default=None, cast=None): + def __call__( + self, default: Optional[Any] = None, cast: Optional[Callable[[str], Any]] = None + ) -> Any: """Get attribute called as section.""" return ConfigAttribute(self.name, None, None, sep=self.sep)( default=default, cast=cast ) - def to_dict(self): + def to_dict(self) -> Dict[str, str]: """Return as dict. Args: @@ -171,7 +215,7 @@ def to_dict(self): dict: all the key:value pairs from the initial mapping, neglecting environment variables not present there. """ - items = self.items + items = dict(self.items) env_items = { envvar[len(self.prefix) :].lower(): envval # noqa: E203 for envvar, envval in os.environ.items() @@ -184,12 +228,18 @@ def to_dict(self): return ret_dict +FromItemsType = Union[str, Path, Mapping[str, Any], None] + + class Config: """Base Config item.""" def __init__( - self, from_items=INI_FILE, default_section=DEFAULT_SECTION, sep=DEFAULT_SEP - ): + self, + from_items: FromItemsType = INI_FILE, + default_section: str = DEFAULT_SECTION, + sep: str = DEFAULT_SEP, + ) -> None: """Create a new Config item. Args: @@ -201,19 +251,19 @@ def __init__( default_section (str): config items that need not be under a section sep (str): string to separate sections from items in env vars. """ - self.sep = sep - self.default_section = default_section - self.fake_default_section = "None" if default_section != "None" else "enoN" - self.config_sections = [] - self.config_attributes = [] + self.sep: str = sep + self.default_section: str = default_section + self.fake_default_section: str = "None" if default_section != "None" else "enoN" + self.config_sections: List[str] = [] + self.config_attributes: List[str] = [] if from_items is not None: try: - self._init_from_mapping(from_items) + self._init_from_mapping(from_items) # type: ignore except AttributeError: - self._init_from_str(from_items) + self._init_from_str(from_items) # type: ignore - def _init_from_mapping(self, mapping): + def _init_from_mapping(self, mapping: Mapping[str, Any]) -> None: for section in mapping.keys(): if section == self.fake_default_section: continue @@ -227,14 +277,14 @@ def _init_from_mapping(self, mapping): setattr(self, k, ConfigAttribute(k, v, None, sep=self.sep)) self.config_attributes.append(k) - def _init_from_str(self, str_like): + def _init_from_str(self, str_like: Union[str, Path]) -> None: try: - buf = io.StringIO(str_like) + buf = io.StringIO(str(str_like)) config_parser = ConfigParser(default_section=self.fake_default_section) config_parser.read_file(buf) self._init_from_mapping(config_parser) except Exception as e: # pylint: disable=broad-except - cwd_file = Path.cwd() / str_like + cwd_file = Path.cwd() / str(str_like) if cwd_file.is_file() and cwd_file.suffix == ".ini": config_parser = ConfigParser(default_section=self.fake_default_section) config_parser.read(cwd_file) @@ -245,11 +295,11 @@ def _init_from_str(self, str_like): else: raise FileNotFoundError from e - def __getattr__(self, name): + def __getattr__(self, name: str) -> ConfigSection: """Get new section.""" return ConfigSection(name, None, self.sep) - def to_dict(self): + def to_dict(self) -> Dict[str, Any]: """Return as dict. Args: @@ -259,7 +309,7 @@ def to_dict(self): dict: all the key:value pairs from the initial mapping, neglecting environment variables not present there. """ - ret_dict = {} + ret_dict: Dict[str, Any] = {} for section in self.config_sections: ret_dict[section] = self.__getattribute__(section).to_dict() for attribute in self.config_attributes: @@ -268,7 +318,7 @@ def to_dict(self): def to_dotenv(self) -> str: """Return as .env file""" - lines = [] + lines: List[str] = [] for k, v in self.to_dict().items(): try: for k1, v1 in v.items(): @@ -285,13 +335,16 @@ class AutoConfig(Config): # pylint: disable=too-few-public-methods empty Config file that can be used with defaults and/or env vars. """ - def __init__(self, max_up_levels=1, filename=INI_FILE, sep=DEFAULT_SEP): + def __init__( + self, max_up_levels: int = 1, filename: str = INI_FILE, sep: str = DEFAULT_SEP + ) -> None: """Create a new AutoConfig item. Args: max_up_levels (int): how many parents should it traverse searching for an `ini` file filename (str): filename to search for + sep (str): string to separate sections from items in env vars. """ frame = sys._getframe() settings_file = Path(frame.f_back.f_code.co_filename).parent / filename diff --git a/youconfigme/youconfigme.pyi b/youconfigme/youconfigme.pyi new file mode 100644 index 0000000..2299d4c --- /dev/null +++ b/youconfigme/youconfigme.pyi @@ -0,0 +1,84 @@ +"""Type stubs for youconfigme""" + +import logging +from pathlib import Path +from typing import (Any, Callable, Dict, List, Mapping, Optional, TypeVar, + Union, overload) + +T = TypeVar("T") + +DEFAULT_SECTION: str +INI_FILE: str +DEFAULT_SEP: str + +def config_logger(name: str) -> logging.Logger: ... + +class ConfigItemNotFound(Exception): ... + +class ConfigAttribute: + name: str + value: Optional[str] + section_name: Optional[str] + env_str: str + env: Optional[str] + + def __init__( + self, + name: str, + value: Optional[Any], + section_name: Optional[str], + sep: str = ..., + ) -> None: ... + @overload + def __call__( + self, default: None = ..., cast: None = ..., from_pass: bool = ... + ) -> str: ... + @overload + def __call__(self, default: T, cast: None = ..., from_pass: bool = ...) -> str: ... + @overload + def __call__( + self, default: None = ..., cast: Callable[[str], T] = ..., from_pass: bool = ... + ) -> T: ... + @overload + def __call__( + self, default: Any, cast: Callable[[str], T] = ..., from_pass: bool = ... + ) -> T: ... + +class ConfigSection: + name: str + items: Dict[str, Any] + sep: str + prefix: str + + def __init__( + self, name: str, items: Optional[Mapping[str, Any]], sep: str = ... + ) -> None: ... + def __getattr__(self, val: str) -> ConfigAttribute: ... + def __call__( + self, default: Optional[Any] = ..., cast: Optional[Callable[[str], Any]] = ... + ) -> Any: ... + def to_dict(self) -> Dict[str, str]: ... + +FromItemsType = Union[str, Path, Mapping[str, Any], None] + +class Config: + sep: str + default_section: str + fake_default_section: str + config_sections: List[str] + config_attributes: List[str] + + def __init__( + self, + from_items: FromItemsType = ..., + default_section: str = ..., + sep: str = ..., + ) -> None: ... + def __getattr__(self, name: str) -> ConfigSection: ... + def to_dict(self) -> Dict[str, Any]: ... + def to_dotenv(self) -> str: ... + +class AutoConfig(Config): + def __init__( + self, max_up_levels: int = ..., filename: str = ..., sep: str = ... + ) -> None: ... From c18c80b263a4c4b5eb6d084e32a77bfb00c5ac32 Mon Sep 17 00:00:00 2001 From: CrossNox Date: Thu, 17 Jul 2025 12:12:10 -0300 Subject: [PATCH 02/21] add tests --- mypy.ini | 9 ++- setup.py | 7 +- tests/test_typing.py | 157 +++++++++++++++++++++++++++++++++++++ youconfigme/youconfigme.py | 33 +++++--- 4 files changed, 190 insertions(+), 16 deletions(-) create mode 100644 tests/test_typing.py diff --git a/mypy.ini b/mypy.ini index ab8d791..e2b4c46 100644 --- a/mypy.ini +++ b/mypy.ini @@ -10,6 +10,11 @@ warn_redundant_casts = True warn_unused_ignores = True warn_unreachable = True strict_equality = True +show_error_codes = True -[mypy-toml.*] -ignore_missing_imports = True +# Ignore all test files by default +[mypy-tests.*] +ignore_errors = True + +[mypy-tests.test_typing] +ignore_errors = False diff --git a/setup.py b/setup.py index eb20301..5bf0c5a 100644 --- a/setup.py +++ b/setup.py @@ -5,15 +5,16 @@ from setuptools import setup -def get_version(): +def get_version() -> str: init_f = Path(__file__).parent / "youconfigme" / "__init__.py" with open(init_f) as f: for line in f: if "__version__" in line: return line.split("=")[-1].strip().strip('"') + raise ValueError("Version not found") -def read_readme(): +def read_readme() -> str: readme_f = Path(__file__).parent / "README.md" with open(readme_f) as f: return f.read() @@ -40,6 +41,8 @@ def read_readme(): "bump", "nox", "types-toml", + "pytest-mypy-testing", + "types-setuptools", ], }, packages=["youconfigme"], diff --git a/tests/test_typing.py b/tests/test_typing.py new file mode 100644 index 0000000..4e5619c --- /dev/null +++ b/tests/test_typing.py @@ -0,0 +1,157 @@ +"""Type checking tests for youconfigme using pytest-mypy-testing.""" + +# pylint: disable=redefined-outer-name +# pylint: disable=missing-function-docstring +# pylint: disable=unused-variable + +from typing import Any, Dict, List + +import pytest +from youconfigme import Config, ConfigSection + +# fmt: off + + +@pytest.mark.mypy_testing +def test_correct_basic_usage() -> None: + """Test correct basic usage - should produce no errors.""" + config = Config(from_items={"db": {"host": "localhost", "port": "5432"}}) + + # These should all work without errors + host: str = config.db.host() + assert host == "localhost" + port_str: str = config.db.port() + assert port_str == "5432" + port_int: int = config.db.port(cast=int) + assert port_int == 5432 + + # With defaults + timeout: str = config.db.timeout(default="30") + assert timeout == "30" + timeout_int: int = config.db.timeout(default="30", cast=int) + assert timeout_int == 5432 + timeout_int_2: int = config.db.timeout(default=30, cast=int) + assert timeout_int_2 == 5432 + + # Correct casting functions + def to_int(value: str) -> int: + return int(value) + + timeout_int_3: int = config.db.port(default="30", cast=to_int) + assert timeout_int_3 == 5432 + timeout_int_4: int = config.db.port(default=30, cast=to_int) + assert timeout_int_4 == 5432 + timeout_int_5: int = config.db.port(cast=to_int) + assert timeout_int_5 == 5432 + + +@pytest.mark.mypy_testing +def test_wrong_type_assignment_without_cast() -> None: + """Test wrong type assignment without cast - should produce error.""" + config = Config(from_items={"test": {"value": "123"}}) + + # This should produce a type error + wrong: int = config.test.value() # type: ignore # E: Incompatible types in assignment (expression has type "str", variable has type "int") [assignment] + assert wrong == "123" # type: ignore + + +@pytest.mark.mypy_testing +def test_invalid_cast_function_signature() -> None: + """Test cast function with wrong input type - should produce error.""" + config = Config(from_items={"test": {"value": "123"}}) + + # Cast function should take str, not int + def bad_cast(value: int) -> str: + return str(value) + + # This should produce error about incompatible argument type + result = config.test.value(cast=bad_cast) # type: ignore # E: Argument "cast" to "__call__" of "ConfigAttribute" has incompatible type "Callable[[int], str]"; expected "Callable[[str], str]" [arg-type] + + assert result == "123" + + +@pytest.mark.mypy_testing +def test_cast_return_type_mismatch() -> None: + """Test mismatch between cast return type and variable type.""" + config = Config(from_items={"test": {"items": "a,b,c"}}) + + def parse_to_list(value: str) -> List[str]: + return value.split(",") + + # Expecting List[int] but cast returns List[str] + numbers: List[int] = config.test.items(cast=parse_to_list) # type: ignore # E: Incompatible types in assignment (expression has type "List[str]", variable has type "List[int]") [assignment] + assert numbers == ["a", "b", "c"] # type: ignore + + +@pytest.mark.mypy_testing +def test_bad_default_type_without_cast() -> None: + """Test that default without cast doesn't convert types.""" + config = Config(from_items={}) + + # Default is string "100", without cast it stays string + port: int = config.server.port(default="100") # type: ignore # E: Incompatible types in assignment (expression has type "str", variable has type "int") [assignment] + assert port == "100" # type: ignore + + # This works because we use cast + port_ok: int = config.server.port(default="100", cast=int) + assert port_ok == 100 + + +@pytest.mark.mypy_testing +def test_correct_custom_cast_functions() -> None: + """Test various custom cast functions work correctly.""" + config = Config(from_items={ + "app": { + "debug": "true", + "features": "auth,api,admin", + "max_connections": "100" + } + }) + + def str_to_bool(value: str) -> bool: + return value.lower() in ("true", "1", "yes") + + def str_to_list(value: str) -> List[str]: + return [item.strip() for item in value.split(",")] + + # These should all type check correctly + debug: bool = config.app.debug(cast=str_to_bool) + assert debug is True + features: List[str] = config.app.features(cast=str_to_list) + assert features == ["auth", "api", "admin"] + connections: int = config.app.max_connections(cast=int) + assert connections == 100 + + +@pytest.mark.mypy_testing +def test_config_section_typing() -> None: + """Test ConfigSection type and methods.""" + config = Config(from_items={"cache": {"ttl": "3600", "size": "1000"}}) + + # ConfigSection access + cache_section: ConfigSection = config.cache + + # to_dict returns Dict[str, str] + cache_dict: Dict[str, str] = cache_section.to_dict() + assert cache_dict == {"cache_ttl": "3600", "cache_size": "1000"} + + # Wrong type for to_dict + wrong_dict: Dict[str, int] = cache_section.to_dict() # type: ignore # E: Incompatible types in assignment (expression has type "dict[str, str]", variable has type "dict[str, int]") [assignment] + assert wrong_dict == {"cache_ttl": "3600", "cache_size": "1000"} # type: ignore + + +@pytest.mark.mypy_testing +def test_config_to_dict_return_type() -> None: + """Test Config.to_dict() return type.""" + config = Config(from_items={ + "server": {"host": "0.0.0.0", "port": "8080"}, + "db": {"name": "mydb"} + }) + + # to_dict returns Dict[str, str] + config_dict: Dict[str, str] = config.to_dict() + assert config_dict == {"server_host": "0.0.0.0", "server_port": "8080", "db_name": "mydb"} + + # This is too wide + wide_dict: Dict[str, Any] = config.to_dict() # E: Incompatible types in assignment (expression has type "Dict[str, Any]", variable has type "Dict[str, str]") [assignment] + assert wide_dict == {"server_host": "0.0.0.0", "server_port": "8080", "db_name": "mydb"} diff --git a/youconfigme/youconfigme.py b/youconfigme/youconfigme.py index 91c2862..af7dcc6 100644 --- a/youconfigme/youconfigme.py +++ b/youconfigme/youconfigme.py @@ -10,8 +10,17 @@ import sys from configparser import ConfigParser from pathlib import Path -from typing import (Any, Callable, Dict, List, Mapping, Optional, TypeVar, - Union, overload) +from typing import ( + Any, + Callable, + Dict, + List, + Mapping, + Optional, + TypeVar, + Union, + overload, +) import toml as libtoml @@ -98,7 +107,7 @@ def __init__( self.value = value self.section_name = section_name if self.section_name is not None: - self.env_str = f"{section_name.upper()}{sep}{name.upper()}" + self.env_str = f"{self.section_name.upper()}{sep}{name.upper()}" else: self.env_str = f"{name.upper()}" logger.debug("Try to get env_str: %s", self.env_str) @@ -111,12 +120,12 @@ def __init__( @overload def __call__( self, default: None = None, cast: None = None, from_pass: bool = False - ) -> str: - ... + ) -> str: ... @overload - def __call__(self, default: T, cast: None = None, from_pass: bool = False) -> str: - ... + def __call__( + self, default: T, cast: None = None, from_pass: bool = False + ) -> str: ... @overload def __call__( @@ -124,14 +133,12 @@ def __call__( default: None = None, cast: Callable[[str], T] = ..., from_pass: bool = False, - ) -> T: - ... + ) -> T: ... @overload def __call__( self, default: Any, cast: Callable[[str], T] = ..., from_pass: bool = False - ) -> T: - ... + ) -> T: ... def __call__( self, @@ -151,7 +158,7 @@ def __call__( Returns: Any: A str or casted item """ - retval = None + retval: Any if self.env is not None: retval = self.env elif self.value is not None: @@ -347,6 +354,8 @@ def __init__( sep (str): string to separate sections from items in env vars. """ frame = sys._getframe() + if frame.f_back is None: + raise ValueError("No caller frame") settings_file = Path(frame.f_back.f_code.co_filename).parent / filename for _ in range(max_up_levels + 1): try: From a346bd1e4cc20b38e82b7f4829721bba98f1af67 Mon Sep 17 00:00:00 2001 From: CrossNox Date: Thu, 17 Jul 2025 12:33:26 -0300 Subject: [PATCH 03/21] commit --- .flake8 | 3 +++ .pre-commit-config.yaml | 2 +- mypy.ini | 7 ------- tests/test_typing.py | 33 ++++++--------------------------- 4 files changed, 10 insertions(+), 35 deletions(-) create mode 100644 .flake8 diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..b59ec90 --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] +per-file-ignores = + tests/test_typing.py: E501,F841 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 51ea7ea..d23fe5f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -32,7 +32,7 @@ repos: entry: mypy language: system types: [python] - exclude: docs + exclude: docs|tests/test_typing\.py - id: pylint name: pylint entry: pylint diff --git a/mypy.ini b/mypy.ini index e2b4c46..a81f397 100644 --- a/mypy.ini +++ b/mypy.ini @@ -11,10 +11,3 @@ warn_unused_ignores = True warn_unreachable = True strict_equality = True show_error_codes = True - -# Ignore all test files by default -[mypy-tests.*] -ignore_errors = True - -[mypy-tests.test_typing] -ignore_errors = False diff --git a/tests/test_typing.py b/tests/test_typing.py index 4e5619c..74c48a8 100644 --- a/tests/test_typing.py +++ b/tests/test_typing.py @@ -19,30 +19,22 @@ def test_correct_basic_usage() -> None: # These should all work without errors host: str = config.db.host() - assert host == "localhost" port_str: str = config.db.port() - assert port_str == "5432" port_int: int = config.db.port(cast=int) - assert port_int == 5432 # With defaults timeout: str = config.db.timeout(default="30") - assert timeout == "30" timeout_int: int = config.db.timeout(default="30", cast=int) - assert timeout_int == 5432 timeout_int_2: int = config.db.timeout(default=30, cast=int) - assert timeout_int_2 == 5432 # Correct casting functions + def to_int(value: str) -> int: return int(value) timeout_int_3: int = config.db.port(default="30", cast=to_int) - assert timeout_int_3 == 5432 timeout_int_4: int = config.db.port(default=30, cast=to_int) - assert timeout_int_4 == 5432 timeout_int_5: int = config.db.port(cast=to_int) - assert timeout_int_5 == 5432 @pytest.mark.mypy_testing @@ -51,8 +43,7 @@ def test_wrong_type_assignment_without_cast() -> None: config = Config(from_items={"test": {"value": "123"}}) # This should produce a type error - wrong: int = config.test.value() # type: ignore # E: Incompatible types in assignment (expression has type "str", variable has type "int") [assignment] - assert wrong == "123" # type: ignore + wrong: int = config.test.value() # E: Incompatible types in assignment (expression has type "str", variable has type "int") [assignment] @pytest.mark.mypy_testing @@ -65,9 +56,7 @@ def bad_cast(value: int) -> str: return str(value) # This should produce error about incompatible argument type - result = config.test.value(cast=bad_cast) # type: ignore # E: Argument "cast" to "__call__" of "ConfigAttribute" has incompatible type "Callable[[int], str]"; expected "Callable[[str], str]" [arg-type] - - assert result == "123" + result = config.test.value(cast=bad_cast) # E: Argument "cast" to "__call__" of "ConfigAttribute" has incompatible type "Callable[[int], str]"; expected "Callable[[str], str]" [arg-type] @pytest.mark.mypy_testing @@ -79,8 +68,7 @@ def parse_to_list(value: str) -> List[str]: return value.split(",") # Expecting List[int] but cast returns List[str] - numbers: List[int] = config.test.items(cast=parse_to_list) # type: ignore # E: Incompatible types in assignment (expression has type "List[str]", variable has type "List[int]") [assignment] - assert numbers == ["a", "b", "c"] # type: ignore + numbers: List[int] = config.test.items(cast=parse_to_list) # E: Incompatible types in assignment (expression has type "List[str]", variable has type "List[int]") [assignment] @pytest.mark.mypy_testing @@ -89,12 +77,10 @@ def test_bad_default_type_without_cast() -> None: config = Config(from_items={}) # Default is string "100", without cast it stays string - port: int = config.server.port(default="100") # type: ignore # E: Incompatible types in assignment (expression has type "str", variable has type "int") [assignment] - assert port == "100" # type: ignore + port: int = config.server.port(default="100") # E: Incompatible types in assignment (expression has type "str", variable has type "int") [assignment] # This works because we use cast port_ok: int = config.server.port(default="100", cast=int) - assert port_ok == 100 @pytest.mark.mypy_testing @@ -116,11 +102,8 @@ def str_to_list(value: str) -> List[str]: # These should all type check correctly debug: bool = config.app.debug(cast=str_to_bool) - assert debug is True features: List[str] = config.app.features(cast=str_to_list) - assert features == ["auth", "api", "admin"] connections: int = config.app.max_connections(cast=int) - assert connections == 100 @pytest.mark.mypy_testing @@ -133,11 +116,9 @@ def test_config_section_typing() -> None: # to_dict returns Dict[str, str] cache_dict: Dict[str, str] = cache_section.to_dict() - assert cache_dict == {"cache_ttl": "3600", "cache_size": "1000"} # Wrong type for to_dict - wrong_dict: Dict[str, int] = cache_section.to_dict() # type: ignore # E: Incompatible types in assignment (expression has type "dict[str, str]", variable has type "dict[str, int]") [assignment] - assert wrong_dict == {"cache_ttl": "3600", "cache_size": "1000"} # type: ignore + wrong_dict: Dict[str, int] = cache_section.to_dict() # E: Incompatible types in assignment (expression has type "dict[str, str]", variable has type "dict[str, int]") [assignment] @pytest.mark.mypy_testing @@ -150,8 +131,6 @@ def test_config_to_dict_return_type() -> None: # to_dict returns Dict[str, str] config_dict: Dict[str, str] = config.to_dict() - assert config_dict == {"server_host": "0.0.0.0", "server_port": "8080", "db_name": "mydb"} # This is too wide wide_dict: Dict[str, Any] = config.to_dict() # E: Incompatible types in assignment (expression has type "Dict[str, Any]", variable has type "Dict[str, str]") [assignment] - assert wide_dict == {"server_host": "0.0.0.0", "server_port": "8080", "db_name": "mydb"} From 3f697d07eb88587128c0bd696bc416934d0060a0 Mon Sep 17 00:00:00 2001 From: CrossNox Date: Thu, 17 Jul 2025 12:53:34 -0300 Subject: [PATCH 04/21] commit --- youconfigme/__init__.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/youconfigme/__init__.py b/youconfigme/__init__.py index 59fa037..5243942 100644 --- a/youconfigme/__init__.py +++ b/youconfigme/__init__.py @@ -1,15 +1,6 @@ """Entrypoint to make relevant classes available at the top level.""" -from typing import TYPE_CHECKING - from .youconfigme import AutoConfig, Config, ConfigItemNotFound, ConfigSection -if TYPE_CHECKING: - # Re-export for type checkers - from .youconfigme import AutoConfig as AutoConfig - from .youconfigme import Config as Config - from .youconfigme import ConfigItemNotFound as ConfigItemNotFound - from .youconfigme import ConfigSection as ConfigSection - __version__ = "1.0.0" __all__ = ["AutoConfig", "Config", "ConfigItemNotFound", "ConfigSection"] From 164d4cf0bcbfd7c631ccbd2c9e29b22eaac1cf9f Mon Sep 17 00:00:00 2001 From: CrossNox Date: Thu, 17 Jul 2025 12:55:24 -0300 Subject: [PATCH 05/21] commit --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d23fe5f..b8444f5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -32,7 +32,7 @@ repos: entry: mypy language: system types: [python] - exclude: docs|tests/test_typing\.py + exclude: docs/.*|tests/.* - id: pylint name: pylint entry: pylint From 4711ea3e4f822a6f269285545af2f7a0438728ea Mon Sep 17 00:00:00 2001 From: CrossNox Date: Thu, 17 Jul 2025 12:59:47 -0300 Subject: [PATCH 06/21] commit --- noxfile.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/noxfile.py b/noxfile.py index cbcd7b8..d45f074 100644 --- a/noxfile.py +++ b/noxfile.py @@ -4,7 +4,7 @@ @nox.session(reuse_venv=True, python="3.8") -def cop(session): +def cop(session): # type: ignore """Run all pre-commit hooks.""" session.install(".") session.install(".[dev,test]") @@ -14,7 +14,7 @@ def cop(session): @nox.session(reuse_venv=True, python="3.8") -def tests(session): +def tests(session) -> None: # type: ignore """Run all tests.""" session.install(".") session.install(".[test]") From 3977bde65dfacfd1013eeed7df40ad45ae51612b Mon Sep 17 00:00:00 2001 From: CrossNox Date: Thu, 17 Jul 2025 13:01:07 -0300 Subject: [PATCH 07/21] commit --- setup.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 5bf0c5a..d73ef95 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ def read_readme() -> str: author="CrossNox", install_requires=["toml"], extras_require={ - "test": ["pytest"], + "test": ["pytest", "pytest-mypy-testing"], "dev": [ "pre-commit", "mypy", @@ -41,7 +41,6 @@ def read_readme() -> str: "bump", "nox", "types-toml", - "pytest-mypy-testing", "types-setuptools", ], }, From 683e83d9b34b9fad8430c98bf3c9b8b16e68de45 Mon Sep 17 00:00:00 2001 From: CrossNox Date: Thu, 17 Jul 2025 13:39:32 -0300 Subject: [PATCH 08/21] add --- tests/test_typing.py | 7 +-- youconfigme/__init__.py | 2 +- youconfigme/cast.py | 17 ++++++ youconfigme/youconfigme.py | 113 +++++++++++++++++------------------- youconfigme/youconfigme.pyi | 2 +- 5 files changed, 75 insertions(+), 66 deletions(-) create mode 100644 youconfigme/cast.py diff --git a/tests/test_typing.py b/tests/test_typing.py index 74c48a8..04343cf 100644 --- a/tests/test_typing.py +++ b/tests/test_typing.py @@ -129,8 +129,5 @@ def test_config_to_dict_return_type() -> None: "db": {"name": "mydb"} }) - # to_dict returns Dict[str, str] - config_dict: Dict[str, str] = config.to_dict() - - # This is too wide - wide_dict: Dict[str, Any] = config.to_dict() # E: Incompatible types in assignment (expression has type "Dict[str, Any]", variable has type "Dict[str, str]") [assignment] + # to_dict returns Dict[str, Dict[str, str]] + config_dict: Dict[str, Dict[str, str]] = config.to_dict() diff --git a/youconfigme/__init__.py b/youconfigme/__init__.py index 5243942..dfa353d 100644 --- a/youconfigme/__init__.py +++ b/youconfigme/__init__.py @@ -2,5 +2,5 @@ from .youconfigme import AutoConfig, Config, ConfigItemNotFound, ConfigSection -__version__ = "1.0.0" +__version__ = "1.1.0" __all__ = ["AutoConfig", "Config", "ConfigItemNotFound", "ConfigSection"] diff --git a/youconfigme/cast.py b/youconfigme/cast.py new file mode 100644 index 0000000..3398ceb --- /dev/null +++ b/youconfigme/cast.py @@ -0,0 +1,17 @@ +"""Common casts""" + +from typing import Union + + +def to_bool(config_value: Union[str, bool]) -> bool: + """Cast a configuration option into boolean.""" + if isinstance(config_value, bool): + return config_value + + if config_value.lower() in ("yes", "true", "t", "1", "True"): + return True + + if config_value.lower() in ("no", "false", "f", "0", "False"): + return False + + raise ValueError(f"Invalid value for bool: {config_value}") diff --git a/youconfigme/youconfigme.py b/youconfigme/youconfigme.py index af7dcc6..94a9140 100644 --- a/youconfigme/youconfigme.py +++ b/youconfigme/youconfigme.py @@ -10,17 +10,8 @@ import sys from configparser import ConfigParser from pathlib import Path -from typing import ( - Any, - Callable, - Dict, - List, - Mapping, - Optional, - TypeVar, - Union, - overload, -) +from typing import (Any, Callable, Dict, List, Mapping, Optional, TypeVar, + Union, overload) import toml as libtoml @@ -103,19 +94,19 @@ def __init__( section_name (str): section where the value should be placed sep (str): string to separate sections from items in env vars. """ - self.name = name - self.value = value - self.section_name = section_name - if self.section_name is not None: - self.env_str = f"{self.section_name.upper()}{sep}{name.upper()}" + self.__name = name + self.__value = value + self.__section_name = section_name + if self.__section_name is not None: + self.__env_str = f"{self.__section_name.upper()}{sep}{name.upper()}" else: - self.env_str = f"{name.upper()}" - logger.debug("Try to get env_str: %s", self.env_str) - self.env = os.getenv(self.env_str) - if self.value is not None: - self.value = str(self.value) - if self.env is not None: - self.env = str(self.env) + self.__env_str = f"{name.upper()}" + logger.debug("Try to get env_str: %s", self.__env_str) + self.__env = os.getenv(self.__env_str) + if self.__value is not None: + self.__value = str(self.__value) + if self.__env is not None: + self.__env = str(self.__env) @overload def __call__( @@ -125,7 +116,7 @@ def __call__( @overload def __call__( self, default: T, cast: None = None, from_pass: bool = False - ) -> str: ... + ) -> Union[T, str]: ... @overload def __call__( @@ -159,16 +150,16 @@ def __call__( Any: A str or casted item """ retval: Any - if self.env is not None: - retval = self.env - elif self.value is not None: - retval = self.value + if self.__env is not None: + retval = self.__env + elif self.__value is not None: + retval = self.__value elif default is not None: retval = str(default) else: - err_str = f"Configuration item {self.name}" - if self.section_name is not None: - err_str = f"{err_str} on section {self.section_name}" + err_str = f"Configuration item {self.__name}" + if self.__section_name is not None: + err_str = f"{err_str} on section {self.__section_name}" err_str = f"{err_str} was not found" raise ConfigItemNotFound(err_str) @@ -195,20 +186,20 @@ def __init__( items (mapping): mapping of attributes names to values sep (str): string to separate sections from items in env vars. """ - self.name = name - self.items = items or {} - self.sep = sep - self.prefix = f"{self.name}{self.sep}".upper() + self.__name = name + self.__items = items or {} + self.__sep = sep + self.__prefix = f"{self.__name}{self.__sep}".upper() def __getattr__(self, val: str) -> ConfigAttribute: """Get a new attribute.""" - return ConfigAttribute(val, self.items.get(val), self.name, sep=self.sep) + return ConfigAttribute(val, self.__items.get(val), self.__name, sep=self.__sep) def __call__( self, default: Optional[Any] = None, cast: Optional[Callable[[str], Any]] = None ) -> Any: """Get attribute called as section.""" - return ConfigAttribute(self.name, None, None, sep=self.sep)( + return ConfigAttribute(self.__name, None, None, sep=self.__sep)( default=default, cast=cast ) @@ -222,15 +213,15 @@ def to_dict(self) -> Dict[str, str]: dict: all the key:value pairs from the initial mapping, neglecting environment variables not present there. """ - items = dict(self.items) + items = dict(self.__items) env_items = { - envvar[len(self.prefix) :].lower(): envval # noqa: E203 + envvar[len(self.__prefix) :].lower(): envval # noqa: E203 for envvar, envval in os.environ.items() - if envvar.startswith(self.prefix) + if envvar.startswith(self.__prefix) } items.update(env_items) if items == {}: - raise ConfigItemNotFound(f"Section {self.name} is empty") + raise ConfigItemNotFound(f"Section {self.__name} is empty") ret_dict = {k: self.__getattr__(k)() for k in items.keys()} return ret_dict @@ -258,11 +249,13 @@ def __init__( default_section (str): config items that need not be under a section sep (str): string to separate sections from items in env vars. """ - self.sep: str = sep - self.default_section: str = default_section - self.fake_default_section: str = "None" if default_section != "None" else "enoN" - self.config_sections: List[str] = [] - self.config_attributes: List[str] = [] + self.__sep: str = sep + self.__default_section: str = default_section + self.__fake_default_section: str = ( + "None" if default_section != "None" else "enoN" + ) + self.__config_sections: List[str] = [] + self.__config_attributes: List[str] = [] if from_items is not None: try: @@ -272,28 +265,30 @@ def __init__( def _init_from_mapping(self, mapping: Mapping[str, Any]) -> None: for section in mapping.keys(): - if section == self.fake_default_section: + if section == self.__fake_default_section: continue - if section != self.default_section: + if section != self.__default_section: setattr( - self, section, ConfigSection(section, mapping[section], self.sep) + self, section, ConfigSection(section, mapping[section], self.__sep) ) - self.config_sections.append(section) + self.__config_sections.append(section) else: for k, v in mapping[section].items(): - setattr(self, k, ConfigAttribute(k, v, None, sep=self.sep)) - self.config_attributes.append(k) + setattr(self, k, ConfigAttribute(k, v, None, sep=self.__sep)) + self.__config_attributes.append(k) def _init_from_str(self, str_like: Union[str, Path]) -> None: try: buf = io.StringIO(str(str_like)) - config_parser = ConfigParser(default_section=self.fake_default_section) + config_parser = ConfigParser(default_section=self.__fake_default_section) config_parser.read_file(buf) self._init_from_mapping(config_parser) except Exception as e: # pylint: disable=broad-except cwd_file = Path.cwd() / str(str_like) if cwd_file.is_file() and cwd_file.suffix == ".ini": - config_parser = ConfigParser(default_section=self.fake_default_section) + config_parser = ConfigParser( + default_section=self.__fake_default_section + ) config_parser.read(cwd_file) self._init_from_mapping(config_parser) elif cwd_file.is_file() and cwd_file.suffix == ".toml": @@ -304,9 +299,9 @@ def _init_from_str(self, str_like: Union[str, Path]) -> None: def __getattr__(self, name: str) -> ConfigSection: """Get new section.""" - return ConfigSection(name, None, self.sep) + return ConfigSection(name, None, self.__sep) - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> Dict[str, Dict[str, str]]: """Return as dict. Args: @@ -317,9 +312,9 @@ def to_dict(self) -> Dict[str, Any]: neglecting environment variables not present there. """ ret_dict: Dict[str, Any] = {} - for section in self.config_sections: + for section in self.__config_sections: ret_dict[section] = self.__getattribute__(section).to_dict() - for attribute in self.config_attributes: + for attribute in self.__config_attributes: ret_dict[attribute] = self.__getattribute__(attribute)() return ret_dict @@ -329,7 +324,7 @@ def to_dotenv(self) -> str: for k, v in self.to_dict().items(): try: for k1, v1 in v.items(): - lines.append(f"{k}{self.sep}{k1}={v1}".upper()) + lines.append(f"{k}{self.__sep}{k1}={v1}".upper()) except AttributeError: lines.append(f"{k}={v}".upper()) return "\n".join(sorted(lines)) diff --git a/youconfigme/youconfigme.pyi b/youconfigme/youconfigme.pyi index 2299d4c..8836765 100644 --- a/youconfigme/youconfigme.pyi +++ b/youconfigme/youconfigme.pyi @@ -20,7 +20,7 @@ class ConfigAttribute: value: Optional[str] section_name: Optional[str] env_str: str - env: Optional[str] + __env: Optional[str] def __init__( self, From 2e69e61322891dfc405c6523ae5c9f19ad678d84 Mon Sep 17 00:00:00 2001 From: CrossNox Date: Thu, 17 Jul 2025 13:58:08 -0300 Subject: [PATCH 09/21] commit --- tests/test_typing.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_typing.py b/tests/test_typing.py index 04343cf..66d118b 100644 --- a/tests/test_typing.py +++ b/tests/test_typing.py @@ -4,7 +4,7 @@ # pylint: disable=missing-function-docstring # pylint: disable=unused-variable -from typing import Any, Dict, List +from typing import Dict, List import pytest from youconfigme import Config, ConfigSection @@ -62,13 +62,13 @@ def bad_cast(value: int) -> str: @pytest.mark.mypy_testing def test_cast_return_type_mismatch() -> None: """Test mismatch between cast return type and variable type.""" - config = Config(from_items={"test": {"items": "a,b,c"}}) + config = Config(from_items={"test": {"numbers": "a,b,c"}}) def parse_to_list(value: str) -> List[str]: return value.split(",") # Expecting List[int] but cast returns List[str] - numbers: List[int] = config.test.items(cast=parse_to_list) # E: Incompatible types in assignment (expression has type "List[str]", variable has type "List[int]") [assignment] + numbers: List[int] = config.test.numbers(cast=parse_to_list) # E: Argument "cast" to "__call__" of "ConfigAttribute" has incompatible type "Callable[[str], list[str]]"; expected "Callable[[str], list[int]]" [arg-type] @pytest.mark.mypy_testing From 697538e7129484b7a724cb0b554b6df8480b9ba3 Mon Sep 17 00:00:00 2001 From: CrossNox Date: Thu, 17 Jul 2025 13:58:41 -0300 Subject: [PATCH 10/21] commit --- youconfigme/youconfigme.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/youconfigme/youconfigme.py b/youconfigme/youconfigme.py index 94a9140..5cd1159 100644 --- a/youconfigme/youconfigme.py +++ b/youconfigme/youconfigme.py @@ -10,8 +10,17 @@ import sys from configparser import ConfigParser from pathlib import Path -from typing import (Any, Callable, Dict, List, Mapping, Optional, TypeVar, - Union, overload) +from typing import ( + Any, + Callable, + Dict, + List, + Mapping, + Optional, + TypeVar, + Union, + overload, +) import toml as libtoml From 6a8662bb16dde3c14e92daba6f01305b1aeba9e6 Mon Sep 17 00:00:00 2001 From: CrossNox Date: Thu, 17 Jul 2025 14:12:32 -0300 Subject: [PATCH 11/21] commit --- .flake8 | 3 ++- tests/test_cast.py | 55 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 tests/test_cast.py diff --git a/.flake8 b/.flake8 index b59ec90..dd31424 100644 --- a/.flake8 +++ b/.flake8 @@ -1,3 +1,4 @@ [flake8] per-file-ignores = - tests/test_typing.py: E501,F841 + tests/test_typing.py:E501,F841 + tests/test_cast.py:F841 diff --git a/tests/test_cast.py b/tests/test_cast.py new file mode 100644 index 0000000..02d013c --- /dev/null +++ b/tests/test_cast.py @@ -0,0 +1,55 @@ +"""Test cast functions""" + +# pylint: disable=redefined-outer-name +# pylint: disable=missing-function-docstring +# pylint: disable=unused-variable + +import pytest +from pytest.cast import to_bool +from youconfigme import Config + + +@pytest.fixture +def config_dict(): + return Config( + from_items={ + "yes": {"k1": "yes", "k2": "true", "k4": "1", "k5": "True", "k6": True}, + "no": { + "k1": "no", + "k2": "false", + "k3": "f", + "k4": "0", + "k5": "False", + "k6": False, + }, + } + ) + + +def test_to_bool(config_dict): + """Test to_bool""" + assert config_dict.yes.k1(cast=to_bool) is True + assert config_dict.yes.k2(cast=to_bool) is True + assert config_dict.yes.k3(cast=to_bool) is True + assert config_dict.yes.k4(cast=to_bool) is True + assert config_dict.yes.k5(cast=to_bool) is True + assert config_dict.yes.k6(cast=to_bool) is True + + assert config_dict.no.k1(cast=to_bool) is False + assert config_dict.no.k2(cast=to_bool) is False + assert config_dict.no.k3(cast=to_bool) is False + assert config_dict.no.k4(cast=to_bool) is False + assert config_dict.no.k5(cast=to_bool) is False + assert config_dict.no.k6(cast=to_bool) is False + + +@pytest.mark.mypy_testing +def test_to_bool_types(config_dict) -> None: + """Test typing""" + x: bool + x = config_dict.yes.k1(cast=to_bool) + x = config_dict.yes.k2(cast=to_bool) + x = config_dict.yes.k3(cast=to_bool) + x = config_dict.yes.k4(cast=to_bool) + x = config_dict.yes.k5(cast=to_bool) + x = config_dict.yes.k6(cast=to_bool) From 0041461841c291b29a72a82a27417f9126676c81 Mon Sep 17 00:00:00 2001 From: CrossNox Date: Thu, 17 Jul 2025 14:17:59 -0300 Subject: [PATCH 12/21] commit --- tests/test_cast.py | 2 +- youconfigme/cast.py | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/test_cast.py b/tests/test_cast.py index 02d013c..6e1a247 100644 --- a/tests/test_cast.py +++ b/tests/test_cast.py @@ -5,8 +5,8 @@ # pylint: disable=unused-variable import pytest -from pytest.cast import to_bool from youconfigme import Config +from youconfigme.cast import to_bool @pytest.fixture diff --git a/youconfigme/cast.py b/youconfigme/cast.py index 3398ceb..99810ab 100644 --- a/youconfigme/cast.py +++ b/youconfigme/cast.py @@ -1,6 +1,16 @@ """Common casts""" -from typing import Union +import builtins +from typing import Optional, Union + + +def ellipsis_none(config_value: Union[str, "builtins.ellipsis"]) -> Optional[str]: + """Return None for ellipsis defaults.""" + if config_value is ...: + return None + if config_value == "": + return None + return config_value def to_bool(config_value: Union[str, bool]) -> bool: From 6899e6a805cf9ed592077aca88c5ec279e84c85c Mon Sep 17 00:00:00 2001 From: CrossNox Date: Thu, 17 Jul 2025 14:19:59 -0300 Subject: [PATCH 13/21] commit --- tests/test_cast.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/test_cast.py b/tests/test_cast.py index 6e1a247..0cb9209 100644 --- a/tests/test_cast.py +++ b/tests/test_cast.py @@ -13,7 +13,14 @@ def config_dict(): return Config( from_items={ - "yes": {"k1": "yes", "k2": "true", "k4": "1", "k5": "True", "k6": True}, + "yes": { + "k1": "yes", + "k2": "true", + "k3": "y", + "k4": "1", + "k5": "True", + "k6": True, + }, "no": { "k1": "no", "k2": "false", From 2d486667b0c2f18fd8abee6506153654ed030bb5 Mon Sep 17 00:00:00 2001 From: CrossNox Date: Thu, 17 Jul 2025 14:21:10 -0300 Subject: [PATCH 14/21] commit --- tests/test_cast.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/test_cast.py b/tests/test_cast.py index 0cb9209..35b2c7b 100644 --- a/tests/test_cast.py +++ b/tests/test_cast.py @@ -55,8 +55,9 @@ def test_to_bool_types(config_dict) -> None: """Test typing""" x: bool x = config_dict.yes.k1(cast=to_bool) - x = config_dict.yes.k2(cast=to_bool) - x = config_dict.yes.k3(cast=to_bool) - x = config_dict.yes.k4(cast=to_bool) - x = config_dict.yes.k5(cast=to_bool) - x = config_dict.yes.k6(cast=to_bool) + + +@pytest.mark.mypy_testing +def test_to_bool_bad_types(config_dict) -> None: + """Test typing""" + x: int = config_dict.yes.k1(cast=to_bool) # E: lala From d88c2138dac3be8e630b9120708c65dc9eef2b9e Mon Sep 17 00:00:00 2001 From: CrossNox Date: Thu, 17 Jul 2025 16:29:36 -0300 Subject: [PATCH 15/21] add casts --- .flake8 | 2 +- tests/test_cast.py | 17 +++++++++-------- tests/test_config_section.py | 2 +- tests/test_default_none.py | 16 ++++++++++++++++ tests/test_typing.py | 8 ++++++++ youconfigme/cast.py | 14 ++------------ youconfigme/youconfigme.py | 28 +++++++++++++++++++++------- 7 files changed, 58 insertions(+), 29 deletions(-) create mode 100644 tests/test_default_none.py diff --git a/.flake8 b/.flake8 index dd31424..04134cc 100644 --- a/.flake8 +++ b/.flake8 @@ -1,4 +1,4 @@ [flake8] per-file-ignores = tests/test_typing.py:E501,F841 - tests/test_cast.py:F841 + tests/test_cast.py:E501,F841 diff --git a/tests/test_cast.py b/tests/test_cast.py index 35b2c7b..d788b6e 100644 --- a/tests/test_cast.py +++ b/tests/test_cast.py @@ -8,15 +8,17 @@ from youconfigme import Config from youconfigme.cast import to_bool +# fmt: off + @pytest.fixture -def config_dict(): +def config_dict() -> Config: return Config( from_items={ "yes": { "k1": "yes", "k2": "true", - "k3": "y", + "k3": "t", "k4": "1", "k5": "True", "k6": True, @@ -33,7 +35,7 @@ def config_dict(): ) -def test_to_bool(config_dict): +def test_to_bool(config_dict: Config) -> None: """Test to_bool""" assert config_dict.yes.k1(cast=to_bool) is True assert config_dict.yes.k2(cast=to_bool) is True @@ -51,13 +53,12 @@ def test_to_bool(config_dict): @pytest.mark.mypy_testing -def test_to_bool_types(config_dict) -> None: +def test_to_bool_types(config_dict: Config) -> None: """Test typing""" - x: bool - x = config_dict.yes.k1(cast=to_bool) + x: bool = config_dict.yes.k1(cast=to_bool) @pytest.mark.mypy_testing -def test_to_bool_bad_types(config_dict) -> None: +def test_to_bool_bad_types(config_dict: Config) -> None: """Test typing""" - x: int = config_dict.yes.k1(cast=to_bool) # E: lala + x: str = config_dict.yes.k1(cast=to_bool) # E: Incompatible types in assignment (expression has type "bool", variable has type "str") [assignment] diff --git a/tests/test_config_section.py b/tests/test_config_section.py index 519709c..54d0e20 100644 --- a/tests/test_config_section.py +++ b/tests/test_config_section.py @@ -55,7 +55,7 @@ def test_val_nex(config_section): def test_val_nex_def(config_section): - assert config_section.w(7) == "7" + assert config_section.w(7) == 7 def test_val_nex_def_cast(config_section): diff --git a/tests/test_default_none.py b/tests/test_default_none.py new file mode 100644 index 0000000..776a1a8 --- /dev/null +++ b/tests/test_default_none.py @@ -0,0 +1,16 @@ +"""Test cast functions""" + +# pylint: disable=redefined-outer-name +# pylint: disable=missing-function-docstring +# pylint: disable=unused-variable + +import pytest +from youconfigme import Config, ConfigItemNotFound + + +def test_default_none() -> None: + config = Config(from_items={"a": {"b": 1}}) + with pytest.raises(ConfigItemNotFound): + config.a.c() + + assert config.a.c(default=None) is None diff --git a/tests/test_typing.py b/tests/test_typing.py index 66d118b..8fd9021 100644 --- a/tests/test_typing.py +++ b/tests/test_typing.py @@ -71,6 +71,14 @@ def parse_to_list(value: str) -> List[str]: numbers: List[int] = config.test.numbers(cast=parse_to_list) # E: Argument "cast" to "__call__" of "ConfigAttribute" has incompatible type "Callable[[str], list[str]]"; expected "Callable[[str], list[int]]" [arg-type] +@pytest.mark.mypy_testing +def test_cast_return_type_mismatch_simple() -> None: + """Test mismatch between cast return type and variable type.""" + config = Config(from_items={"test": {"number": "1"}}) + + number: str = config.test.number(cast=int) # E: Incompatible types in assignment (expression has type "int", variable has type "str") [assignment] + + @pytest.mark.mypy_testing def test_bad_default_type_without_cast() -> None: """Test that default without cast doesn't convert types.""" diff --git a/youconfigme/cast.py b/youconfigme/cast.py index 99810ab..762e35a 100644 --- a/youconfigme/cast.py +++ b/youconfigme/cast.py @@ -1,16 +1,6 @@ """Common casts""" -import builtins -from typing import Optional, Union - - -def ellipsis_none(config_value: Union[str, "builtins.ellipsis"]) -> Optional[str]: - """Return None for ellipsis defaults.""" - if config_value is ...: - return None - if config_value == "": - return None - return config_value +from typing import Union def to_bool(config_value: Union[str, bool]) -> bool: @@ -24,4 +14,4 @@ def to_bool(config_value: Union[str, bool]) -> bool: if config_value.lower() in ("no", "false", "f", "0", "False"): return False - raise ValueError(f"Invalid value for bool: {config_value}") + raise ValueError(f"Invalid value for to_bool: {config_value}") diff --git a/youconfigme/youconfigme.py b/youconfigme/youconfigme.py index 5cd1159..d3eacfb 100644 --- a/youconfigme/youconfigme.py +++ b/youconfigme/youconfigme.py @@ -11,6 +11,7 @@ from configparser import ConfigParser from pathlib import Path from typing import ( + TYPE_CHECKING, Any, Callable, Dict, @@ -26,6 +27,16 @@ from youconfigme.getpass import get_pass +if TYPE_CHECKING: + from enum import Enum + + class ellipsis(Enum): + Ellipsis = "..." + + Ellipsis = ellipsis.Ellipsis # pylint: disable=redefined-builtin +else: + ellipsis = type(Ellipsis) + def config_logger(name: str) -> logging.Logger: """Set a new logger. @@ -119,7 +130,7 @@ def __init__( @overload def __call__( - self, default: None = None, cast: None = None, from_pass: bool = False + self, default: ellipsis = Ellipsis, cast: None = None, from_pass: bool = False ) -> str: ... @overload @@ -130,7 +141,7 @@ def __call__( @overload def __call__( self, - default: None = None, + default: ellipsis = Ellipsis, cast: Callable[[str], T] = ..., from_pass: bool = False, ) -> T: ... @@ -142,7 +153,7 @@ def __call__( def __call__( self, - default: Optional[Any] = None, + default: Any = Ellipsis, cast: Optional[Callable[[str], Any]] = None, from_pass: bool = False, ) -> Any: @@ -159,12 +170,13 @@ def __call__( Any: A str or casted item """ retval: Any + if self.__env is not None: retval = self.__env elif self.__value is not None: retval = self.__value - elif default is not None: - retval = str(default) + elif default != Ellipsis: + retval = default else: err_str = f"Configuration item {self.__name}" if self.__section_name is not None: @@ -175,7 +187,9 @@ def __call__( if from_pass: retval = get_pass(retval) - return (cast or str)(retval) + if cast is not None: + return cast(retval) + return retval def __getattr__(self, name: str) -> None: """Get attr that does not exist.""" @@ -205,7 +219,7 @@ def __getattr__(self, val: str) -> ConfigAttribute: return ConfigAttribute(val, self.__items.get(val), self.__name, sep=self.__sep) def __call__( - self, default: Optional[Any] = None, cast: Optional[Callable[[str], Any]] = None + self, default: Any = Ellipsis, cast: Optional[Callable[[str], Any]] = None ) -> Any: """Get attribute called as section.""" return ConfigAttribute(self.__name, None, None, sep=self.__sep)( From 527cd4707e9fff287ae7e8f04205a250ef971112 Mon Sep 17 00:00:00 2001 From: CrossNox Date: Thu, 17 Jul 2025 16:30:14 -0300 Subject: [PATCH 16/21] commit --- youconfigme/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/youconfigme/__init__.py b/youconfigme/__init__.py index dfa353d..c8208ab 100644 --- a/youconfigme/__init__.py +++ b/youconfigme/__init__.py @@ -2,5 +2,5 @@ from .youconfigme import AutoConfig, Config, ConfigItemNotFound, ConfigSection -__version__ = "1.1.0" +__version__ = "2.0.0" __all__ = ["AutoConfig", "Config", "ConfigItemNotFound", "ConfigSection"] From c4028273e70f32a722d021a55cae2ad42d241dbb Mon Sep 17 00:00:00 2001 From: CrossNox Date: Thu, 17 Jul 2025 16:31:43 -0300 Subject: [PATCH 17/21] commit --- noxfile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/noxfile.py b/noxfile.py index d45f074..21b123d 100644 --- a/noxfile.py +++ b/noxfile.py @@ -4,7 +4,7 @@ @nox.session(reuse_venv=True, python="3.8") -def cop(session): # type: ignore +def cop(session) -> None: # type: ignore """Run all pre-commit hooks.""" session.install(".") session.install(".[dev,test]") From 93d07c120f482988288fd7c9103bf66aa5df8895 Mon Sep 17 00:00:00 2001 From: CrossNox Date: Thu, 17 Jul 2025 16:55:45 -0300 Subject: [PATCH 18/21] stash --- setup.py | 2 +- youconfigme/youconfigme.pyi | 47 +++++++++++++++++++------------------ 2 files changed, 25 insertions(+), 24 deletions(-) diff --git a/setup.py b/setup.py index d73ef95..bc07964 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ def read_readme() -> str: author="CrossNox", install_requires=["toml"], extras_require={ - "test": ["pytest", "pytest-mypy-testing"], + "test": ["pytest", "pytest-mypy-testing", "pytest-cov"], "dev": [ "pre-commit", "mypy", diff --git a/youconfigme/youconfigme.pyi b/youconfigme/youconfigme.pyi index 8836765..a28c6e9 100644 --- a/youconfigme/youconfigme.pyi +++ b/youconfigme/youconfigme.pyi @@ -3,7 +3,17 @@ import logging from pathlib import Path from typing import (Any, Callable, Dict, List, Mapping, Optional, TypeVar, - Union, overload) + Union, overload, TYPE_CHECKING) + +if TYPE_CHECKING: + from enum import Enum + + class ellipsis(Enum): + Ellipsis = "..." + + Ellipsis = ellipsis.Ellipsis +else: + ellipsis = type(Ellipsis) T = TypeVar("T") @@ -16,12 +26,6 @@ def config_logger(name: str) -> logging.Logger: ... class ConfigItemNotFound(Exception): ... class ConfigAttribute: - name: str - value: Optional[str] - section_name: Optional[str] - env_str: str - __env: Optional[str] - def __init__( self, name: str, @@ -31,51 +35,48 @@ class ConfigAttribute: ) -> None: ... @overload def __call__( - self, default: None = ..., cast: None = ..., from_pass: bool = ... + self, default: ellipsis = ..., cast: None = ..., from_pass: bool = ... ) -> str: ... @overload - def __call__(self, default: T, cast: None = ..., from_pass: bool = ...) -> str: ... + def __call__( + self, default: T, cast: None = ..., from_pass: bool = ... + ) -> Union[T, str]: ... @overload def __call__( - self, default: None = ..., cast: Callable[[str], T] = ..., from_pass: bool = ... + self, + default: ellipsis = ..., + cast: Callable[[str], T] = ..., + from_pass: bool = ..., ) -> T: ... @overload def __call__( self, default: Any, cast: Callable[[str], T] = ..., from_pass: bool = ... ) -> T: ... + def __getattr__(self, name: str) -> None: ... class ConfigSection: - name: str - items: Dict[str, Any] - sep: str - prefix: str - def __init__( self, name: str, items: Optional[Mapping[str, Any]], sep: str = ... ) -> None: ... def __getattr__(self, val: str) -> ConfigAttribute: ... def __call__( - self, default: Optional[Any] = ..., cast: Optional[Callable[[str], Any]] = ... + self, default: Any = ..., cast: Optional[Callable[[str], Any]] = ... ) -> Any: ... def to_dict(self) -> Dict[str, str]: ... FromItemsType = Union[str, Path, Mapping[str, Any], None] class Config: - sep: str - default_section: str - fake_default_section: str - config_sections: List[str] - config_attributes: List[str] - def __init__( self, from_items: FromItemsType = ..., default_section: str = ..., sep: str = ..., ) -> None: ... + def _init_from_mapping(self, mapping: Mapping[str, Any]) -> None: ... + def _init_from_str(self, str_like: Union[str, Path]) -> None: ... def __getattr__(self, name: str) -> ConfigSection: ... - def to_dict(self) -> Dict[str, Any]: ... + def to_dict(self) -> Dict[str, Dict[str, str]]: ... def to_dotenv(self) -> str: ... class AutoConfig(Config): From 5e7eedcb8a07bb0db0d031341be8bac0e4676f43 Mon Sep 17 00:00:00 2001 From: CrossNox Date: Thu, 17 Jul 2025 17:13:43 -0300 Subject: [PATCH 19/21] commit --- youconfigme/youconfigme.pyi | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/youconfigme/youconfigme.pyi b/youconfigme/youconfigme.pyi index a28c6e9..b4225a4 100644 --- a/youconfigme/youconfigme.pyi +++ b/youconfigme/youconfigme.pyi @@ -38,6 +38,10 @@ class ConfigAttribute: self, default: ellipsis = ..., cast: None = ..., from_pass: bool = ... ) -> str: ... @overload + def __call__( + self, default: str, cast: None = ..., from_pass: bool = ... + ) -> str: ... + @overload def __call__( self, default: T, cast: None = ..., from_pass: bool = ... ) -> Union[T, str]: ... @@ -50,7 +54,7 @@ class ConfigAttribute: ) -> T: ... @overload def __call__( - self, default: Any, cast: Callable[[str], T] = ..., from_pass: bool = ... + self, default: Any, cast: Callable[[str], T], from_pass: bool = ... ) -> T: ... def __getattr__(self, name: str) -> None: ... From c5212353f343c21f8059fceccead54b7dd65ee29 Mon Sep 17 00:00:00 2001 From: CrossNox Date: Thu, 17 Jul 2025 17:36:35 -0300 Subject: [PATCH 20/21] commit --- .flake8 | 1 + .pre-commit-config.yaml | 1 - tests/test_cast.py | 2 +- tests/test_pass.py | 16 ++-- youconfigme/{cast.py => common_casts.py} | 12 ++- youconfigme/getpass.py | 20 ----- youconfigme/use_gnupass.py | 69 +++++++++++++++ youconfigme/youconfigme.py | 107 +++++++++++++++-------- 8 files changed, 160 insertions(+), 68 deletions(-) rename youconfigme/{cast.py => common_casts.py} (60%) delete mode 100644 youconfigme/getpass.py create mode 100644 youconfigme/use_gnupass.py diff --git a/.flake8 b/.flake8 index 04134cc..02031bb 100644 --- a/.flake8 +++ b/.flake8 @@ -1,4 +1,5 @@ [flake8] +max-line-length = 120 per-file-ignores = tests/test_typing.py:E501,F841 tests/test_cast.py:E501,F841 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b8444f5..e609d5b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,7 +18,6 @@ repos: entry: flake8 language: system types: [python] - args: [--max-line-length=88] exclude: docs - id: isort name: isort diff --git a/tests/test_cast.py b/tests/test_cast.py index d788b6e..09a02f5 100644 --- a/tests/test_cast.py +++ b/tests/test_cast.py @@ -6,7 +6,7 @@ import pytest from youconfigme import Config -from youconfigme.cast import to_bool +from youconfigme.common_casts import to_bool # fmt: off diff --git a/tests/test_pass.py b/tests/test_pass.py index 15e5ba0..24d6ddc 100644 --- a/tests/test_pass.py +++ b/tests/test_pass.py @@ -24,29 +24,29 @@ def config_underscore(): del os.environ["K4"] -@patch("youconfigme.getpass.subprocess.run") +@patch("youconfigme.use_gnupass.subprocess.run") def test_k1(mock_run, config_underscore): - mock_run.return_value.stdout.decode.return_value.strip.return_value = PASS + mock_run.return_value.stdout = PASS + "\n" cfg = config_underscore assert cfg.a.k1(from_pass=True) == PASS -@patch("youconfigme.getpass.subprocess.run") +@patch("youconfigme.use_gnupass.subprocess.run") def test_k2(mock_run, config_underscore): - mock_run.return_value.stdout.decode.return_value.strip.return_value = PASS + mock_run.return_value.stdout = PASS + "\n" cfg = config_underscore assert cfg.a.k2(from_pass=True) == PASS -@patch("youconfigme.getpass.subprocess.run") +@patch("youconfigme.use_gnupass.subprocess.run") def test_k3(mock_run, config_underscore): - mock_run.return_value.stdout.decode.return_value.strip.return_value = PASS + mock_run.return_value.stdout = PASS + "\n" cfg = config_underscore assert cfg.k3(from_pass=True) == PASS -@patch("youconfigme.getpass.subprocess.run") +@patch("youconfigme.use_gnupass.subprocess.run") def test_k4(mock_run, config_underscore): - mock_run.return_value.stdout.decode.return_value.strip.return_value = PASS + mock_run.return_value.stdout = PASS + "\n" cfg = config_underscore assert cfg.k4(from_pass=True) == PASS diff --git a/youconfigme/cast.py b/youconfigme/common_casts.py similarity index 60% rename from youconfigme/cast.py rename to youconfigme/common_casts.py index 762e35a..df72440 100644 --- a/youconfigme/cast.py +++ b/youconfigme/common_casts.py @@ -4,7 +4,17 @@ def to_bool(config_value: Union[str, bool]) -> bool: - """Cast a configuration option into boolean.""" + """Cast a configuration option into boolean. + + Args: + config_value: String or boolean value to convert + + Returns: + Boolean representation of the input value + + Raises: + ValueError: If the input cannot be converted to boolean + """ if isinstance(config_value, bool): return config_value diff --git a/youconfigme/getpass.py b/youconfigme/getpass.py deleted file mode 100644 index e8fd5c0..0000000 --- a/youconfigme/getpass.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Get values from GNU pass.""" - -import subprocess -from typing import Optional - - -def get_pass(key: str, section: Optional[str] = None) -> str: - """Get password from GNU pass. - - Args: - key: The key to retrieve - section: Optional section prefix - - Returns: - The password value as a string - """ - if section is not None: - key = f"{section}/{key}" - res = subprocess.run(["pass", key], capture_output=True, check=True) - return res.stdout.decode().strip() diff --git a/youconfigme/use_gnupass.py b/youconfigme/use_gnupass.py new file mode 100644 index 0000000..a9f9138 --- /dev/null +++ b/youconfigme/use_gnupass.py @@ -0,0 +1,69 @@ +"""Get values from GNU pass.""" + +import re +import subprocess +from typing import Optional + + +def get_pass(key: str, section: Optional[str] = None) -> str: + """Get password from GNU pass. + + Args: + key: The key to retrieve (letters, numbers, -, _, /) + section: Optional section prefix - (letters, numbers, -, _) + + Returns: + The password value as a string + + Raises: + ValueError: If key or section contains invalid characters + subprocess.SubprocessError: If pass command fails + subprocess.TimeoutExpired: If pass command times out + """ + # Validate key to prevent command injection + if not _is_valid_pass_key(key): + raise ValueError( + f"Invalid key format: {key}. Only alphanumeric characters, -, _, and / are allowed." + ) + + if section is not None: + if not _is_valid_pass_key( + section.replace("/", "") + ): # Allow slashes in section for nested paths + raise ValueError( + f"Invalid section format: {section}. Only alphanumeric characters, -, and _ are allowed." + ) + key = f"{section}/{key}" + + try: + res = subprocess.run( + ["pass", "show", key], + capture_output=True, + check=True, + timeout=10, # Add timeout to prevent hanging + text=True, # Handle text encoding properly + ) + return res.stdout.strip() + except subprocess.TimeoutExpired as e: + raise subprocess.TimeoutExpired( + f"pass command timed out for key: {key}", 10 + ) from e + except subprocess.CalledProcessError as e: + raise subprocess.SubprocessError( + f"pass command failed for key: {key}. Error: {e.stderr}" + ) from e + + +def _is_valid_pass_key(key: str) -> bool: + """Validate that a pass key contains only safe characters. + + Args: + key: The key to validate + + Returns: + True if key is safe, False otherwise + """ + if not key: + return False + # Allow alphanumeric, hyphens, underscores, and forward slashes + return re.match(r"^[a-zA-Z0-9/_-]+$", key) is not None diff --git a/youconfigme/youconfigme.py b/youconfigme/youconfigme.py index d3eacfb..3e84d61 100644 --- a/youconfigme/youconfigme.py +++ b/youconfigme/youconfigme.py @@ -25,7 +25,7 @@ import toml as libtoml -from youconfigme.getpass import get_pass +from youconfigme.use_gnupass import get_pass if TYPE_CHECKING: from enum import Enum @@ -162,12 +162,12 @@ def __call__( Follows the order of lookup. Args: - default (str): default value if item not found - cast (callable): how to cast the item - from_pass (bool): whether to retrieve value from pass + default: Default value if item not found + cast: Function to cast the item + from_pass: Whether to retrieve value from GNU pass Returns: - Any: A str or casted item + A string or casted item """ retval: Any @@ -192,7 +192,14 @@ def __call__( return retval def __getattr__(self, name: str) -> None: - """Get attr that does not exist.""" + """Get attribute that does not exist. + + Args: + name: Name of the attribute that was requested + + Raises: + ConfigItemNotFound: Always, since the attribute doesn't exist + """ raise ConfigItemNotFound(f"section {name} not found") @@ -205,9 +212,9 @@ def __init__( """Create a new ConfigSection. Args: - name (str): name of the section - items (mapping): mapping of attributes names to values - sep (str): string to separate sections from items in env vars. + name: Name of the section + items: Mapping of attribute names to values + sep: String to separate sections from items in env vars """ self.__name = name self.__items = items or {} @@ -215,26 +222,41 @@ def __init__( self.__prefix = f"{self.__name}{self.__sep}".upper() def __getattr__(self, val: str) -> ConfigAttribute: - """Get a new attribute.""" + """Get a new configuration attribute. + + Args: + val: Name of the attribute to retrieve + + Returns: + A ConfigAttribute instance for the requested attribute + """ return ConfigAttribute(val, self.__items.get(val), self.__name, sep=self.__sep) def __call__( self, default: Any = Ellipsis, cast: Optional[Callable[[str], Any]] = None ) -> Any: - """Get attribute called as section.""" + """Get attribute called as section. + + Args: + default: Default value if item not found + cast: Function to cast the item + + Returns: + The configuration value, optionally cast + """ return ConfigAttribute(self.__name, None, None, sep=self.__sep)( default=default, cast=cast ) def to_dict(self) -> Dict[str, str]: - """Return as dict. - - Args: - None + """Return section as dictionary. Returns: - dict: all the key:value pairs from the initial mapping, - neglecting environment variables not present there. + Dictionary of all key:value pairs from the initial mapping, + including environment variables that match the section prefix + + Raises: + ConfigItemNotFound: If the section is empty """ items = dict(self.__items) env_items = { @@ -264,18 +286,18 @@ def __init__( """Create a new Config item. Args: - from_items (mapping or str or filename): where the config should be - populated from: - - filename: path for an `ini` file - - mapping: mapping of sections -> mapping of name -> value - - str: string representation of an `ini` file - default_section (str): config items that need not be under a section - sep (str): string to separate sections from items in env vars. + from_items: Where the config should be populated from: + - filename: Path for an ini/toml file + - mapping: Mapping of sections to mappings of name to value + - str: String representation of an ini file + - None: Empty configuration + default_section: Config items that need not be under a section + sep: String to separate sections from items in env vars """ self.__sep: str = sep self.__default_section: str = default_section self.__fake_default_section: str = ( - "None" if default_section != "None" else "enoN" + f"__YOUCONFIGME_INTERNAL_SECTION_{id(self)}__" ) self.__config_sections: List[str] = [] self.__config_attributes: List[str] = [] @@ -321,18 +343,22 @@ def _init_from_str(self, str_like: Union[str, Path]) -> None: raise FileNotFoundError from e def __getattr__(self, name: str) -> ConfigSection: - """Get new section.""" + """Get new configuration section. + + Args: + name: Name of the section to retrieve + + Returns: + A ConfigSection instance for the requested section + """ return ConfigSection(name, None, self.__sep) def to_dict(self) -> Dict[str, Dict[str, str]]: - """Return as dict. - - Args: - None + """Return configuration as dictionary. Returns: - dict: all the key:value pairs from the initial mapping, - neglecting environment variables not present there. + Dictionary of all sections and their key:value pairs, + including standalone attributes not in sections """ ret_dict: Dict[str, Any] = {} for section in self.__config_sections: @@ -342,7 +368,11 @@ def to_dict(self) -> Dict[str, Dict[str, str]]: return ret_dict def to_dotenv(self) -> str: - """Return as .env file""" + """Return configuration as .env file format. + + Returns: + String representation in .env format with uppercase keys + """ lines: List[str] = [] for k, v in self.to_dict().items(): try: @@ -366,10 +396,13 @@ def __init__( """Create a new AutoConfig item. Args: - max_up_levels (int): how many parents should it traverse searching - for an `ini` file - filename (str): filename to search for - sep (str): string to separate sections from items in env vars. + max_up_levels: How many parent directories to traverse searching + for a configuration file + filename: Filename to search for + sep: String to separate sections from items in env vars + + Raises: + ValueError: If no caller frame is available """ frame = sys._getframe() if frame.f_back is None: From a9a0f22978b2ea81818a5c7a8ec823d9f0e047ba Mon Sep 17 00:00:00 2001 From: CrossNox Date: Thu, 17 Jul 2025 17:51:13 -0300 Subject: [PATCH 21/21] commit --- tests/test_typing.py | 6 +++--- youconfigme/youconfigme.py | 12 +++++++----- youconfigme/youconfigme.pyi | 2 +- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/tests/test_typing.py b/tests/test_typing.py index 8fd9021..77ec54f 100644 --- a/tests/test_typing.py +++ b/tests/test_typing.py @@ -4,7 +4,7 @@ # pylint: disable=missing-function-docstring # pylint: disable=unused-variable -from typing import Dict, List +from typing import Dict, List, Union import pytest from youconfigme import Config, ConfigSection @@ -137,5 +137,5 @@ def test_config_to_dict_return_type() -> None: "db": {"name": "mydb"} }) - # to_dict returns Dict[str, Dict[str, str]] - config_dict: Dict[str, Dict[str, str]] = config.to_dict() + # to_dict returns Dict[str, Union[Dict[str, str], str]] + config_dict: Dict[str, Union[Dict[str, str], str]] = config.to_dict() diff --git a/youconfigme/youconfigme.py b/youconfigme/youconfigme.py index 3e84d61..ea48f5e 100644 --- a/youconfigme/youconfigme.py +++ b/youconfigme/youconfigme.py @@ -353,14 +353,14 @@ def __getattr__(self, name: str) -> ConfigSection: """ return ConfigSection(name, None, self.__sep) - def to_dict(self) -> Dict[str, Dict[str, str]]: + def to_dict(self) -> Dict[str, Union[Dict[str, str], str]]: """Return configuration as dictionary. Returns: Dictionary of all sections and their key:value pairs, including standalone attributes not in sections """ - ret_dict: Dict[str, Any] = {} + ret_dict: Dict[str, Union[Dict[str, str], str]] = {} for section in self.__config_sections: ret_dict[section] = self.__getattribute__(section).to_dict() for attribute in self.__config_attributes: @@ -374,12 +374,14 @@ def to_dotenv(self) -> str: String representation in .env format with uppercase keys """ lines: List[str] = [] + v: Union[str, Dict[str, str]] for k, v in self.to_dict().items(): - try: + # I'd rather use try/except, but _mypy_ + if isinstance(v, str): + lines.append(f"{k}={v}".upper()) + else: for k1, v1 in v.items(): lines.append(f"{k}{self.__sep}{k1}={v1}".upper()) - except AttributeError: - lines.append(f"{k}={v}".upper()) return "\n".join(sorted(lines)) diff --git a/youconfigme/youconfigme.pyi b/youconfigme/youconfigme.pyi index b4225a4..bde4dc4 100644 --- a/youconfigme/youconfigme.pyi +++ b/youconfigme/youconfigme.pyi @@ -80,7 +80,7 @@ class Config: def _init_from_mapping(self, mapping: Mapping[str, Any]) -> None: ... def _init_from_str(self, str_like: Union[str, Path]) -> None: ... def __getattr__(self, name: str) -> ConfigSection: ... - def to_dict(self) -> Dict[str, Dict[str, str]]: ... + def to_dict(self) -> Dict[str, Union[Dict[str, str], str]]: ... def to_dotenv(self) -> str: ... class AutoConfig(Config):