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.
@@ -0,0 +1,3 @@
1
+ github: [casperdcl, tqdm]
2
+ thanks_dev: u/gh/casperdcl
3
+ custom: https://tqdm.github.io/merch
@@ -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 }}
@@ -0,0 +1,11 @@
1
+ *.py[cod]
2
+ __pycache__/
3
+
4
+ # Packages
5
+ /*.egg*/
6
+ /build/
7
+ /dist/
8
+
9
+ # Unit test / coverage reports
10
+ /.coverage*
11
+ /coverage.xml
@@ -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
+ [![CI](https://github.com/tqdm/envwrap/actions/workflows/test.yml/badge.svg)](https://github.com/tqdm/envwrap/actions/workflows/test.yml)
38
+ [![coveralls](https://img.shields.io/coveralls/github/tqdm/envwrap/main?logo=coveralls)](https://coveralls.io/github/tqdm/envwrap)
39
+ [![codecov](https://codecov.io/gh/tqdm/envwrap/graph/badge.svg?token=PEWICBIPVW)](https://codecov.io/gh/tqdm/envwrap)
40
+ [![codacy](https://app.codacy.com/project/badge/Grade/6ca7a441560444489fd5c5b1548ab0de)](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,65 @@
1
+ # envwrap
2
+
3
+ [![CI](https://github.com/tqdm/envwrap/actions/workflows/test.yml/badge.svg)](https://github.com/tqdm/envwrap/actions/workflows/test.yml)
4
+ [![coveralls](https://img.shields.io/coveralls/github/tqdm/envwrap/main?logo=coveralls)](https://coveralls.io/github/tqdm/envwrap)
5
+ [![codecov](https://codecov.io/gh/tqdm/envwrap/graph/badge.svg?token=PEWICBIPVW)](https://codecov.io/gh/tqdm/envwrap)
6
+ [![codacy](https://app.codacy.com/project/badge/Grade/6ca7a441560444489fd5c5b1548ab0de)](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
+ [![CI](https://github.com/tqdm/envwrap/actions/workflows/test.yml/badge.svg)](https://github.com/tqdm/envwrap/actions/workflows/test.yml)
38
+ [![coveralls](https://img.shields.io/coveralls/github/tqdm/envwrap/main?logo=coveralls)](https://coveralls.io/github/tqdm/envwrap)
39
+ [![codecov](https://codecov.io/gh/tqdm/envwrap/graph/badge.svg?token=PEWICBIPVW)](https://codecov.io/gh/tqdm/envwrap)
40
+ [![codacy](https://app.codacy.com/project/badge/Grade/6ca7a441560444489fd5c5b1548ab0de)](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,2 @@
1
+ [console_scripts]
2
+ envwrap = envwrap.cli:main
@@ -0,0 +1,10 @@
1
+ platformdirs
2
+ PyYAML
3
+
4
+ [:python_version < "3.11"]
5
+ toml
6
+
7
+ [dev]
8
+ pytest>=6
9
+ pytest-cov
10
+ toml
@@ -0,0 +1,2 @@
1
+ dist
2
+ envwrap
@@ -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
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)