diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..02031bb --- /dev/null +++ b/.flake8 @@ -0,0 +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 51ea7ea..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 @@ -32,7 +31,7 @@ repos: entry: mypy language: system types: [python] - exclude: docs + exclude: docs/.*|tests/.* - id: pylint name: pylint entry: pylint diff --git a/mypy.ini b/mypy.ini index 461b55e..a81f397 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,4 +1,13 @@ [mypy] - -ignore_missing_imports = True -follow_imports = skip +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 +show_error_codes = True diff --git a/noxfile.py b/noxfile.py index cbcd7b8..21b123d 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) -> None: # 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]") diff --git a/setup.py b/setup.py index 7834c9b..bc07964 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() @@ -29,7 +30,7 @@ def read_readme(): author="CrossNox", install_requires=["toml"], extras_require={ - "test": ["pytest"], + "test": ["pytest", "pytest-mypy-testing", "pytest-cov"], "dev": [ "pre-commit", "mypy", @@ -40,8 +41,12 @@ def read_readme(): "bump", "nox", "types-toml", + "types-setuptools", ], }, packages=["youconfigme"], + package_data={ + "youconfigme": ["py.typed", "*.pyi"], + }, classifiers=["Programming Language :: Python :: 3"], ) diff --git a/tests/test_cast.py b/tests/test_cast.py new file mode 100644 index 0000000..09a02f5 --- /dev/null +++ b/tests/test_cast.py @@ -0,0 +1,64 @@ +"""Test cast functions""" + +# pylint: disable=redefined-outer-name +# pylint: disable=missing-function-docstring +# pylint: disable=unused-variable + +import pytest +from youconfigme import Config +from youconfigme.common_casts import to_bool + +# fmt: off + + +@pytest.fixture +def config_dict() -> Config: + return Config( + from_items={ + "yes": { + "k1": "yes", + "k2": "true", + "k3": "t", + "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: 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 + 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: Config) -> None: + """Test typing""" + x: bool = config_dict.yes.k1(cast=to_bool) + + +@pytest.mark.mypy_testing +def test_to_bool_bad_types(config_dict: Config) -> None: + """Test typing""" + 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_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/tests/test_typing.py b/tests/test_typing.py new file mode 100644 index 0000000..77ec54f --- /dev/null +++ b/tests/test_typing.py @@ -0,0 +1,141 @@ +"""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 Dict, List, Union + +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() + port_str: str = config.db.port() + port_int: int = config.db.port(cast=int) + + # With defaults + timeout: str = config.db.timeout(default="30") + timeout_int: int = config.db.timeout(default="30", cast=int) + timeout_int_2: int = config.db.timeout(default=30, cast=int) + + # Correct casting functions + + def to_int(value: str) -> int: + return int(value) + + timeout_int_3: int = config.db.port(default="30", cast=to_int) + timeout_int_4: int = config.db.port(default=30, cast=to_int) + timeout_int_5: int = config.db.port(cast=to_int) + + +@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() # E: Incompatible types in assignment (expression has type "str", variable has type "int") [assignment] + + +@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) # E: Argument "cast" to "__call__" of "ConfigAttribute" has incompatible type "Callable[[int], str]"; expected "Callable[[str], str]" [arg-type] + + +@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": {"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.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.""" + config = Config(from_items={}) + + # Default is string "100", without cast it stays string + 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) + + +@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) + features: List[str] = config.app.features(cast=str_to_list) + connections: int = config.app.max_connections(cast=int) + + +@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() + + # Wrong type for to_dict + 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 +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, Union[Dict[str, str], str]] + config_dict: Dict[str, Union[Dict[str, str], str]] = config.to_dict() diff --git a/youconfigme/__init__.py b/youconfigme/__init__.py index 5243942..c8208ab 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__ = "2.0.0" __all__ = ["AutoConfig", "Config", "ConfigItemNotFound", "ConfigSection"] diff --git a/youconfigme/common_casts.py b/youconfigme/common_casts.py new file mode 100644 index 0000000..df72440 --- /dev/null +++ b/youconfigme/common_casts.py @@ -0,0 +1,27 @@ +"""Common casts""" + +from typing import Union + + +def to_bool(config_value: Union[str, bool]) -> bool: + """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 + + 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 to_bool: {config_value}") diff --git a/youconfigme/getpass.py b/youconfigme/getpass.py deleted file mode 100644 index c2e24fc..0000000 --- a/youconfigme/getpass.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Get values from GNU pass.""" - -import subprocess - - -def get_pass(key, section=None): - 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/py.typed b/youconfigme/py.typed new file mode 100644 index 0000000..e69de29 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 11ccee3..ea48f5e 100644 --- a/youconfigme/youconfigme.py +++ b/youconfigme/youconfigme.py @@ -10,20 +10,42 @@ import sys from configparser import ConfigParser from pathlib import Path +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Dict, + List, + Mapping, + Optional, + TypeVar, + Union, + overload, +) import toml as libtoml -from youconfigme.getpass import get_pass +from youconfigme.use_gnupass import get_pass +if TYPE_CHECKING: + from enum import Enum -def config_logger(name): + 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. 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 +83,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 +99,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: @@ -84,159 +114,226 @@ def __init__(self, name, value, section_name, sep=DEFAULT_SEP): 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"{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) - - def __call__(self, default=None, cast=None, from_pass=False): + 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__( + self, default: ellipsis = Ellipsis, cast: None = None, from_pass: bool = False + ) -> str: ... + + @overload + def __call__( + self, default: T, cast: None = None, from_pass: bool = False + ) -> Union[T, str]: ... + + @overload + def __call__( + self, + default: ellipsis = Ellipsis, + 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: Any = Ellipsis, + 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 + 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 = None - if self.env is not None: - retval = self.env - elif self.value is not None: - retval = self.value - elif default is not None: + retval: Any + + if self.__env is not None: + retval = self.__env + elif self.__value is not None: + retval = self.__value + elif default != Ellipsis: retval = 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) 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 attribute that does not exist. + + Args: + name: Name of the attribute that was requested - def __getattr__(self, name): - """Get attr that does not exist.""" + Raises: + ConfigItemNotFound: Always, since the attribute doesn't exist + """ raise ConfigItemNotFound(f"section {name} not found") 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: - 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 {} - self.sep = sep - self.prefix = f"{self.name}{self.sep}".upper() - - def __getattr__(self, val): - """Get a new attribute.""" - return ConfigAttribute(val, self.items.get(val), self.name, sep=self.sep) - - def __call__(self, default=None, cast=None): - """Get attribute called as section.""" - return ConfigAttribute(self.name, None, None, sep=self.sep)( - default=default, cast=cast - ) + 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 configuration attribute. + + Args: + val: Name of the attribute to retrieve - def to_dict(self): - """Return as dict. + 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. Args: - None + 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 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 = 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 +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: - 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 = 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 = ( + f"__YOUCONFIGME_INTERNAL_SECTION_{id(self)}__" + ) + 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: + 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): + def _init_from_str(self, str_like: Union[str, Path]) -> None: try: - buf = io.StringIO(str_like) - config_parser = ConfigParser(default_section=self.fake_default_section) + 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 = 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": @@ -245,36 +342,46 @@ def _init_from_str(self, str_like): else: raise FileNotFoundError from e - def __getattr__(self, name): - """Get new section.""" - return ConfigSection(name, None, self.sep) - - def to_dict(self): - """Return as dict. + def __getattr__(self, name: str) -> ConfigSection: + """Get new configuration section. Args: - None + 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, Union[Dict[str, str], str]]: + """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 = {} - for section in self.config_sections: + 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: + for attribute in self.__config_attributes: ret_dict[attribute] = self.__getattribute__(attribute)() return ret_dict def to_dotenv(self) -> str: - """Return as .env file""" - lines = [] + """Return configuration as .env file format. + + Returns: + 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: - for k1, v1 in v.items(): - lines.append(f"{k}{self.sep}{k1}={v1}".upper()) - except AttributeError: + # 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()) return "\n".join(sorted(lines)) @@ -285,15 +392,23 @@ 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 + 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: + 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: diff --git a/youconfigme/youconfigme.pyi b/youconfigme/youconfigme.pyi new file mode 100644 index 0000000..bde4dc4 --- /dev/null +++ b/youconfigme/youconfigme.pyi @@ -0,0 +1,89 @@ +"""Type stubs for youconfigme""" + +import logging +from pathlib import Path +from typing import (Any, Callable, Dict, List, Mapping, Optional, TypeVar, + 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") + +DEFAULT_SECTION: str +INI_FILE: str +DEFAULT_SEP: str + +def config_logger(name: str) -> logging.Logger: ... + +class ConfigItemNotFound(Exception): ... + +class ConfigAttribute: + def __init__( + self, + name: str, + value: Optional[Any], + section_name: Optional[str], + sep: str = ..., + ) -> None: ... + @overload + def __call__( + 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]: ... + @overload + def __call__( + 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: + def __init__( + self, name: str, items: Optional[Mapping[str, Any]], sep: str = ... + ) -> None: ... + def __getattr__(self, val: str) -> ConfigAttribute: ... + def __call__( + 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: + 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, Union[Dict[str, str], str]]: ... + def to_dotenv(self) -> str: ... + +class AutoConfig(Config): + def __init__( + self, max_up_levels: int = ..., filename: str = ..., sep: str = ... + ) -> None: ...