Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .mailmap
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
claude[AI] <noreply@anthropic.com>
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
29 changes: 27 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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`)
Expand All @@ -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:
Expand Down Expand Up @@ -118,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)
113 changes: 84 additions & 29 deletions envwrap/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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', ''))
TRUES = frozenset(('true', 'yes', 'on', '1', 'y', 't'))
FALSES = frozenset(('false', 'no', 'off', '0', 'n', 'f', ''))
log = logging.getLogger(__name__)


Expand All @@ -37,8 +47,7 @@
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
Expand All @@ -49,8 +58,8 @@


@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:

Check warning on line 61 in envwrap/__init__.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

envwrap/__init__.py#L61

Method _defaults has 54 lines of code (limit is 50)

Check failure on line 61 in envwrap/__init__.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

envwrap/__init__.py#L61

Method _defaults has a cyclomatic complexity of 23 (limit is 15)

Check warning on line 61 in envwrap/__init__.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

envwrap/__init__.py#L61

_defaults is too complex (21) (MC0001)
"""config, env"""

Check notice on line 62 in envwrap/__init__.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

envwrap/__init__.py#L62

First line should end with a period, question mark, or exclamation point (not 'v') (D415)
conf = PlatformDirs(name, False)
overrides = {}
log.debug("Searching in pyproject.toml::tool.%s", name)
Expand Down Expand Up @@ -99,26 +108,67 @@
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):
"""`typ(value)` but:

Check notice on line 130 in envwrap/__init__.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

envwrap/__init__.py#L130

1 blank line required between summary line and description (found 0) (D205)

Check notice on line 130 in envwrap/__init__.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

envwrap/__init__.py#L130

First line should end with a period, question mark, or exclamation point (not ':') (D415)

Check notice on line 130 in envwrap/__init__.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

envwrap/__init__.py#L130

Multi-line docstring summary should start at the second line (D213)
- 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):

Check warning on line 134 in envwrap/__init__.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

envwrap/__init__.py#L134

Use isinstance() rather than type() for a typecheck.
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}")
return typ(value)


def envwrap(name: str, app: str = "", types: dict = None, is_method=False):
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, convert_config=True):
"""Function decorator overriding default arguments.

Precedence (descending):
Expand All @@ -134,6 +184,13 @@
- ./`pyproject.toml::tool.name.{app.func.a,func.a,app.a,a}`
- signature (`def foo(a=1)`)

Typecasting precedence (descending):
- if `convert_config=False`: unconverted config file value
- typehint
- default value's type
- `types[...]`
- unconverted

Parameters
----------
name:
Expand All @@ -146,6 +203,8 @@
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
--------
Expand All @@ -169,28 +228,24 @@

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 in overrides:
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:
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`)
pass
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)
except Exception:
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)

Expand Down
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading