envwrap 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- envwrap-0.2.0/.github/FUNDING.yml +3 -0
- envwrap-0.2.0/.github/workflows/test.yml +74 -0
- envwrap-0.2.0/.gitignore +11 -0
- envwrap-0.2.0/.pre-commit-config.yaml +66 -0
- envwrap-0.2.0/LICENCE +7 -0
- envwrap-0.2.0/PKG-INFO +99 -0
- envwrap-0.2.0/README.md +65 -0
- envwrap-0.2.0/envwrap/__init__.py +172 -0
- envwrap-0.2.0/envwrap/cli.py +30 -0
- envwrap-0.2.0/envwrap.egg-info/PKG-INFO +99 -0
- envwrap-0.2.0/envwrap.egg-info/SOURCES.txt +16 -0
- envwrap-0.2.0/envwrap.egg-info/dependency_links.txt +1 -0
- envwrap-0.2.0/envwrap.egg-info/entry_points.txt +2 -0
- envwrap-0.2.0/envwrap.egg-info/requires.txt +10 -0
- envwrap-0.2.0/envwrap.egg-info/top_level.txt +2 -0
- envwrap-0.2.0/pyproject.toml +83 -0
- envwrap-0.2.0/setup.cfg +4 -0
- envwrap-0.2.0/tests/test_envwrap.py +115 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
name: ci
|
|
2
|
+
on:
|
|
3
|
+
push:
|
|
4
|
+
pull_request:
|
|
5
|
+
schedule: [{cron: '3 2 * * 6'}] # M H d m w (Sat 2:03)
|
|
6
|
+
jobs:
|
|
7
|
+
test:
|
|
8
|
+
if: github.event_name != 'pull_request' || !contains('OWNER,MEMBER,COLLABORATOR', github.event.pull_request.author_association)
|
|
9
|
+
strategy:
|
|
10
|
+
matrix:
|
|
11
|
+
python: [3.8, 3.9, '3.10', 3.11, 3.12, 3.13, 3.14]
|
|
12
|
+
os: [ubuntu]
|
|
13
|
+
include: [{os: macos, python: 3.13}, {os: windows, python: 3.13}]
|
|
14
|
+
runs-on: ${{ matrix.os }}-latest
|
|
15
|
+
timeout-minutes: 5
|
|
16
|
+
defaults: {run: {shell: bash}}
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v6
|
|
19
|
+
with: {fetch-depth: 0}
|
|
20
|
+
- uses: actions/setup-python@v6
|
|
21
|
+
with:
|
|
22
|
+
python-version: ${{ matrix.python }}
|
|
23
|
+
- run: pip install -e .[dev]
|
|
24
|
+
- run: pytest --cov=envwrap --cov-report=xml
|
|
25
|
+
- uses: coverallsapp/github-action@v2
|
|
26
|
+
with:
|
|
27
|
+
flag-name: ${{ matrix.python }}-${{ matrix.os }}
|
|
28
|
+
parallel: true
|
|
29
|
+
- uses: codecov/codecov-action@v6
|
|
30
|
+
with:
|
|
31
|
+
token: ${{ secrets.CODECOV_TOKEN }}
|
|
32
|
+
flags: ${{ matrix.python }}, ${{ matrix.os }}
|
|
33
|
+
- name: codacy
|
|
34
|
+
run: |
|
|
35
|
+
bash <(curl -fsL https://coverage.codacy.com/get.sh) report -l Python -r coverage.xml --partial || :
|
|
36
|
+
env:
|
|
37
|
+
CODACY_PROJECT_TOKEN: ${{ secrets.CODACY_PROJECT_TOKEN }}
|
|
38
|
+
finish:
|
|
39
|
+
continue-on-error: ${{ github.event_name != 'push' }}
|
|
40
|
+
needs: test
|
|
41
|
+
runs-on: ubuntu-slim
|
|
42
|
+
steps:
|
|
43
|
+
- uses: coverallsapp/github-action@v2
|
|
44
|
+
with: {parallel-finished: true}
|
|
45
|
+
- name: codacy
|
|
46
|
+
run: |
|
|
47
|
+
bash <(curl -fsL https://coverage.codacy.com/get.sh) final || :
|
|
48
|
+
env:
|
|
49
|
+
CODACY_PROJECT_TOKEN: ${{ secrets.CODACY_PROJECT_TOKEN }}
|
|
50
|
+
deploy:
|
|
51
|
+
needs: test
|
|
52
|
+
runs-on: ubuntu-latest
|
|
53
|
+
environment: pypi
|
|
54
|
+
permissions: {contents: write, id-token: write, packages: write}
|
|
55
|
+
steps:
|
|
56
|
+
- uses: actions/checkout@v6
|
|
57
|
+
with:
|
|
58
|
+
fetch-depth: 0
|
|
59
|
+
token: ${{ secrets.GH_TOKEN || github.token }}
|
|
60
|
+
- uses: actions/setup-python@v6
|
|
61
|
+
with: {python-version: '3.x'}
|
|
62
|
+
- id: dist
|
|
63
|
+
uses: casperdcl/deploy-pypi@v3
|
|
64
|
+
with:
|
|
65
|
+
upload: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags') }}
|
|
66
|
+
build: true
|
|
67
|
+
- if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
|
|
68
|
+
name: Release
|
|
69
|
+
run: |
|
|
70
|
+
changelog=$(git log --pretty='format:%d%n- %s%n%b---' $(git tag --sort=v:refname | tail -n2 | head -n1)..HEAD)
|
|
71
|
+
tag="${GITHUB_REF#refs/tags/}"
|
|
72
|
+
gh release create --title "tqdm $tag stable" --draft --notes "$changelog" "$tag" dist/${{ steps.dist.outputs.whl }} dist/${{ steps.dist.outputs.whl_asc }}
|
|
73
|
+
env:
|
|
74
|
+
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
envwrap-0.2.0/.gitignore
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
default_language_version:
|
|
2
|
+
python: python3
|
|
3
|
+
repos:
|
|
4
|
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
5
|
+
rev: v6.0.0
|
|
6
|
+
hooks:
|
|
7
|
+
- id: check-added-large-files
|
|
8
|
+
- id: check-case-conflict
|
|
9
|
+
- id: check-docstring-first
|
|
10
|
+
- id: check-executables-have-shebangs
|
|
11
|
+
- id: check-toml
|
|
12
|
+
- id: check-merge-conflict
|
|
13
|
+
- id: check-yaml
|
|
14
|
+
- id: debug-statements
|
|
15
|
+
- id: end-of-file-fixer
|
|
16
|
+
- id: mixed-line-ending
|
|
17
|
+
- id: sort-simple-yaml
|
|
18
|
+
- id: trailing-whitespace
|
|
19
|
+
exclude: ^README.rst$
|
|
20
|
+
- repo: local
|
|
21
|
+
hooks:
|
|
22
|
+
- id: todo
|
|
23
|
+
name: Check TODO
|
|
24
|
+
language: pygrep
|
|
25
|
+
entry: WIP
|
|
26
|
+
args: [-i]
|
|
27
|
+
types: [text]
|
|
28
|
+
exclude: ^(.pre-commit-config.yaml|.github/workflows/test.yml)$
|
|
29
|
+
- id: pytest
|
|
30
|
+
name: pytest
|
|
31
|
+
language: python
|
|
32
|
+
entry: env PYTHONPATH=. pytest
|
|
33
|
+
args: [--cov=envwrap]
|
|
34
|
+
types: [python]
|
|
35
|
+
pass_filenames: false
|
|
36
|
+
additional_dependencies:
|
|
37
|
+
- pytest-cov
|
|
38
|
+
- platformdirs
|
|
39
|
+
- PyYAML
|
|
40
|
+
- toml
|
|
41
|
+
- repo: https://github.com/PyCQA/bandit
|
|
42
|
+
rev: '1.9.4'
|
|
43
|
+
hooks:
|
|
44
|
+
- id: bandit
|
|
45
|
+
args: [-c, pyproject.toml]
|
|
46
|
+
- repo: https://github.com/PyCQA/flake8
|
|
47
|
+
rev: 7.3.0
|
|
48
|
+
hooks:
|
|
49
|
+
- id: flake8
|
|
50
|
+
args: [-j8]
|
|
51
|
+
additional_dependencies:
|
|
52
|
+
- flake8-broken-line
|
|
53
|
+
- flake8-bugbear
|
|
54
|
+
- flake8-comprehensions
|
|
55
|
+
- flake8-debugger
|
|
56
|
+
- flake8-isort
|
|
57
|
+
- flake8-pyproject
|
|
58
|
+
- repo: https://github.com/asottile/pyupgrade
|
|
59
|
+
rev: v3.21.2
|
|
60
|
+
hooks:
|
|
61
|
+
- id: pyupgrade
|
|
62
|
+
args: [--py38-plus]
|
|
63
|
+
- repo: https://github.com/PyCQA/isort
|
|
64
|
+
rev: 9.0.0a3
|
|
65
|
+
hooks:
|
|
66
|
+
- id: isort
|
envwrap-0.2.0/LICENCE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Mozilla Public License Version 2.0
|
|
2
|
+
==================================
|
|
3
|
+
|
|
4
|
+
This Source Code Form is subject to the terms of the
|
|
5
|
+
Mozilla Public License, v. 2.0.
|
|
6
|
+
If a copy of the MPL was not distributed with this project,
|
|
7
|
+
You can obtain one at https://mozilla.org/MPL/2.0/.
|
envwrap-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: envwrap
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Override parameter defaults via environment variables & config files
|
|
5
|
+
Maintainer-email: tqdm developers <devs@tqdm.ml>
|
|
6
|
+
License: MPL-2.0
|
|
7
|
+
Project-URL: repository, https://github.com/tqdm/envwrap
|
|
8
|
+
Project-URL: changelog, https://github.com/tqdm/envwrap/releases
|
|
9
|
+
Keywords: config,environment,env,defaults,partial,appdir,appdirs,platformdirs
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Operating System :: MacOS
|
|
20
|
+
Classifier: Operating System :: Microsoft
|
|
21
|
+
Classifier: Operating System :: POSIX
|
|
22
|
+
Classifier: Operating System :: Unix
|
|
23
|
+
Requires-Python: >=3.8
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENCE
|
|
26
|
+
Requires-Dist: platformdirs
|
|
27
|
+
Requires-Dist: toml; python_version < "3.11"
|
|
28
|
+
Requires-Dist: PyYAML
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=6; extra == "dev"
|
|
31
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
32
|
+
Requires-Dist: toml; extra == "dev"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# envwrap
|
|
36
|
+
|
|
37
|
+
[](https://github.com/tqdm/envwrap/actions/workflows/test.yml)
|
|
38
|
+
[](https://coveralls.io/github/tqdm/envwrap)
|
|
39
|
+
[](https://codecov.io/gh/tqdm/envwrap)
|
|
40
|
+
[](https://app.codacy.com/gh/tqdm/envwrap/dashboard)
|
|
41
|
+
|
|
42
|
+
Override parameter defaults via environment variables & config files.
|
|
43
|
+
|
|
44
|
+
```py
|
|
45
|
+
import envwrap
|
|
46
|
+
|
|
47
|
+
@envwrap.envwrap("name", "app")
|
|
48
|
+
def func(a=1):
|
|
49
|
+
...
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Precedence (descending):
|
|
53
|
+
|
|
54
|
+
- call (`func(a=3)`)
|
|
55
|
+
- environment (`NAME_APP_FUNC_A=2`, `NAME_FUNC_A=2`, `NAME_APP_A=2`, `NAME_A=2`)
|
|
56
|
+
- `UPPER_CASE` env vars -> `lower_case` param names
|
|
57
|
+
- other cases aren't supported because Windows ignores case
|
|
58
|
+
- config file:
|
|
59
|
+
- ./`{name}.{toml,yaml,yml,json,ini,cfg}::{app.func.a,func.a,app.a,a}`
|
|
60
|
+
- [platformdirs](https://platformdirs.readthedocs.io/en/latest/parameters.html).{user,site}_config_path(name, False)/
|
|
61
|
+
- `{app}.{toml,yaml,yml,json,ini,cfg}::{func.a,a}`
|
|
62
|
+
- `{name}.{toml,yaml,yml,json,ini,cfg}::{app.func.a,func.a,app.a,a}`
|
|
63
|
+
- signature (`def foo(a=1)`)
|
|
64
|
+
|
|
65
|
+
## Advanced Usage
|
|
66
|
+
|
|
67
|
+
### Live-reload
|
|
68
|
+
|
|
69
|
+
To force re-reading config files & environment variables without restarting the process:
|
|
70
|
+
|
|
71
|
+
```py
|
|
72
|
+
envwrap.get_defaults.cache_clear()
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Debugging
|
|
76
|
+
|
|
77
|
+
A CLI tool can print defaults. For example, with this config:
|
|
78
|
+
|
|
79
|
+
```toml
|
|
80
|
+
# config file: foo.toml
|
|
81
|
+
[test]
|
|
82
|
+
a = 1337
|
|
83
|
+
b = 2
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
python -m envwrap --help
|
|
88
|
+
FOO_A=42 python -m envwrap foo test
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
will print:
|
|
92
|
+
|
|
93
|
+
```py
|
|
94
|
+
>>> @envwrap.envwrap('foo', '')
|
|
95
|
+
>>> def test(...):
|
|
96
|
+
... ...
|
|
97
|
+
will use defaults:
|
|
98
|
+
{'a': '42', 'b': 2, ...}
|
|
99
|
+
```
|
envwrap-0.2.0/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# envwrap
|
|
2
|
+
|
|
3
|
+
[](https://github.com/tqdm/envwrap/actions/workflows/test.yml)
|
|
4
|
+
[](https://coveralls.io/github/tqdm/envwrap)
|
|
5
|
+
[](https://codecov.io/gh/tqdm/envwrap)
|
|
6
|
+
[](https://app.codacy.com/gh/tqdm/envwrap/dashboard)
|
|
7
|
+
|
|
8
|
+
Override parameter defaults via environment variables & config files.
|
|
9
|
+
|
|
10
|
+
```py
|
|
11
|
+
import envwrap
|
|
12
|
+
|
|
13
|
+
@envwrap.envwrap("name", "app")
|
|
14
|
+
def func(a=1):
|
|
15
|
+
...
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Precedence (descending):
|
|
19
|
+
|
|
20
|
+
- call (`func(a=3)`)
|
|
21
|
+
- environment (`NAME_APP_FUNC_A=2`, `NAME_FUNC_A=2`, `NAME_APP_A=2`, `NAME_A=2`)
|
|
22
|
+
- `UPPER_CASE` env vars -> `lower_case` param names
|
|
23
|
+
- other cases aren't supported because Windows ignores case
|
|
24
|
+
- config file:
|
|
25
|
+
- ./`{name}.{toml,yaml,yml,json,ini,cfg}::{app.func.a,func.a,app.a,a}`
|
|
26
|
+
- [platformdirs](https://platformdirs.readthedocs.io/en/latest/parameters.html).{user,site}_config_path(name, False)/
|
|
27
|
+
- `{app}.{toml,yaml,yml,json,ini,cfg}::{func.a,a}`
|
|
28
|
+
- `{name}.{toml,yaml,yml,json,ini,cfg}::{app.func.a,func.a,app.a,a}`
|
|
29
|
+
- signature (`def foo(a=1)`)
|
|
30
|
+
|
|
31
|
+
## Advanced Usage
|
|
32
|
+
|
|
33
|
+
### Live-reload
|
|
34
|
+
|
|
35
|
+
To force re-reading config files & environment variables without restarting the process:
|
|
36
|
+
|
|
37
|
+
```py
|
|
38
|
+
envwrap.get_defaults.cache_clear()
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Debugging
|
|
42
|
+
|
|
43
|
+
A CLI tool can print defaults. For example, with this config:
|
|
44
|
+
|
|
45
|
+
```toml
|
|
46
|
+
# config file: foo.toml
|
|
47
|
+
[test]
|
|
48
|
+
a = 1337
|
|
49
|
+
b = 2
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
python -m envwrap --help
|
|
54
|
+
FOO_A=42 python -m envwrap foo test
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
will print:
|
|
58
|
+
|
|
59
|
+
```py
|
|
60
|
+
>>> @envwrap.envwrap('foo', '')
|
|
61
|
+
>>> def test(...):
|
|
62
|
+
... ...
|
|
63
|
+
will use defaults:
|
|
64
|
+
{'a': '42', 'b': 2, ...}
|
|
65
|
+
```
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Override parameter defaults via environment variables & config files."""
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
from functools import partial, partialmethod
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
from functools import cache # py>=3.9
|
|
8
|
+
except ImportError:
|
|
9
|
+
from functools import lru_cache
|
|
10
|
+
cache = lru_cache(maxsize=None)
|
|
11
|
+
from inspect import signature
|
|
12
|
+
from pathlib import Path, PurePath
|
|
13
|
+
from warnings import warn
|
|
14
|
+
|
|
15
|
+
from platformdirs import PlatformDirs
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def read_config(fpath: PurePath) -> dict:
|
|
21
|
+
log.debug("Reading: %s", fpath)
|
|
22
|
+
ext = fpath.suffix.lower()[1:]
|
|
23
|
+
if ext == "toml":
|
|
24
|
+
try:
|
|
25
|
+
from tomllib import loads # py>=3.11
|
|
26
|
+
except ModuleNotFoundError:
|
|
27
|
+
from toml import loads
|
|
28
|
+
elif ext in ("yaml", "yml"):
|
|
29
|
+
from yaml import safe_load as loads
|
|
30
|
+
elif ext == "json":
|
|
31
|
+
from json import loads
|
|
32
|
+
elif ext in ("ini", "cfg"):
|
|
33
|
+
from configparser import ConfigParser
|
|
34
|
+
parser = ConfigParser()
|
|
35
|
+
parser.read_string(fpath.read_text())
|
|
36
|
+
res = {sec: dict(parser.items(sec)) for sec in parser.sections() if sec.count(".") == 0}
|
|
37
|
+
for sec in parser.sections():
|
|
38
|
+
if sec.count(".") == 1:
|
|
39
|
+
parent, child = sec.split(".", 1)
|
|
40
|
+
res.setdefault(parent, {}).setdefault(child, {})
|
|
41
|
+
res[parent][child] |= parser.items(sec)
|
|
42
|
+
elif sec.count(".") > 1:
|
|
43
|
+
warn(f"Skipping nested section: {sec}", UserWarning, stacklevel=2)
|
|
44
|
+
return res
|
|
45
|
+
else:
|
|
46
|
+
raise TypeError(f"Unsupported config filetype: {fpath}")
|
|
47
|
+
|
|
48
|
+
return loads(fpath.read_text())
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@cache
|
|
52
|
+
def get_defaults(name: str, app: str, func: str):
|
|
53
|
+
"""In-memory (functools.cache) of overrides extracted from config files & env vars."""
|
|
54
|
+
conf = PlatformDirs(name, False)
|
|
55
|
+
overrides = {}
|
|
56
|
+
for pth, base in (
|
|
57
|
+
(conf.site_config_path, name),
|
|
58
|
+
(conf.site_config_path, app),
|
|
59
|
+
(conf.user_config_path, name),
|
|
60
|
+
(conf.user_config_path, app),
|
|
61
|
+
(Path("."), name),
|
|
62
|
+
):
|
|
63
|
+
if not base:
|
|
64
|
+
continue
|
|
65
|
+
log.debug("Searching in %s/%s.*", pth, base)
|
|
66
|
+
for ext in ('cfg', 'ini', 'json', 'yml', 'yaml', 'toml'):
|
|
67
|
+
if (fpath := pth / f"{base}.{ext}").is_file():
|
|
68
|
+
try:
|
|
69
|
+
cfg = read_config(fpath)
|
|
70
|
+
overrides.update(cfg)
|
|
71
|
+
if base == name:
|
|
72
|
+
# app.func.a,func.a,app.a,a
|
|
73
|
+
if app in cfg:
|
|
74
|
+
overrides.update(cfg[app])
|
|
75
|
+
if func in cfg:
|
|
76
|
+
overrides.update(cfg[func])
|
|
77
|
+
if app in cfg and func in cfg[app]:
|
|
78
|
+
overrides.update(cfg[app][func])
|
|
79
|
+
elif base == app:
|
|
80
|
+
# func.a,a
|
|
81
|
+
if func in cfg:
|
|
82
|
+
overrides.update(cfg[func])
|
|
83
|
+
except Exception as exc:
|
|
84
|
+
log.debug(f"Exception ignored: {exc}")
|
|
85
|
+
if app:
|
|
86
|
+
prefixes = name, f"{name}_{app}", f"{name}_{func}", f"{name}_{app}_{func}"
|
|
87
|
+
else:
|
|
88
|
+
prefixes = name, f"{name}_{func}"
|
|
89
|
+
for prefix in prefixes:
|
|
90
|
+
prefix = prefix.upper() + "_"
|
|
91
|
+
log.debug(f"Looking for variables: {prefix}*")
|
|
92
|
+
overrides.update(
|
|
93
|
+
(k[len(prefix):].lower(), v) for k, v in os.environ.items() if k.startswith(prefix))
|
|
94
|
+
return overrides
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def envwrap(name: str, app: str = "", types: dict = None, is_method=False):
|
|
98
|
+
"""Function decorator overriding default arguments.
|
|
99
|
+
|
|
100
|
+
Precedence (descending):
|
|
101
|
+
- call (`func(a=3)`)
|
|
102
|
+
- environment (`NAME_APP_FUNC_A=2`, `NAME_FUNC_A=2`, `NAME_APP_A=2`, `NAME_A=2`)
|
|
103
|
+
- `UPPER_CASE` env vars -> `lower_case` param names
|
|
104
|
+
- other cases aren't supported because Windows ignores case
|
|
105
|
+
- config file:
|
|
106
|
+
- ./`{name}.{toml,yaml,yml,json,ini,cfg}::{app.func.a,func.a,app.a,a}`
|
|
107
|
+
- platformdirs.{user,site}_config_path(name, False)/
|
|
108
|
+
- `{app}.{toml,yaml,yml,json,ini,cfg}::{func.a,a}`
|
|
109
|
+
- `{name}.{toml,yaml,yml,json,ini,cfg}::{app.func.a,func.a,app.a,a}`
|
|
110
|
+
- signature (`def foo(a=1)`)
|
|
111
|
+
|
|
112
|
+
Parameters
|
|
113
|
+
----------
|
|
114
|
+
name:
|
|
115
|
+
Configuration name.
|
|
116
|
+
app:
|
|
117
|
+
Application name.
|
|
118
|
+
types:
|
|
119
|
+
Fallback mappings `{'param_name': type, ...}` if types cannot be
|
|
120
|
+
inferred from function signature.
|
|
121
|
+
Consider using `types=collections.defaultdict(lambda: ast.literal_eval)`.
|
|
122
|
+
is_method:
|
|
123
|
+
Whether to use `functools.partialmethod`. If (default: False) use `functools.partial`.
|
|
124
|
+
|
|
125
|
+
Examples
|
|
126
|
+
--------
|
|
127
|
+
>>> os.environ.update(dict(FOO_A="7", FOO_TEST_A="42", FOO_C="1337"))
|
|
128
|
+
>>> from envwrap import envwrap
|
|
129
|
+
>>> @envwrap("FOO")
|
|
130
|
+
>>> def test(a=1, b=2, c=3):
|
|
131
|
+
... print(f"received: a={a}, b={b}, c={c}")
|
|
132
|
+
...
|
|
133
|
+
>>> test(c=99)
|
|
134
|
+
received: a=42, b=2, c=99
|
|
135
|
+
|
|
136
|
+
"""
|
|
137
|
+
if types is None:
|
|
138
|
+
types = {}
|
|
139
|
+
if name[-1] == "_":
|
|
140
|
+
name = name[:-1]
|
|
141
|
+
warn("Trailing underscore in `name` is automatic", DeprecationWarning, stacklevel=2)
|
|
142
|
+
|
|
143
|
+
part = partialmethod if is_method else partial
|
|
144
|
+
|
|
145
|
+
def wrap(func):
|
|
146
|
+
params = signature(func).parameters
|
|
147
|
+
env_overrides = get_defaults(name, app, func.__name__)
|
|
148
|
+
# ignore unknown env vars
|
|
149
|
+
overrides = {k: v for k, v in env_overrides.items() if k in params}
|
|
150
|
+
log.debug("Loaded overrides for %s: %s", func.__name__, overrides)
|
|
151
|
+
# infer overrides' `type`s
|
|
152
|
+
for k in overrides:
|
|
153
|
+
param = params[k]
|
|
154
|
+
if param.annotation is not param.empty: # typehints
|
|
155
|
+
for typ in getattr(param.annotation, '__args__', (param.annotation,)):
|
|
156
|
+
try:
|
|
157
|
+
overrides[k] = typ(overrides[k])
|
|
158
|
+
except Exception:
|
|
159
|
+
log.debug("Failed to convert %s to %s", overrides[k], typ)
|
|
160
|
+
else:
|
|
161
|
+
break
|
|
162
|
+
elif param.default is not None: # type of default value
|
|
163
|
+
overrides[k] = type(param.default)(overrides[k])
|
|
164
|
+
else:
|
|
165
|
+
try: # `types` fallback
|
|
166
|
+
overrides[k] = types[k](overrides[k])
|
|
167
|
+
except KeyError: # keep unconverted (`str`)
|
|
168
|
+
pass
|
|
169
|
+
log.debug("Typed overrides: %s", overrides)
|
|
170
|
+
return part(func, **overrides)
|
|
171
|
+
|
|
172
|
+
return wrap
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import logging
|
|
3
|
+
from pprint import pprint
|
|
4
|
+
|
|
5
|
+
from . import get_defaults
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main(argv=None):
|
|
9
|
+
parser = argparse.ArgumentParser(description="Print envwrap overrides")
|
|
10
|
+
parser.add_argument("args", nargs="+", help="<name> [<app>] <func>")
|
|
11
|
+
parser.add_argument("-v", "--verbose", action="count", default=0, help="print debug info")
|
|
12
|
+
|
|
13
|
+
args = parser.parse_args(argv)
|
|
14
|
+
logging.basicConfig(
|
|
15
|
+
level={0: logging.INFO, 1: logging.DEBUG}.get(args.verbose, logging.NOTSET))
|
|
16
|
+
if len(args.args) == 2:
|
|
17
|
+
app = ""
|
|
18
|
+
elif len(args.args) == 3:
|
|
19
|
+
app = args.args[1]
|
|
20
|
+
else:
|
|
21
|
+
raise ValueError("Usage: envwrap <config_name> [<app_name>] <func_name>")
|
|
22
|
+
name = args.args[0]
|
|
23
|
+
func = args.args[-1]
|
|
24
|
+
print(f">>> @envwrap.envwrap('{name}', '{app}')", f">>> def {func}(...):", "... ...",
|
|
25
|
+
"will use defaults:", sep="\n")
|
|
26
|
+
pprint(get_defaults(name, app, func))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
if __name__ == "__main__":
|
|
30
|
+
main() # pragma: no cover
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: envwrap
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Override parameter defaults via environment variables & config files
|
|
5
|
+
Maintainer-email: tqdm developers <devs@tqdm.ml>
|
|
6
|
+
License: MPL-2.0
|
|
7
|
+
Project-URL: repository, https://github.com/tqdm/envwrap
|
|
8
|
+
Project-URL: changelog, https://github.com/tqdm/envwrap/releases
|
|
9
|
+
Keywords: config,environment,env,defaults,partial,appdir,appdirs,platformdirs
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Operating System :: MacOS
|
|
20
|
+
Classifier: Operating System :: Microsoft
|
|
21
|
+
Classifier: Operating System :: POSIX
|
|
22
|
+
Classifier: Operating System :: Unix
|
|
23
|
+
Requires-Python: >=3.8
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENCE
|
|
26
|
+
Requires-Dist: platformdirs
|
|
27
|
+
Requires-Dist: toml; python_version < "3.11"
|
|
28
|
+
Requires-Dist: PyYAML
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=6; extra == "dev"
|
|
31
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
32
|
+
Requires-Dist: toml; extra == "dev"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# envwrap
|
|
36
|
+
|
|
37
|
+
[](https://github.com/tqdm/envwrap/actions/workflows/test.yml)
|
|
38
|
+
[](https://coveralls.io/github/tqdm/envwrap)
|
|
39
|
+
[](https://codecov.io/gh/tqdm/envwrap)
|
|
40
|
+
[](https://app.codacy.com/gh/tqdm/envwrap/dashboard)
|
|
41
|
+
|
|
42
|
+
Override parameter defaults via environment variables & config files.
|
|
43
|
+
|
|
44
|
+
```py
|
|
45
|
+
import envwrap
|
|
46
|
+
|
|
47
|
+
@envwrap.envwrap("name", "app")
|
|
48
|
+
def func(a=1):
|
|
49
|
+
...
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Precedence (descending):
|
|
53
|
+
|
|
54
|
+
- call (`func(a=3)`)
|
|
55
|
+
- environment (`NAME_APP_FUNC_A=2`, `NAME_FUNC_A=2`, `NAME_APP_A=2`, `NAME_A=2`)
|
|
56
|
+
- `UPPER_CASE` env vars -> `lower_case` param names
|
|
57
|
+
- other cases aren't supported because Windows ignores case
|
|
58
|
+
- config file:
|
|
59
|
+
- ./`{name}.{toml,yaml,yml,json,ini,cfg}::{app.func.a,func.a,app.a,a}`
|
|
60
|
+
- [platformdirs](https://platformdirs.readthedocs.io/en/latest/parameters.html).{user,site}_config_path(name, False)/
|
|
61
|
+
- `{app}.{toml,yaml,yml,json,ini,cfg}::{func.a,a}`
|
|
62
|
+
- `{name}.{toml,yaml,yml,json,ini,cfg}::{app.func.a,func.a,app.a,a}`
|
|
63
|
+
- signature (`def foo(a=1)`)
|
|
64
|
+
|
|
65
|
+
## Advanced Usage
|
|
66
|
+
|
|
67
|
+
### Live-reload
|
|
68
|
+
|
|
69
|
+
To force re-reading config files & environment variables without restarting the process:
|
|
70
|
+
|
|
71
|
+
```py
|
|
72
|
+
envwrap.get_defaults.cache_clear()
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Debugging
|
|
76
|
+
|
|
77
|
+
A CLI tool can print defaults. For example, with this config:
|
|
78
|
+
|
|
79
|
+
```toml
|
|
80
|
+
# config file: foo.toml
|
|
81
|
+
[test]
|
|
82
|
+
a = 1337
|
|
83
|
+
b = 2
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
python -m envwrap --help
|
|
88
|
+
FOO_A=42 python -m envwrap foo test
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
will print:
|
|
92
|
+
|
|
93
|
+
```py
|
|
94
|
+
>>> @envwrap.envwrap('foo', '')
|
|
95
|
+
>>> def test(...):
|
|
96
|
+
... ...
|
|
97
|
+
will use defaults:
|
|
98
|
+
{'a': '42', 'b': 2, ...}
|
|
99
|
+
```
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
.gitignore
|
|
2
|
+
.pre-commit-config.yaml
|
|
3
|
+
LICENCE
|
|
4
|
+
README.md
|
|
5
|
+
pyproject.toml
|
|
6
|
+
.github/FUNDING.yml
|
|
7
|
+
.github/workflows/test.yml
|
|
8
|
+
envwrap/__init__.py
|
|
9
|
+
envwrap/cli.py
|
|
10
|
+
envwrap.egg-info/PKG-INFO
|
|
11
|
+
envwrap.egg-info/SOURCES.txt
|
|
12
|
+
envwrap.egg-info/dependency_links.txt
|
|
13
|
+
envwrap.egg-info/entry_points.txt
|
|
14
|
+
envwrap.egg-info/requires.txt
|
|
15
|
+
envwrap.egg-info/top_level.txt
|
|
16
|
+
tests/test_envwrap.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=42", "setuptools-scm[toml]>=3.4"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[tool.setuptools_scm]
|
|
6
|
+
|
|
7
|
+
[tool.setuptools.packages.find]
|
|
8
|
+
exclude = ["tests"]
|
|
9
|
+
|
|
10
|
+
[project.urls]
|
|
11
|
+
repository = "https://github.com/tqdm/envwrap"
|
|
12
|
+
changelog = "https://github.com/tqdm/envwrap/releases"
|
|
13
|
+
|
|
14
|
+
[project]
|
|
15
|
+
name = "envwrap"
|
|
16
|
+
dynamic = ["version"]
|
|
17
|
+
maintainers = [{name = "tqdm developers", email = "devs@tqdm.ml"}]
|
|
18
|
+
description = "Override parameter defaults via environment variables & config files"
|
|
19
|
+
readme = "README.md"
|
|
20
|
+
requires-python = ">=3.8"
|
|
21
|
+
keywords = ["config", "environment", "env", "defaults", "partial", "appdir", "appdirs", "platformdirs"]
|
|
22
|
+
license = {text = "MPL-2.0"}
|
|
23
|
+
classifiers = [
|
|
24
|
+
"Development Status :: 4 - Beta",
|
|
25
|
+
"Intended Audience :: Developers",
|
|
26
|
+
"Programming Language :: Python :: 3.8",
|
|
27
|
+
"Programming Language :: Python :: 3.9",
|
|
28
|
+
"Programming Language :: Python :: 3.10",
|
|
29
|
+
"Programming Language :: Python :: 3.11",
|
|
30
|
+
"Programming Language :: Python :: 3.12",
|
|
31
|
+
"Programming Language :: Python :: 3.13",
|
|
32
|
+
"Programming Language :: Python :: 3.14",
|
|
33
|
+
"Operating System :: MacOS",
|
|
34
|
+
"Operating System :: Microsoft",
|
|
35
|
+
"Operating System :: POSIX",
|
|
36
|
+
"Operating System :: Unix"]
|
|
37
|
+
dependencies = [
|
|
38
|
+
"platformdirs",
|
|
39
|
+
"toml; python_version < '3.11'",
|
|
40
|
+
"PyYAML",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[project.optional-dependencies]
|
|
44
|
+
dev = ["pytest>=6", "pytest-cov", "toml"]
|
|
45
|
+
|
|
46
|
+
[project.scripts]
|
|
47
|
+
envwrap = "envwrap.cli:main"
|
|
48
|
+
|
|
49
|
+
[tool.bandit]
|
|
50
|
+
exclude_dirs = ["tests"]
|
|
51
|
+
|
|
52
|
+
[tool.flake8]
|
|
53
|
+
max_line_length = 99
|
|
54
|
+
extend_ignore = ["E261"]
|
|
55
|
+
exclude = [".git", "__pycache__", "build", "dist", ".eggs"]
|
|
56
|
+
|
|
57
|
+
[tool.yapf]
|
|
58
|
+
spaces_before_comment = [15, 20]
|
|
59
|
+
arithmetic_precedence_indication = true
|
|
60
|
+
allow_split_before_dict_value = false
|
|
61
|
+
coalesce_brackets = true
|
|
62
|
+
column_limit = 99
|
|
63
|
+
each_dict_entry_on_separate_line = false
|
|
64
|
+
space_between_ending_comma_and_closing_bracket = false
|
|
65
|
+
split_before_named_assigns = false
|
|
66
|
+
split_before_closing_bracket = false
|
|
67
|
+
blank_line_before_nested_class_or_def = false
|
|
68
|
+
|
|
69
|
+
[tool.isort]
|
|
70
|
+
line_length = 99
|
|
71
|
+
multi_line_output = 4
|
|
72
|
+
known_first_party = ["envwrap", "tests"]
|
|
73
|
+
|
|
74
|
+
[tool.pytest.ini_options]
|
|
75
|
+
minversion = "6.0"
|
|
76
|
+
log_level = "INFO"
|
|
77
|
+
testpaths = ["tests"]
|
|
78
|
+
addopts = "-v --tb=short -rxs -W=error --durations=0 --durations-min=0.1"
|
|
79
|
+
|
|
80
|
+
[tool.coverage.run]
|
|
81
|
+
branch = true
|
|
82
|
+
[tool.coverage.report]
|
|
83
|
+
show_missing = true
|
envwrap-0.2.0/setup.cfg
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from sys import version_info
|
|
3
|
+
from textwrap import dedent
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from envwrap import cli, envwrap, get_defaults
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def write_config(fpath, cfg):
|
|
11
|
+
ext = fpath.suffix.lower()[1:]
|
|
12
|
+
fpath.parent.mkdir(parents=True, exist_ok=True)
|
|
13
|
+
if ext == "toml":
|
|
14
|
+
from toml import dumps
|
|
15
|
+
elif ext in ("yaml", "yml"):
|
|
16
|
+
from yaml import safe_dump as dumps
|
|
17
|
+
elif ext == "json":
|
|
18
|
+
from json import dumps
|
|
19
|
+
elif ext in ("ini", "cfg"):
|
|
20
|
+
from configparser import ConfigParser
|
|
21
|
+
parser = ConfigParser()
|
|
22
|
+
for sec, items in cfg.items():
|
|
23
|
+
parser[sec] = {k: v for k, v in items.items() if not isinstance(v, dict)}
|
|
24
|
+
for subsec, subitems in items.items():
|
|
25
|
+
if isinstance(subitems, dict):
|
|
26
|
+
parser[f"{sec}.{subsec}"] = subitems
|
|
27
|
+
with fpath.open("w") as fd:
|
|
28
|
+
return parser.write(fd)
|
|
29
|
+
else:
|
|
30
|
+
raise TypeError(f"Unsupported config filetype: {fpath}")
|
|
31
|
+
fpath.write_text(dumps(cfg))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.fixture(autouse=True)
|
|
35
|
+
def set_env():
|
|
36
|
+
os.environ["ENVWRAP_B"] = "42"
|
|
37
|
+
os.environ["ENVWRAP_C"] = "1337"
|
|
38
|
+
os.environ["ENVWRAP_TESTENV_D"] = "360"
|
|
39
|
+
os.environ["ENVWRAP_FUNCNAME_E"] = "101"
|
|
40
|
+
os.environ["ENVWRAP_TESTENV_FUNCNAME_F"] = "404"
|
|
41
|
+
get_defaults.cache_clear()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def funcname(a=1, b=2, c=3, d=4, e=5, f=6):
|
|
45
|
+
return {"a": a, "b": b, "c": c, "d": d, "e": e, "f": f}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_env():
|
|
49
|
+
f = envwrap("envwrap", "testenv")(funcname)
|
|
50
|
+
assert f(c=99) == {"a": 1, "b": 42, "c": 99, "d": 360, "e": 101, "f": 404}
|
|
51
|
+
f = envwrap("envwrap")(funcname)
|
|
52
|
+
assert f(c=99) == {"a": 1, "b": 42, "c": 99, "d": 4, "e": 101, "f": 6}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@pytest.mark.parametrize("ext", ["toml", "yaml", "yml", "json", "ini", "cfg"])
|
|
56
|
+
def test_conf(tmp_path, ext):
|
|
57
|
+
if version_info < (3, 9) and ext in ("ini", "cfg"):
|
|
58
|
+
pytest.skip("configparser dict merging requires python>=3.9")
|
|
59
|
+
config = {
|
|
60
|
+
"testcfg": {"b": 43, "c": 1338, "d": 361, "funcname": {"f": 405}}, "funcname": {"e": 102}}
|
|
61
|
+
write_config(tmp_path / f"cfgwrap.{ext}", config)
|
|
62
|
+
pwd = os.curdir
|
|
63
|
+
os.chdir(tmp_path)
|
|
64
|
+
try:
|
|
65
|
+
f = envwrap("cfgwrap", "testcfg")(funcname)
|
|
66
|
+
assert f(c=98) == {"a": 1, "b": 43, "c": 98, "d": 361, "e": 102, "f": 405}
|
|
67
|
+
finally:
|
|
68
|
+
os.chdir(pwd)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_env_cli(capsys):
|
|
72
|
+
cli.main(["envwrap", "testenv", "funcname"])
|
|
73
|
+
out, err = capsys.readouterr()
|
|
74
|
+
assert out == dedent("""\
|
|
75
|
+
>>> @envwrap.envwrap('envwrap', 'testenv')
|
|
76
|
+
>>> def funcname(...):
|
|
77
|
+
... ...
|
|
78
|
+
will use defaults:
|
|
79
|
+
{'b': '42',
|
|
80
|
+
'c': '1337',
|
|
81
|
+
'd': '360',
|
|
82
|
+
'e': '101',
|
|
83
|
+
'f': '404',
|
|
84
|
+
'funcname_e': '101',
|
|
85
|
+
'funcname_f': '404',
|
|
86
|
+
'testenv_d': '360',
|
|
87
|
+
'testenv_funcname_f': '404'}
|
|
88
|
+
""")
|
|
89
|
+
assert not err
|
|
90
|
+
|
|
91
|
+
cli.main(["envwrap", "funcname"])
|
|
92
|
+
out, err = capsys.readouterr()
|
|
93
|
+
assert out == dedent("""\
|
|
94
|
+
>>> @envwrap.envwrap('envwrap', '')
|
|
95
|
+
>>> def funcname(...):
|
|
96
|
+
... ...
|
|
97
|
+
will use defaults:
|
|
98
|
+
{'b': '42',
|
|
99
|
+
'c': '1337',
|
|
100
|
+
'e': '101',
|
|
101
|
+
'funcname_e': '101',
|
|
102
|
+
'testenv_d': '360',
|
|
103
|
+
'testenv_funcname_f': '404'}
|
|
104
|
+
""")
|
|
105
|
+
assert not err
|
|
106
|
+
|
|
107
|
+
with pytest.raises(ValueError):
|
|
108
|
+
cli.main(["envwrap"])
|
|
109
|
+
with pytest.raises(ValueError):
|
|
110
|
+
cli.main(["envwrap", "testenv", "funcname", "extra"])
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_deprecated_underscore():
|
|
114
|
+
with pytest.warns(DeprecationWarning, match="Trailing underscore"):
|
|
115
|
+
envwrap("envwrap_", "testenv")(funcname)
|