From 96bce3b3e2f00f354dc38caa128ea7252cf3b91b Mon Sep 17 00:00:00 2001 From: Casper da Costa-Luis Date: Sat, 5 Sep 2026 21:52:35 +0100 Subject: [PATCH 1/9] minor framework updates --- .pre-commit-config.yaml | 2 +- README.md | 2 +- pyproject.toml | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3b7441b..be2af24 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -68,6 +68,6 @@ repos: args: [-i] additional_dependencies: [toml] - repo: https://github.com/PyCQA/isort - rev: 9.0.0b1 + rev: 9.0.1 hooks: - id: isort diff --git a/README.md b/README.md index 257ce94..a815afd 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![CI](https://github.com/tqdm/envwrap/actions/workflows/test.yml/badge.svg)](https://github.com/tqdm/envwrap/actions/workflows/test.yml) [![coveralls](https://img.shields.io/coveralls/github/tqdm/envwrap/main?logo=coveralls)](https://coveralls.io/github/tqdm/envwrap) -[![codecov](https://codecov.io/gh/tqdm/envwrap/graph/badge.svg?token=PEWICBIPVW)](https://codecov.io/gh/tqdm/envwrap) +[![codecov](https://codecov.io/gh/tqdm/envwrap/branch/main/graph/badge.svg)](https://codecov.io/gh/tqdm/envwrap) [![codacy](https://app.codacy.com/project/badge/Grade/6ca7a441560444489fd5c5b1548ab0de)](https://app.codacy.com/gh/tqdm/envwrap/dashboard) [![releases](https://img.shields.io/pypi/v/envwrap.svg?label=changelog)](https://github.com/tqdm/envwrap/releases) diff --git a/pyproject.toml b/pyproject.toml index 2302224..35fd4b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,7 +70,6 @@ blank_line_before_nested_class_or_def = false [tool.isort] line_length = 99 -multi_line_output = 4 known_first_party = ["envwrap", "tests"] [tool.pytest.ini_options] From 71238db9491f27dbf55537ede27911b314e0d5a0 Mon Sep 17 00:00:00 2001 From: Casper da Costa-Luis Date: Sat, 5 Sep 2026 22:22:39 +0100 Subject: [PATCH 2/9] support None type --- envwrap/__init__.py | 31 ++++++++++++++++++++++++------- tests/test_envwrap.py | 12 ++++++++++++ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/envwrap/__init__.py b/envwrap/__init__.py index ce69490..13d5425 100644 --- a/envwrap/__init__.py +++ b/envwrap/__init__.py @@ -115,6 +115,10 @@ def cast(value, typ): if val in ('false', 'no', 'off', '0', 'n', 'f', ''): return False raise TypeError(f"{typ}: {val}") + if typ is type(None) or typ is None: + if value.strip().lower() in ('none', 'null', 'nil', 'undefined', ''): + return None + raise TypeError(f"{typ}: {value}") return typ(value) @@ -175,21 +179,34 @@ def wrap(func): log.debug("Loaded overrides for %s: %s", func.__name__, overrides) # infer overrides' `type`s for k in overrides: + success = False param = params[k] - if param.annotation is not param.empty: # typehints + if param.annotation is not param.empty: # typehints for typ in getattr(param.annotation, '__args__', (param.annotation,)): try: overrides[k] = cast(overrides[k], typ) except Exception: log.debug("Failed to convert %s to %s", overrides[k], typ) else: + success = True break - elif param.default is not None: # type of default value - overrides[k] = cast(overrides[k], type(param.default)) - else: - try: # `types` fallback - overrides[k] = cast(overrides[k], types[k]) - except KeyError: # keep unconverted (`str`) + if not success and param.default is not None: # type of default value + try: + overrides[k] = cast(overrides[k], type(param.default)) + success = True + except Exception: + log.debug("Failed to convert %s to %s", overrides[k], type(param.default)) + if not success: # `types` fallback + try: + for typ in getattr(types[k], '__args__', (types[k],)): + try: + overrides[k] = cast(overrides[k], typ) + except Exception: + log.debug("Failed to convert %s to %s", overrides[k], typ) + else: + success = True + break + except KeyError: # keep unconverted (`str`) pass log.debug("Typed overrides: %s", overrides) return part(func, **overrides) diff --git a/tests/test_envwrap.py b/tests/test_envwrap.py index 59131a6..f435c77 100644 --- a/tests/test_envwrap.py +++ b/tests/test_envwrap.py @@ -3,6 +3,7 @@ from pathlib import Path from sys import version_info from textwrap import dedent +from typing import Optional import pytest @@ -157,3 +158,14 @@ def func(default_true=True, default_false=False, annotated: bool = None, fallbac return default_true, default_false, annotated, fallback assert (False, True, False, True) == func() + + +def test_none(monkeypatch): + for k, v in {'optional': "none", 'fallback': "", 'keep': "nil", 'empty': ""}.items(): + monkeypatch.setenv(f"NONEWRAP_{k}", v) + + @envwrap("nonewrap", types={'fallback': Optional[int]}) + def func(optional: Optional[int] = 5, fallback=1, keep="s", empty="s"): + return optional, fallback, keep, empty + + assert func() == (None, None, "nil", "") From ab9a64bace92a292c843cb2984935defba4411fa Mon Sep 17 00:00:00 2001 From: Casper da Costa-Luis Date: Sat, 5 Sep 2026 22:27:42 +0100 Subject: [PATCH 3/9] tests: simpler py3.8 support, monkeypatch.chdir --- envwrap/__init__.py | 3 +-- tests/test_envwrap.py | 63 ++++++++++++++++++------------------------- 2 files changed, 27 insertions(+), 39 deletions(-) diff --git a/envwrap/__init__.py b/envwrap/__init__.py index 13d5425..2f78860 100644 --- a/envwrap/__init__.py +++ b/envwrap/__init__.py @@ -37,8 +37,7 @@ def read_config(fpath: PurePath) -> dict: for sec in parser.sections(): if sec.count('.') == 1: parent, child = sec.split('.', 1) - res.setdefault(parent, {}).setdefault(child, {}) - res[parent][child] |= parser.items(sec) + res.setdefault(parent, {}).setdefault(child, {}).update(parser.items(sec)) elif sec.count('.') > 1: warn(f"Skipping nested section: {sec}", UserWarning, stacklevel=2) return res diff --git a/tests/test_envwrap.py b/tests/test_envwrap.py index f435c77..cd9099b 100644 --- a/tests/test_envwrap.py +++ b/tests/test_envwrap.py @@ -1,7 +1,6 @@ import os import shutil from pathlib import Path -from sys import version_info from textwrap import dedent from typing import Optional @@ -57,47 +56,37 @@ def test_env(): @pytest.mark.parametrize('ext', ['toml', 'yaml', 'yml', 'json', 'ini', 'cfg']) @pytest.mark.parametrize('base', ['cfgwrap', 'testcfg']) -def test_conf(tmp_path, base, ext): - if version_info < (3, 9) and ext in ('ini', 'cfg'): - pytest.skip("configparser dict merging requires python>=3.9") +def test_conf(tmp_path, monkeypatch, base, ext): config = { 'testcfg': {'b': 43, 'c': 1338, 'd': 361, 'funcname': {'f': 405}}, 'funcname': {'e': 102, 'a': 0}, 'cfgwrap': {'b': -1, 'e': -2, 'f': -3, 'funcname': {'e': -4}}} write_config(tmp_path / f"{base}.{ext}", config) - pwd = os.curdir - os.chdir(tmp_path) - try: - wrapped = envwrap('cfgwrap', 'testcfg')(funcname) - if base == 'cfgwrap': - assert wrapped(c=98) == {'a': 0, 'b': 43, 'c': 98, 'd': 361, 'e': 102, 'f': 405} - else: - assert wrapped(c=98) == {'a': None, 'b': 2, 'c': 98, 'd': 4, 'e': 5, 'f': 6} - assert int(get_defaults(base, 'testcfg', 'funcname')['a']) == 0 - assert int(get_defaults(base, 'testcfg', 'funcname')['f']) == 405 - assert int(get_defaults(base, 'testcfg', 'miss-n/a')['d']) == 361 - assert int(get_defaults(base, 'cfgwrap', 'funcname')['b']) == -1 - assert int(get_defaults(base, 'cfgwrap', 'miss-n/a')['e']) == -2 - assert int(get_defaults(base, 'cfgwrap', 'funcname')['f']) == -3 - assert int(get_defaults(base, 'cfgwrap', 'funcname')['e']) == -4 - finally: - os.chdir(pwd) - - -def test_pyproject(tmp_path): - pwd = os.curdir - os.chdir(tmp_path) - try: - shutil.copy(Path(__file__).parent.parent / "pyproject.toml", "pyproject.toml") - for tool, key in (('isort', 'line_length'), ('flake8', 'max_line_length'), - ('yapf', 'column_limit')): - assert get_defaults(tool, '', '')[key] == 99 - - assert get_defaults('coverage', '', 'report')['show_missing'] is True - assert get_defaults('coverage', 'report', '')['show_missing'] is True - assert get_defaults('coverage', 'report', 'show_missing')['report']['show_missing'] is True - finally: - os.chdir(pwd) + monkeypatch.chdir(tmp_path) + wrapped = envwrap('cfgwrap', 'testcfg')(funcname) + if base == 'cfgwrap': + assert wrapped(c=98) == {'a': 0, 'b': 43, 'c': 98, 'd': 361, 'e': 102, 'f': 405} + else: + assert wrapped(c=98) == {'a': None, 'b': 2, 'c': 98, 'd': 4, 'e': 5, 'f': 6} + assert int(get_defaults(base, 'testcfg', 'funcname')['a']) == 0 + assert int(get_defaults(base, 'testcfg', 'funcname')['f']) == 405 + assert int(get_defaults(base, 'testcfg', 'miss-n/a')['d']) == 361 + assert int(get_defaults(base, 'cfgwrap', 'funcname')['b']) == -1 + assert int(get_defaults(base, 'cfgwrap', 'miss-n/a')['e']) == -2 + assert int(get_defaults(base, 'cfgwrap', 'funcname')['f']) == -3 + assert int(get_defaults(base, 'cfgwrap', 'funcname')['e']) == -4 + + +def test_pyproject(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + shutil.copy(Path(__file__).parent.parent / "pyproject.toml", "pyproject.toml") + for tool, key in (('isort', 'line_length'), ('flake8', 'max_line_length'), ('yapf', + 'column_limit')): + assert get_defaults(tool, '', '')[key] == 99 + + assert get_defaults('coverage', '', 'report')['show_missing'] is True + assert get_defaults('coverage', 'report', '')['show_missing'] is True + assert get_defaults('coverage', 'report', 'show_missing')['report']['show_missing'] is True def test_env_cli(capsys): From 92da4a6933a1529a17f0b24bf303d02ace45dc7f Mon Sep 17 00:00:00 2001 From: Casper da Costa-Luis Date: Sat, 5 Sep 2026 22:50:35 +0100 Subject: [PATCH 4/9] misc refactoring --- envwrap/__init__.py | 93 ++++++++++++++++++++++++++++----------------- 1 file changed, 58 insertions(+), 35 deletions(-) diff --git a/envwrap/__init__.py b/envwrap/__init__.py index 2f78860..c2cd0d8 100644 --- a/envwrap/__init__.py +++ b/envwrap/__init__.py @@ -10,10 +10,20 @@ cache = lru_cache(maxsize=None) from inspect import signature from pathlib import Path, PurePath +from typing import Union, get_args, get_origin from warnings import warn from platformdirs import PlatformDirs +try: + from types import UnionType # py>=3.10, yapf: disable + UNIONS = (Union, UnionType) +except ImportError: + UNIONS = (Union,) +CONTAINERS = list, tuple, set, frozenset, dict, bytes, bytearray +NONES = frozenset(('none', 'null', 'nil', 'undefined', '')) +TRUES = frozenset(('true', 'yes', 'on', '1', 'y', 't')) +FALSES = frozenset(('false', 'no', 'off', '0', 'n', 'f', '')) log = logging.getLogger(__name__) @@ -107,20 +117,47 @@ def get_defaults(name: str, app: str, func: str): def cast(value, typ): + """`typ(value)` but: + - supports word-like str conversion to `bool` and `None` + - passes non-`str` `value`s (e.g. already parsed upstream) + """ + if typ is None or typ is type(None): + if value is None or (isinstance(value, str) and value.strip().lower() in NONES): + return None + raise TypeError(f"{typ}: {value}") + base = get_origin(typ) or typ # `list[int]` -> `list` + if isinstance(base, type): + if issubclass(base, CONTAINERS): # would mangle `str`s char-by-char + raise TypeError(f"{typ}: unparseable; use `types={{'param_name': ast.literal_eval}}`") + if isinstance(value, base): + return value + if not isinstance(value, str): + return typ(value) if typ is bool: - val = value.strip().lower() - if val in ('true', 'yes', 'on', '1', 'y', 't'): + if (val := value.strip().lower()) in TRUES: return True - if val in ('false', 'no', 'off', '0', 'n', 'f', ''): + if val in FALSES: return False raise TypeError(f"{typ}: {val}") - if typ is type(None) or typ is None: - if value.strip().lower() in ('none', 'null', 'nil', 'undefined', ''): - return None - raise TypeError(f"{typ}: {value}") return typ(value) +def _iter_union_types(typ): + return get_args(typ) if get_origin(typ) in UNIONS else (typ,) + + +def _candidate_types(param, types, key): + if param.annotation is not param.empty: # typehints + yield from _iter_union_types(param.annotation) + if param.default is not param.empty: # type of default value + yield type(param.default) + try: + fallback = types[key] # `types` fallback (maybe a `defaultdict`) + except KeyError: + return + yield from _iter_union_types(fallback) + + def envwrap(name: str, app: str = "", types: dict = None, is_method=False): """Function decorator overriding default arguments. @@ -137,6 +174,12 @@ def envwrap(name: str, app: str = "", types: dict = None, is_method=False): - ./`pyproject.toml::tool.name.{app.func.a,func.a,app.a,a}` - signature (`def foo(a=1)`) + Typecasting precedence (descending): + - typehint + - default value's type + - `types[...]` + - unconverted + Parameters ---------- name: @@ -177,36 +220,16 @@ def wrap(func): overrides = {k: v for k, v in defaults.items() if k in params} log.debug("Loaded overrides for %s: %s", func.__name__, overrides) # infer overrides' `type`s - for k in overrides: - success = False - param = params[k] - if param.annotation is not param.empty: # typehints - for typ in getattr(param.annotation, '__args__', (param.annotation,)): - try: - overrides[k] = cast(overrides[k], typ) - except Exception: - log.debug("Failed to convert %s to %s", overrides[k], typ) - else: - success = True - break - if not success and param.default is not None: # type of default value + for k, value in overrides.items(): + for typ in _candidate_types(params[k], types, k): try: - overrides[k] = cast(overrides[k], type(param.default)) - success = True + overrides[k] = cast(value, typ) except Exception: - log.debug("Failed to convert %s to %s", overrides[k], type(param.default)) - if not success: # `types` fallback - try: - for typ in getattr(types[k], '__args__', (types[k],)): - try: - overrides[k] = cast(overrides[k], typ) - except Exception: - log.debug("Failed to convert %s to %s", overrides[k], typ) - else: - success = True - break - except KeyError: # keep unconverted (`str`) - pass + log.debug("Failed to convert %s to %s", value, typ) + else: + break + else: # keep unconverted (`str` or config type) + log.debug("Keeping %s=%r unconverted", k, value) log.debug("Typed overrides: %s", overrides) return part(func, **overrides) From 8595e1e9815839ee8815cb8ed7ed74c0fff91921 Mon Sep 17 00:00:00 2001 From: Casper da Costa-Luis Date: Sat, 5 Sep 2026 22:51:36 +0100 Subject: [PATCH 5/9] drop some None coercion strings --- envwrap/__init__.py | 2 +- tests/test_envwrap.py | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/envwrap/__init__.py b/envwrap/__init__.py index c2cd0d8..d18594f 100644 --- a/envwrap/__init__.py +++ b/envwrap/__init__.py @@ -21,7 +21,7 @@ except ImportError: UNIONS = (Union,) CONTAINERS = list, tuple, set, frozenset, dict, bytes, bytearray -NONES = frozenset(('none', 'null', 'nil', 'undefined', '')) +NONES = frozenset(('none', 'null', '')) TRUES = frozenset(('true', 'yes', 'on', '1', 'y', 't')) FALSES = frozenset(('false', 'no', 'off', '0', 'n', 'f', '')) log = logging.getLogger(__name__) diff --git a/tests/test_envwrap.py b/tests/test_envwrap.py index cd9099b..b1d4318 100644 --- a/tests/test_envwrap.py +++ b/tests/test_envwrap.py @@ -150,11 +150,14 @@ def func(default_true=True, default_false=False, annotated: bool = None, fallbac def test_none(monkeypatch): - for k, v in {'optional': "none", 'fallback': "", 'keep': "nil", 'empty': ""}.items(): + for k, v in { + 'optional': "none", 'hinted': "NULL", 'default': " none ", 'fallback': "", + 'keep': "none", 'empty': ""}.items(): monkeypatch.setenv(f"NONEWRAP_{k}", v) @envwrap("nonewrap", types={'fallback': Optional[int]}) - def func(optional: Optional[int] = 5, fallback=1, keep="s", empty="s"): - return optional, fallback, keep, empty + def func(optional: Optional[int] = 5, hinted: int = None, default=None, fallback=1, keep="s", + empty="s"): + return optional, hinted, default, fallback, keep, empty - assert func() == (None, None, "nil", "") + assert func() == (None, None, None, None, "none", "") From 9dd935defbca4efda23a609368349b46bfe0f7ff Mon Sep 17 00:00:00 2001 From: Casper da Costa-Luis Date: Sat, 5 Sep 2026 23:25:14 +0100 Subject: [PATCH 6/9] optionally don't typecast config file values --- envwrap/__init__.py | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/envwrap/__init__.py b/envwrap/__init__.py index d18594f..02eb729 100644 --- a/envwrap/__init__.py +++ b/envwrap/__init__.py @@ -58,8 +58,8 @@ def read_config(fpath: PurePath) -> dict: @cache -def get_defaults(name: str, app: str, func: str): - """In-memory (functools.cache) of overrides extracted from config files & env vars.""" +def _defaults(name: str, app: str, func: str) -> tuple: + """config, env""" conf = PlatformDirs(name, False) overrides = {} log.debug("Searching in pyproject.toml::tool.%s", name) @@ -108,12 +108,22 @@ def get_defaults(name: str, app: str, func: str): prefixes = name, f"{name}_{app}", f"{name}_{func}", f"{name}_{app}_{func}" else: prefixes = name, f"{name}_{func}" + env = {} for prefix in prefixes: prefix = prefix.upper() + "_" log.debug(f"Looking for variables: {prefix}*") - overrides.update( + env.update( (k[len(prefix):].lower(), v) for k, v in os.environ.items() if k.startswith(prefix)) - return overrides + return overrides, env + + +def get_defaults(name: str, app: str, func: str) -> dict: + """In-memory (functools.cache) of overrides extracted from config files & env vars.""" + config, env = _defaults(name, app, func) + return {**config, **env} + + +get_defaults.cache_clear = _defaults.cache_clear def cast(value, typ): @@ -158,7 +168,7 @@ def _candidate_types(param, types, key): yield from _iter_union_types(fallback) -def envwrap(name: str, app: str = "", types: dict = None, is_method=False): +def envwrap(name: str, app: str = "", types: dict = None, is_method=False, convert_config=True): """Function decorator overriding default arguments. Precedence (descending): @@ -175,6 +185,7 @@ def envwrap(name: str, app: str = "", types: dict = None, is_method=False): - signature (`def foo(a=1)`) Typecasting precedence (descending): + - if `convert_config=False`: unconverted config file value - typehint - default value's type - `types[...]` @@ -192,6 +203,8 @@ def envwrap(name: str, app: str = "", types: dict = None, is_method=False): Consider using `types=collections.defaultdict(lambda: ast.literal_eval)`. is_method: Whether to use `functools.partialmethod`. If (default: False) use `functools.partial`. + convert_config: + Whether (default: True) to typecast config file values (see precedence above). Examples -------- @@ -215,12 +228,15 @@ def envwrap(name: str, app: str = "", types: dict = None, is_method=False): def wrap(func): params = signature(func).parameters - defaults = get_defaults(name, app, func.__name__) + config, env = _defaults(name, app, func.__name__) # ignore unknown params - overrides = {k: v for k, v in defaults.items() if k in params} + overrides = {k: v for k, v in {**config, **env}.items() if k in params} log.debug("Loaded overrides for %s: %s", func.__name__, overrides) # infer overrides' `type`s for k, value in overrides.items(): + if not convert_config and k not in env: + log.debug("Keeping config %s=%r unconverted", k, value) + continue for typ in _candidate_types(params[k], types, k): try: overrides[k] = cast(value, typ) From da5b2ca237204344ac2b7df74fb65c6d5cff94eb Mon Sep 17 00:00:00 2001 From: Casper da Costa-Luis Date: Sat, 5 Sep 2026 23:40:48 +0100 Subject: [PATCH 7/9] document typecast precedence --- README.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a815afd..424a255 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,9 @@ def func(a=1): ... ``` -Precedence (descending): +## Lookup precedence + +In descending order: - call (`func(a=3)`) - environment (`NAME_APP_FUNC_A=2`, `NAME_FUNC_A=2`, `NAME_APP_A=2`, `NAME_A=2`) @@ -33,6 +35,25 @@ Precedence (descending): - ./`pyproject.toml::tool.name.{app.func.a,func.a,app.a,a}` - signature (`def foo(a=1)`) +## Typecasting precedence + +In descending order: + +- if `envwrap.envwrap(convert_config=False)`: unconverted config file value, +- if `convert_config=False`: unconverted config file value +- signature value's typehint +- signature default value's type +- `envwrap.envwrap(types={'param_name': type})` +- unconverted + +type | accepted string values +-- | -- +`bool` | `true`, `yes`, `on`, `1`, `y`, `t` / `false`, `no`, `off`, `0`, `n`, `f`, `` (empty) +`None` | `none`, `null`, `` (empty) + +> [!TIP] +> Containers (`list`, `dict`, ...) aren't parsed to avoid mangling strings; use e.g. `types={'param_name': ast.literal_eval}` instead. + ## Installation Any one of: From 3ced97a0720b7f496c11ab66363cc726a72cf82b Mon Sep 17 00:00:00 2001 From: Casper da Costa-Luis Date: Sat, 5 Sep 2026 23:41:49 +0100 Subject: [PATCH 8/9] add tests Assisted-by: Claude Opus 5 --- tests/test_envwrap.py | 165 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 163 insertions(+), 2 deletions(-) diff --git a/tests/test_envwrap.py b/tests/test_envwrap.py index b1d4318..ad4b1b0 100644 --- a/tests/test_envwrap.py +++ b/tests/test_envwrap.py @@ -1,12 +1,14 @@ import os import shutil +from ast import literal_eval +from collections import defaultdict from pathlib import Path from textwrap import dedent -from typing import Optional +from typing import List, Optional, Union import pytest -from envwrap import cli, envwrap, get_defaults +from envwrap import cast, cli, envwrap, get_defaults, read_config def write_config(fpath, cfg): @@ -149,6 +151,24 @@ def func(default_true=True, default_false=False, annotated: bool = None, fallbac assert (False, True, False, True) == func() +@pytest.mark.parametrize('typ,value,expect', [(bool, "Yes", True), (bool, " OFF ", False), + (bool, "", False), + (bool, True, True), (bool, 1, True), + (None, "Null", None), (type(None), "NONE", None), + (type(None), None, None), (int, "42", 42), + (int, 42, 42), (str, 42, "42"), (float, "1.5", 1.5), + (literal_eval, "[1, 2]", [1, 2])]) +def test_cast(typ, value, expect): + assert cast(value, typ) == expect + + +@pytest.mark.parametrize('typ,value', [(bool, "maybe"), (None, "42"), (type(None), 0), (int, "x"), + (list, "abc"), (List[int], "1,2"), (dict, "ab")]) +def test_cast_invalid(typ, value): + with pytest.raises((TypeError, ValueError)): + cast(value, typ) + + def test_none(monkeypatch): for k, v in { 'optional': "none", 'hinted': "NULL", 'default': " none ", 'fallback': "", @@ -161,3 +181,144 @@ def func(optional: Optional[int] = 5, hinted: int = None, default=None, fallback return optional, hinted, default, fallback, keep, empty assert func() == (None, None, None, None, "none", "") + + +def test_cast_cascade(monkeypatch): + monkeypatch.setenv('CASCADE_num', "3.7") # annotation fails -> type of default + monkeypatch.setenv('CASCADE_word', "seven") # annotation fails -> type of default + monkeypatch.setenv('CASCADE_data', "[1, 2]") # annotation & default fail -> `types` + monkeypatch.setenv('CASCADE_unknown', "?") # nothing works -> unconverted + + @envwrap("cascade", types={'word': int, 'data': literal_eval}) + def func(num: int = 1.5, word: int = "one", data: int = None, unknown: int = None): + return num, word, data, unknown + + assert func() == (3.7, "seven", [1, 2], "?") + + +def test_types_defaultdict(monkeypatch): + monkeypatch.setenv('DD_data', "{'a': 1}") + monkeypatch.setenv('DD_num', "0x10") + monkeypatch.setenv('DD_word', "seven") + + @envwrap("dd", types=defaultdict(lambda: literal_eval)) + def func(data=None, num=None, word=None): + return data, num, word + + assert func() == ({'a': 1}, 16, "seven") + + +def test_union(monkeypatch): + monkeypatch.setenv('UNIONWRAP_a', "1.5") + monkeypatch.setenv('UNIONWRAP_b', "on") + + @envwrap("unionwrap", types={'b': Union[int, bool]}) + def func(a: Union[int, float] = None, b=None): + return a, b + + assert func() == (1.5, True) + + +def test_containers(monkeypatch): + """`str`s should be kept whole rather than split into characters.""" + monkeypatch.setenv('CONTWRAP_items', "abc") + monkeypatch.setenv('CONTWRAP_typed', "1,2") + + @envwrap("contwrap") + def func(items: list = None, typed: List[int] = None): + return items, typed + + assert func() == ("abc", "1,2") + + +def test_no_default(monkeypatch): + monkeypatch.setenv('REQWRAP_a', "abc") + monkeypatch.setenv('REQWRAP_b', "2") + + @envwrap("reqwrap") + def func(a: int, b=1): + return a, b + + assert func() == ("abc", 2) + + +def test_method(monkeypatch): + monkeypatch.setenv('METHWRAP_x', "5") + + class Klass: + @envwrap("methwrap", is_method=True) + def meth(self, x: int = 1): + return x + + assert Klass().meth() == 5 + + +@pytest.mark.parametrize('convert_config,expect', [(True, (True, 3, "5", None)), + (False, (True, 3, 5, "none"))]) +def test_conf_types(tmp_path, monkeypatch, convert_config, expect): + """Values already parsed by config readers shouldn't be mangled.""" + write_config(tmp_path / "typwrap.toml", + {'flag': True, 'num': 3, 'name': 5, 'nothing': "none", 'both': "1"}) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv('TYPWRAP_envnum', "7") # env vars are always converted + monkeypatch.setenv('TYPWRAP_both', "9") # env wins over config -> converted + + @envwrap("typwrap", convert_config=convert_config) + def func(flag=False, num: int = 0, name: str = "", nothing: Optional[int] = 1, envnum: int = 0, + both: int = 0): + return flag, num, name, nothing, envnum, both + + assert func() == expect + (7, 9) + + +def test_conf_types_ini(tmp_path, monkeypatch): + """`ini`/`cfg` values are `str`s, so `convert_config=False` keeps them as such.""" + write_config(tmp_path / "iniwrap.ini", {'funcname': {'num': 3, 'flag': True}}) + monkeypatch.chdir(tmp_path) + + def funcname(num: int = 0, flag=False): + return num, flag + + assert envwrap("iniwrap")(funcname)() == (3, True) + assert envwrap("iniwrap", convert_config=False)(funcname)() == ("3", "True") + + +def test_cache_clear(monkeypatch): + assert 'x' not in get_defaults('cachewrap', '', 'funcname') + monkeypatch.setenv('CACHEWRAP_x', "1") + assert 'x' not in get_defaults('cachewrap', '', 'funcname') + get_defaults.cache_clear() + assert get_defaults('cachewrap', '', 'funcname')['x'] == "1" + + +def test_platform_dirs(tmp_path, monkeypatch): + """`platformdirs.{site,user}_config_path/{name,app}.*`""" + class Dirs: + site_config_path = tmp_path / "site" + user_config_path = tmp_path / "user" + + monkeypatch.setattr('envwrap.PlatformDirs', lambda *_, **__: Dirs) + write_config(Dirs.site_config_path / "dirwrap.toml", {'b': 1, 'testdir': {'c': 2}}) + write_config(Dirs.user_config_path / "testdir.json", {'d': 3, 'funcname': {'e': 4}}) + monkeypatch.chdir(tmp_path) + + defaults = get_defaults('dirwrap', 'testdir', 'funcname') + assert {k: defaults[k] for k in "bcde"} == {'b': 1, 'c': 2, 'd': 3, 'e': 4} + + +def test_bad_config(tmp_path, monkeypatch): + """Unparseable files should be ignored.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text("[tool.badwrap\n") + (tmp_path / "badwrap.json").write_text("{invalid") + assert get_defaults('badwrap', '', 'funcname') == {} + + +def test_read_config(tmp_path): + (fpath := tmp_path / "unsupported.txt").write_text("") + with pytest.raises(TypeError, match="Unsupported"): + read_config(fpath) + + (fpath := tmp_path / "nested.ini").write_text("[a.b.c]\nd = 1\n") + with pytest.warns(UserWarning, match="nested section"): + assert read_config(fpath) == {} From 95ba7d922813dd410ef0cf9f7ee69be53fd29bd6 Mon Sep 17 00:00:00 2001 From: Casper da Costa-Luis Date: Sat, 5 Sep 2026 23:51:23 +0100 Subject: [PATCH 9/9] docs: add git-fame contributors --- .mailmap | 1 + README.md | 4 ++++ 2 files changed, 5 insertions(+) create mode 100644 .mailmap diff --git a/.mailmap b/.mailmap new file mode 100644 index 0000000..e251c5e --- /dev/null +++ b/.mailmap @@ -0,0 +1 @@ +claude[AI] diff --git a/README.md b/README.md index 424a255..911de8f 100644 --- a/README.md +++ b/README.md @@ -139,3 +139,7 @@ will print: will use defaults: {'a': '42', 'b': 2, ...} ``` + +--- + +[![contributors](https://git-fame.cdcl.ml/gh/tqdm/envwrap?enum=1&auth=share)](https://git-fame.cdcl.ml/gh/tqdm/envwrap?enum=1&auth=share)