├── .coveragerc ├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .readthedocs.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── codecov.yml ├── docs ├── _static │ └── PLACEHOLDER ├── conf.py ├── constants.rst ├── cookbook.rst ├── describing.rst ├── envvar.rst ├── exceptions.rst ├── index.rst ├── infer.rst ├── introduction.rst ├── string_parsing.rst └── testing_utilities.rst ├── envolved ├── __init__.py ├── _version.py ├── absolute_name.py ├── basevar.py ├── describe │ ├── __init__.py │ ├── flat.py │ ├── nested.py │ └── util.py ├── envparser.py ├── envvar.py ├── exceptions.py ├── factory_spec.py ├── infer_env_var.py ├── parsers.py ├── py.typed └── utils.py ├── pyproject.toml ├── scripts ├── build_doc.sh ├── format.sh ├── install.sh ├── lint.sh ├── test_type_hinting.sh └── unittest.sh ├── tests ├── __init__.py └── unittests │ ├── test_absolute_name.py │ ├── test_describe.py │ ├── test_describe_flat_grouped.py │ ├── test_describe_flat_sorted.py │ ├── test_describe_multi.py │ ├── test_envparser.py │ ├── test_examples.py │ ├── test_mock.py │ ├── test_parsers.py │ ├── test_parsers_utils.py │ ├── test_schema.py │ ├── test_single_var.py │ └── test_util.py └── type_checking └── env_var.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | exclude_lines = 3 | pragma: no cover 4 | pass -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to PyPI 2 | 3 | on: 4 | release: 5 | types: [ published, edited ] 6 | 7 | jobs: 8 | publish: 9 | 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Set up Python 15 | uses: actions/setup-python@v4 16 | with: 17 | python-version: "3.12" 18 | - name: Install PIP 19 | run: | 20 | python -m pip install --upgrade pip 21 | - name: Install dependencies 22 | run: | 23 | sh scripts/install.sh 24 | - name: Assert Version Correctness 25 | run: | 26 | TOML_VER=$(poetry version | awk -F' ' '{ print $2 }') 27 | echo toml "$TOML_VER" 28 | GIT_VER=${{ github.event.release.tag_name }} 29 | echo git "$GIT_VER" 30 | LIB_VER=$(python -c "exec(open('envolved/_version.py').read()); print(__version__)") 31 | echo lib "$LIB_VER" 32 | [[ "$TOML_VER" == "$GIT_VER" ]] 33 | [[ "$TOML_VER" == "$LIB_VER" ]] 34 | - name: Publish 35 | env: 36 | PYPI_USERNAME: ${{ secrets.pypi_user }} 37 | PYPI_PASSWORD: ${{ secrets.pypi_password }} 38 | run: | 39 | poetry publish -u "$PYPI_USERNAME" -p "$PYPI_PASSWORD" --build 40 | - name: Upload Binaries as Artifacts 41 | uses: actions/upload-artifact@v2 42 | with: 43 | name: binaries 44 | path: dist 45 | - name: Upload Binaries to Release 46 | uses: svenstaro/upload-release-action@v2 47 | with: 48 | repo_token: ${{ secrets.GITHUB_TOKEN }} 49 | file: dist/* 50 | tag: ${{ github.ref }} 51 | overwrite: true 52 | file_glob: true 53 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test envolved 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | unittest: 11 | strategy: 12 | matrix: 13 | python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12"] # format: 3.7, 3.8, 3.9 14 | platform: [ubuntu-latest, macos-latest, windows-latest] 15 | fail-fast: false 16 | runs-on: ${{ matrix.platform }} 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Set up Python 20 | uses: actions/setup-python@v4 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | - name: Install PIP 24 | run: | 25 | python -m pip install --upgrade pip 26 | - name: Install dependencies 27 | run: | 28 | sh scripts/install.sh 29 | - name: Lint 30 | if: matrix.python-version != '3.7' 31 | run: | 32 | poetry run sh scripts/lint.sh 33 | - name: Tests 34 | run: | 35 | poetry run sh scripts/unittest.sh 36 | - name: Upload coverage to Codecov 37 | uses: codecov/codecov-action@v1 38 | with: 39 | file: ./coverage.xml 40 | flags: unittests 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | docs/_build 2 | **/__pycache__ 3 | .vscode/settings.json 4 | poetry.lock 5 | coverage.xml 6 | .coverage 7 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.12" 7 | jobs: 8 | post_create_environment: 9 | # Install poetry 10 | # https://python-poetry.org/docs/#installing-manually 11 | - pip install poetry 12 | post_install: 13 | # Install dependencies with all dependencies 14 | # VIRTUAL_ENV needs to be set manually for now. 15 | # See https://github.com/readthedocs/readthedocs.org/pull/11152/ 16 | - VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry install --only docs 17 | 18 | sphinx: 19 | configuration: docs/conf.py 20 | 21 | formats: all -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # envolved Changelog 2 | ## 1.7.0 3 | ### Added 4 | * `inferred_env_var` can now infer additional parameter data from the `Env` annotation metadata. 5 | * `SchemaEnvVars` can now be initialized with `args=...` to use all keyword arguments with `Env` annotations as arguments. 6 | ### Fixed 7 | * Fixed type annotations for `LookupParser.case_insensitive` 8 | ## 1.6.0 9 | ### Added 10 | * added `AbsoluteName` to create env vars with names that never have a prefix 11 | ### Documentation 12 | * fixed code snippets around documentation 13 | ## 1.5.0 14 | ### Removed 15 | * `envolved` no longer supports python 3.7 16 | ### Added 17 | * `FindIterCollectionParser` 18 | * `with_prefix` can now override many of an env-var's parameters 19 | ### Fixed 20 | * `CollectionParser`'s `opener` and `closer` arguments now correctly handle matches that would be split by the delimiter 21 | * `CollectionParser`'s `closer` argument now correctly handles overlapping matches 22 | * `CollectionParser`'s `closer` argument is now faster when using non-regex matches 23 | * `CollectionParser.pair_wise_delimited` will now be more memory efficient when using a mapping `value_type` 24 | ### Internal 25 | * fixed some documentation typos 26 | ## 1.4.0 27 | ### Deprecated 28 | * this is the last release to support python 3.7 29 | ### Changed 30 | * `BoolParser` is now a subclass of `LookupParser` 31 | ### Fixed 32 | * environment sys-hooks can now handle invalid arguments gracefully 33 | ### Internal 34 | * update formatter to ruff 0.3.0 35 | * unittests now automatically run on all supported platforms 36 | * using sluth for documentation 37 | ## 1.3.0 38 | ### Added 39 | * single-environment variable can now be given additional arguments, that are passed to the parser. 40 | * env-var defaults can now be wrapped in a `Factory` to allow for a default Factory. 41 | ### Changed 42 | * type annotation correctness is no longer supported for python 3.7 43 | ### Documentation 44 | * Fixed some typos in the documentation 45 | ## 1.2.1 46 | ### Fixed 47 | * The children of envvars that are excluded from the description are now also excluded. 48 | ## 1.2.0 49 | ### Added 50 | * new argument `strip_items` for `CollectionParser`. 51 | * new arguments `strip_keys` and `strip_values` for `CollectionParser.pairwise_delimited`. 52 | * `missing`, `as_default`, `no_patch`, and `discard` consts are now available in the `envolved` namespace. 53 | * envvar descriptions can now also be a sequence of strings to denote multiple paragraphs. 54 | * many new options for describing env vars 55 | * inferred env vars can now be used for parameters that don't have a type hint, so long as the default and type are provided. 56 | ### Fixed 57 | * the default `case_sensitive` value for `inferred_env_var`s is now `False` instead of `True`. 58 | * envvars create with `with_prefix` are now correctly added to the description 59 | * calling `describe_env_vars` without any envvars defined no longer raises an error 60 | ### Docs 61 | * changed documentation theme with furo 62 | ### Deprecations 63 | * usage of the `basevar` and `infer_env_var` modules is deprecated 64 | * usage of the `envvar` function to create inferred envvars is deprecated 65 | ## 1.1.2 66 | ### Fixed 67 | * changed type of `args` to be an invariant `Mapping` instead of a `dict` 68 | ## 1.1.1 69 | ### Fixed 70 | * fixed type hint for auto-typed env vars. 71 | ## 1.1.0 72 | ### Added 73 | * Single env vars can now accept pydantic models and type adapters, they will be parsed as jsons. 74 | * added `py.typed` file to the package. 75 | * added `inferred_env_var` to the root `envolved` namespace. 76 | * schema env vars can now have keyword arguments passed to their `get` method, to add values to the schema. 77 | * new parse: `LookupParser`, as a faster alternative to `MatchParser` (that does not support regex matches). 78 | ### Changed 79 | * the special parser of `Enum`s is now `LookupParser` instead of `MatchParser`. 80 | ### Fixed 81 | * `exclude_from_description` now ignores inferred env vars. 82 | ## 1.0.0 83 | ### Added 84 | * `inferred_env_var` to explicitly infer the type, name and default value of an env var. 85 | * `pos_args` to allow for positional arguments in a schema. 86 | * `discard` default value for schema args, which discards an argument from the schema if the value is missing. 87 | * `MatchParser` to return values from discrete matches. This is now the default parser for Mappings and Enums. 88 | * `Optional`s can now be used as parsers 89 | * added official support for python 3.11 and 3.12 90 | ### Deprecated 91 | * auto-typed env vars are deprecated, use `infer_env_var` instead. 92 | ### Fixed 93 | * fixed possible race condition when reloading the environment parser, causing multiple reloads. 94 | * significantly improved performance for case-insensitive env var repeat retrieval. 95 | ### Internal 96 | * use ruff + black for formatting 97 | ## 0.5.0 98 | This release is a complete overhaul of the project. Refer to the documentation for details. 99 | ## 0.4.1 100 | ### Fixed 101 | * Partial Schema error is no longer triggered from default members 102 | ## 0.4.0 103 | ### Changed 104 | * Env Vars no longer default to case sensitive if not uppercase. 105 | ### Added 106 | * Env vars can now be supplied with `prefix_capture`, causing them to become a prefix env var. 107 | * all env vars can now be supplied with the optional `description` keyword argument. 108 | * `describe_env_vars` to make a best-effort attempt to describe all the environment variables defined. 109 | * `raise_for_partial` parameter for schema vars, to not accept partially filled schemas, regardless of default value. 110 | ## 0.3.0 111 | ### Removed 112 | * The caching mechanism from basic vars has been removed 113 | ## 0.2.0 114 | ### Removed 115 | * `env_parser.reload`- the parser is now self-updating! 116 | * `MappingEnvVar`- use `Schema(dict, ...)` instead 117 | * The same envvar cannot be used twice in the same schema anymore 118 | ### Added 119 | * The environment parsing is now self-updating, no more need to manually reload the environment when testing. 120 | * When manifesting `EnvVar`s, additional keyword arguments can be provided. 121 | * When creating a schema, you can now omit the factory type to just use a `SimpleNamespace`. 122 | * Validators can now be used inside a schema class. 123 | * Validators can now be static methods. 124 | * EnvVar children can now overwrite template parameters. 125 | * EnvVar template can be without a name. 126 | * `BoolParser`'s parameters now all have default values. 127 | ### Fixed 128 | * Inner variables without a default value would act as though given a default value. 129 | * If the variadic annotation of `__new__` and `__init__` would disagree, we would have `__new__`'s win out, this has been corrected. 130 | * `EnvVar` is now safe to use both as a parent and as a manifest 131 | * EnvVar validators now correctly transition to children 132 | ### Internal 133 | * added examples page 134 | ## 0.1.1 135 | ### Fixed 136 | * removed recordclasses dependency 137 | ## 0.1.0 138 | * initial release -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020, ben avrahami 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice (including the next paragraphs) shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | 9 | Any machine learning, artificial intelligence, or other predictive software or program that is trained on software code (herafter called the "AI Tool"), is prohibited from using any portion of the Software's source code in its model's training set (or tool-specific equivalent). This is unless the Observing Software's source code is distributed and made available to access and use, free of charge and without restriction. The AI Tool's source code must be in its entirety, and must include everything the AI Tool requires for to both function and maintainance (among others: tests, documentation, and machine learning models). The AI Tool's source code must be made availaible under such a license that it prohibits any action prohibited by this paragraph, but in all other cases permits at least all actions permitted by the GNU Affero General Public License v3.0 (see https://www.gnu.org/licenses/agpl-3.0.txt), with respect to the AI Tool's source code, without restriction. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Envolved 2 | Envolved is a library to make environment variable parsing powerful and effortless. 3 | 4 | documentation: https://envolved.readthedocs.io/en/latest/ 5 | 6 | ```python 7 | from envolved import env_var, EnvVar 8 | 9 | # create an env var with an int value 10 | foo: EnvVar[int] = env_var('FOO', type=int, default=0) 11 | value_of_foo = foo.get() # this method will check for the environment variable FOO, and parse it as an int 12 | 13 | # we can also have some more complex parsers 14 | from typing import List, Optional 15 | from envolved.parsers import CollectionParser 16 | 17 | foo = env_var('FOO', type=CollectionParser(',', int)) 18 | foo.get() # now we will parse the value of FOO as a comma-separated list of ints 19 | 20 | # we can also use schemas to combine multiple environment variables 21 | from dataclasses import dataclass 22 | 23 | 24 | @dataclass 25 | # say we want the environment to describe a ConnectionSetting 26 | class ConnectionSetting: 27 | host: str 28 | port: int 29 | user: Optional[str] 30 | password: Optional[str] 31 | 32 | 33 | connection_settings: EnvVar[ConnectionSetting] = env_var('service_', type=ConnectionSetting, args={ 34 | 'host': env_var('hostname'), 35 | # we now define an env var as an argument. Its suffix will be "hostname", and its type will be inferred from the 36 | # type's annotation 37 | 'port': env_var('port'), 38 | 'user': env_var('username', type=str), # for most types, we can infer the type from the annotation, though we can 39 | # also override it if we want 40 | 'password': env_var('password', type=str, default=None) # we can also set a default value per arg 41 | }) 42 | service_connection_settings: ConnectionSetting = connection_settings.get() 43 | # this will look in 4 environment variables: 44 | # host will be extracted from service_hostname 45 | # port will be extracted from service_port, then converted to an int 46 | # user will be extracted from service_username 47 | # password will be extracted from service_password, and will default to None 48 | # finally, ConnectionSetting will be called with the parameters 49 | ``` 50 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: auto # auto compares coverage to the previous base commit 6 | informational: true -------------------------------------------------------------------------------- /docs/_static/PLACEHOLDER: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bentheiii/envolved/7032560a76464b0e0a0710db7ecbb66360a3b7ec/docs/_static/PLACEHOLDER -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | 13 | from ast import Index 14 | import os 15 | from enum import EnumMeta 16 | from importlib import import_module 17 | from inspect import getsourcefile, getsourcelines 18 | from traceback import print_exc 19 | from unittest.mock import Mock 20 | 21 | # -- Project information ----------------------------------------------------- 22 | 23 | project = "envolved" 24 | copyright = "2020, ben avrahami" 25 | author = "ben avrahami" 26 | 27 | # -- General configuration --------------------------------------------------- 28 | 29 | # Add any Sphinx extension module names here, as strings. They can be 30 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 31 | # ones. 32 | extensions = ["sphinx.ext.intersphinx", "sphinx.ext.autosectionlabel"] 33 | 34 | intersphinx_mapping = { 35 | "python": ("https://docs.python.org/3/", None), 36 | "pytest": ("https://docs.pytest.org/en/latest/", None), 37 | } 38 | 39 | python_use_unqualified_type_names = True 40 | add_module_names = False 41 | autosectionlabel_prefix_document = True 42 | 43 | extensions.append("sphinx.ext.linkcode") 44 | import os 45 | import subprocess 46 | from importlib.util import find_spec 47 | from pathlib import Path 48 | 49 | from sluth import NodeWalk 50 | 51 | release = "main" 52 | if rtd_version := os.environ.get("READTHEDOCS_GIT_IDENTIFIER"): 53 | release = rtd_version 54 | else: 55 | # try to get the current branch name 56 | try: 57 | release = subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"]).decode("utf-8").strip() 58 | except Exception: 59 | pass 60 | 61 | base_url = "https://github.com/bentheiii/envolved" # The base url of the repository 62 | root_dir = Path(find_spec(project).submodule_search_locations[0]) 63 | 64 | 65 | def linkcode_resolve(domain, info): 66 | if domain != "py": 67 | return None 68 | try: 69 | package_file = root_dir / (info["module"].replace(".", "/") + ".py") 70 | if not package_file.exists(): 71 | package_file = root_dir / info["module"].replace(".", "/") / "__init__.py" 72 | if not package_file.exists(): 73 | raise FileNotFoundError 74 | blob = project / Path(package_file).relative_to(root_dir) 75 | walk = NodeWalk.from_file(package_file) 76 | try: 77 | decl = walk.get_last(info["fullname"]) 78 | except KeyError: 79 | return None 80 | except Exception as e: 81 | print(f"error getting link code {info}") 82 | print_exc() 83 | raise 84 | return f"{base_url}/blob/{release}/{blob}#L{decl.lineno}-L{decl.end_lineno}" 85 | 86 | 87 | # Add any paths that contain templates here, relative to this directory. 88 | templates_path = ["_templates"] 89 | 90 | # List of patterns, relative to source directory, that match files and 91 | # directories to ignore when looking for source files. 92 | # This pattern also affects html_static_path and html_extra_path. 93 | exclude_patterns = [] 94 | 95 | # -- Options for HTML output ------------------------------------------------- 96 | 97 | # The theme to use for HTML and HTML Help pages. See the documentation for 98 | # a list of builtin themes. 99 | # 100 | html_theme = "furo" 101 | 102 | html_theme_options = { 103 | "source_repository": "https://github.com/biocatchltd/envolved", 104 | "source_branch": "master", 105 | "source_directory": "docs/", 106 | } 107 | 108 | # Add any paths that contain custom static files (such as style sheets) here, 109 | # relative to this directory. They are copied after the builtin static files, 110 | # so a file named "default.css" will overwrite the builtin "default.css". 111 | html_static_path = ["_static"] 112 | -------------------------------------------------------------------------------- /docs/constants.rst: -------------------------------------------------------------------------------- 1 | Constants 2 | ================== 3 | 4 | Runtime constants for the envolved library 5 | 6 | .. py:currentmodule:: envvar 7 | 8 | .. py:data:: missing 9 | :type: object 10 | 11 | Used to indicate that an EnvVar has no default value. Can also be used in :attr:`~envvar.SchemaEnvVar.on_partial` 12 | to specify that an error should be raised on partial environments. 13 | 14 | .. py:data:: as_default 15 | :type: object 16 | 17 | Used in :attr:`~envvar.SchemaEnvVar.on_partial` to specify that the default should be returned on partial 18 | environments. 19 | 20 | .. py:data:: no_patch 21 | :type: object 22 | 23 | Used in :attr:`~envvar.EnvVar.monkeypatch` to specify that the EnvVar should not be patched. 24 | 25 | .. py:data:: discard 26 | :type: object 27 | 28 | When returned by child env vars of :class:`~envvar.SchemaEnvVar`, the value, and argument, will be discarded. If 29 | a positional argument returns this value, all positional arguments after it will also be discarded. -------------------------------------------------------------------------------- /docs/cookbook.rst: -------------------------------------------------------------------------------- 1 | Cookbook 2 | ============= 3 | EnvVar variable 4 | ----------------- 5 | EnvVars are best defined as global variables (so they will be included in the 6 | :ref:`description `). Also, to differentiate the environment variables and 7 | their eventually retrieved values, we should end the name of the EnvVar variables with the suffix ``_ev``. 8 | 9 | .. code-block:: python 10 | 11 | board_size_ev : EnvVar[int] = env_var('BOARD_SIZE', type=int, default=8) 12 | 13 | ... 14 | 15 | class MyApp: 16 | def __init__(...): 17 | ... 18 | self.board_size = board_size_ev.get() 19 | ... 20 | 21 | Retrieving EnvVar Values 22 | -------------------------- 23 | EnvVars should be retrieved once, preferably at the start of the program or initialization of singletons. This is 24 | important for consistency. While envolved handles environment variables changing from within the python program, 25 | external changes to environment variables are not handled. 26 | 27 | .. warning:: 28 | 29 | This is especially important when running on python 3.7, where in some cases, the environment variables will have 30 | to be reloaded on every retrieval. 31 | 32 | Common Factories 33 | ----------------- 34 | Here are some common types and factories to use when creating a :class:`~envvar.SchemaEnvVar` 35 | 36 | * :class:`types.SimpleNamespace`: This will create a namespace with whatever arguments you pass to it. 37 | * :class:`typing.NamedTuple`: A quick and easy way to create an annotated named tuple. 38 | * :class:`typing.TypedDict`: To create type annotated dictionaries. 39 | 40 | .. code-block:: python 41 | 42 | class Point(typing.NamedTuple): 43 | x: int 44 | y: int 45 | 46 | origin_ev = env_var('ORIGIN_', type=Point, args={ 47 | 'x': inferred_env_var(), 48 | 'y': inferred_env_var(), 49 | }) 50 | 51 | source_ev = env_var('Source_', type=SimpleNamespace, args={ 52 | 'x': inferred_env_var(type=int), 53 | 'y': inferred_env_var(type=int), 54 | }) 55 | 56 | # both these will result in a namespace that has ints for x and y 57 | 58 | class PointTD(typing.TypedDict): 59 | x: int 60 | y: int 61 | 62 | destination_ev = env_var('ORIGIN_', type=PointTD, args={ 63 | 'x': inferred_env_var(), 64 | 'y': inferred_env_var(), 65 | }) 66 | 67 | # this will result in a dict that has ints for keys "x" and "y" 68 | 69 | Inferring Schema Parameter Names Without a Schema 70 | -------------------------------------------------- 71 | 72 | We can actually use :func:`~envvar.inferred_env_var` to infer the name of :class:`~envvar.EnvVar` parameters without a schema. This is useful when 73 | we want to prototype a schema without having to create a schema class. 74 | 75 | .. code-block:: python 76 | 77 | from envolved import ... 78 | 79 | my_schema_ev = env_var('FOO_', type=SimpleNamespace, args={ 80 | 'x': inferred_env_var(type=int, default=0), 81 | 'y': inferred_env_var(type=string, default='hello'), 82 | }) 83 | 84 | # this will result in a namespace that fills `x` and `y` with the values of `FOO_X` 85 | # and `FOO_Y` respectively 86 | 87 | 88 | Note a sticking point here, we have to specify not only the type of the inferred env var, but also the default value. 89 | 90 | .. code-block:: python 91 | 92 | from envolved import ... 93 | 94 | my_schema_ev = env_var('FOO_', type=SimpleNamespace, args={ 95 | 'x': inferred_env_var(type=int), # <-- this code will raise an exception 96 | }) 97 | 98 | 99 | .. note:: Why is this the behaviour? 100 | 101 | In normal :func:`~envvar.env_var`, not passing a `default` implies that the EnvVar is required, why can't we do the same for :func:`~envvar.inferred_env_var`? We do this to reduce side 102 | effects when an actual schema is passed in. If we were to assume that the inferred env var is required, then plugging in a schema that has a default value for that parameter would be 103 | a hard-to-detect breaking change that can have catostraphic consequences. By requiring the default value to be passed in, we force the user to be explicit about the default values, 104 | ehan it might be inferred. 105 | 106 | We can specify that an inferred env var is required by explicitly stating `default=missing` 107 | 108 | 109 | .. code-block:: python 110 | 111 | from envolved import ..., missing 112 | 113 | my_schema_ev = env_var('FOO_', type=SimpleNamespace, args={ 114 | 'x': inferred_env_var(type=int, default=missing), 115 | 'y': inferred_env_var(type=string, default='hello'), 116 | }) 117 | 118 | # this will result in a namespace that fills `x` with the value of `FOO_X` 119 | # and will raise an exception if `FOO_X` is not set 120 | 121 | 122 | Absolute Variable Names 123 | ------------------------ 124 | When creating a schema, we can specify a child env var whose name will not be prefixed with the schema name by making the key of the child env var an intance of the 125 | :class:`~absolute_name.AbsoluteName` class. 126 | 127 | .. code-block:: python 128 | 129 | from envolved import AbsoluteName 130 | 131 | my_schema_ev = env_var('FOO_', type=SimpleNamespace, args={ 132 | 'x': env_var("X", type=int), 133 | 'y': env_var(AbsoluteName("BAR_Y"), type=int), 134 | }) 135 | 136 | # this will result in a namespace that fills `x` with the value of `FOO_X`, 137 | # but `y` with the value of `BAR_Y` 138 | 139 | .. module:: absolute_name 140 | 141 | .. class:: AbsoluteName 142 | 143 | A subclass of :class:`str` that is used to specify that an env var should not be prefixed. -------------------------------------------------------------------------------- /docs/describing.rst: -------------------------------------------------------------------------------- 1 | Describing Environment Variables 2 | =================================== 3 | 4 | Another feature of envolved is the ability to describe all EnvVars. 5 | 6 | .. code-block:: python 7 | 8 | cache_time_ev = env_var('CACHE_TIME', type=int, default=3600, description='Cache time, in seconds') 9 | backlog_size_ev = env_var('BACKLOG_SIZE', type=int, default=100, description='Backlog size') 10 | logging_params_ev = env_var('LOGSTASH', type=SimpleNamespace, description='Logging parameters', 11 | args={ 12 | 'host': env_var('_HOST', type=str), 13 | 'port': env_var('_PORT', type=int), 14 | 'level': env_var('_LEVEL', type=int, default=20), 15 | }) 16 | 17 | print('\n'.join(describe_env_vars())) 18 | 19 | # OUTPUT: 20 | # BACKLOG_SIZE: Backlog size 21 | # CACHE_TIME: Cache time, in seconds 22 | # Logging parameters: 23 | # LOGSTASH_HOST 24 | # LOGSTASH_LEVEL 25 | # LOGSTASH_PORT 26 | 27 | .. warning:: 28 | 29 | The description feature is still experimental and may change in the future. 30 | 31 | .. module:: describe 32 | 33 | .. function:: describe_env_vars(**kwargs)->list[str] 34 | 35 | Returns a list of string lines that describe all the EnvVars. All keyword arguments are passed to 36 | :func:`textwrap.wrap` to wrap the lines. 37 | 38 | .. note:: 39 | 40 | This function will include a description of every alive EnvVar. EnvVars defined in functions, for instance, will 41 | not be included. 42 | 43 | Excluding EnvVars from the description 44 | ------------------------------------------ 45 | 46 | In some cases it is useful to exclude some EnvVars from the description. This can be done with the 47 | :func:`exclude_from_description` function. 48 | 49 | .. code-block:: python 50 | 51 | point_args = dict( 52 | x=env_var('_x', type=int, description='x coordinate'), 53 | y=env_var('_y', type=int, description='y coordinate') 54 | ) # point_args is a common argument set that we will provide to other envars. 55 | 56 | origin_ev = env_var('ORIGIN', type=Point, description='Origin point', args=point_args) 57 | destination_ev = env_var( 58 | 'DESTINATION', type=Point, description='Destination point', args=point_args 59 | ) 60 | 61 | # but the problem is that now the env-vars defined in the original point_args dict will be 62 | # included in the description even though we never read them. We exclude them like this: 63 | 64 | exclude_from_description(point_args) 65 | 66 | .. function:: exclude_from_description(env_vars: EnvVar | collections.abc.Iterable[EnvVar] | \ 67 | collections.abc.Mapping[Any, EnvVar]) 68 | 69 | Mark a given EnvVar or collection of EnvVars from the env-var description. 70 | 71 | :param env_vars: A single EnvVar or a collection of EnvVars to exclude from the description, can also be a mapping 72 | of EnvVar names to EnvVars. 73 | :return: `env_vars`, to allow for piping. 74 | 75 | .. class:: EnvVarsDescription(env_vars: collections.abc.Iterable[EnvVar] | None) 76 | 77 | A class that allows for more fine-grained control over the description of EnvVars. 78 | 79 | :param env_vars: A collection of EnvVars to describe. If None, all alive EnvVars will be described. If the collection 80 | includes two EnvVars, one which is a parent of the other, only the parent will be described. 81 | 82 | .. method:: flat()->FlatEnvVarsDescription 83 | 84 | Returns a flat description of the EnvVars. 85 | 86 | .. method:: nested()->NestedEnvVarsDescription 87 | 88 | Returns a nested description of the EnvVars. 89 | 90 | .. module:: describe.flat 91 | 92 | .. class:: FlatEnvVarsDescription 93 | 94 | A flat representation of the EnvVars description. Only single-environment variable EnvVars (or single-environment variable children of envars) will be described. 95 | 96 | .. method:: wrap_sorted(*, unique_keys: bool = True, **kwargs)->list[str] 97 | 98 | Returns a list of string lines that describe the EnvVars, sorted by their environment variable key. 99 | 100 | :param unique_keys: If True, and if any EnvVars share an environment variable key, they will be combined into one description. 101 | :param kwargs: Keyword arguments to pass to :func:`textwrap.wrap`. 102 | :return: A list of string lines that describe the EnvVars. 103 | 104 | .. method:: wrap_grouped(**kwargs)->list[str] 105 | 106 | Returns a list of string lines that describe the EnvVars, sorted by their environment variable key, but env-vars that are used by the same schema will appear together. 107 | 108 | :param kwargs: Keyword arguments to pass to :func:`textwrap.wrap`. 109 | :return: A list of string lines that describe the EnvVars. 110 | 111 | .. module:: describe.nested 112 | 113 | .. class:: NestedEnvVarsDescription 114 | 115 | A nested representation of the EnvVars description. All EnvVars will be described. 116 | 117 | .. method:: wrap(indent_increment: str = ..., **kwargs)->list[str] 118 | 119 | Returns a list of string lines that describe the EnvVars in a tree structure. 120 | 121 | :param indent_increment: The string to use to increment the indentation of the description with each level. If not provided, 122 | will use the keyword argument "subsequent_indent" from :func:`textwrap.wrap`, if provided. Otherwise, will use a single space. 123 | :param kwargs: Keyword arguments to pass to :func:`textwrap.wrap`. 124 | :return: A list of string lines that describe the EnvVars. -------------------------------------------------------------------------------- /docs/envvar.rst: -------------------------------------------------------------------------------- 1 | EnvVars 2 | ========================================================= 3 | 4 | .. module:: envvar 5 | 6 | .. function:: env_var(key: str, *, type: collections.abc.Callable[[str], T],\ 7 | default: T | missing | discard | Factory[T] = missing,\ 8 | description: str | collections.abc.Sequence[str] | None = None, \ 9 | validators: collections.abc.Iterable[collections.abc.Callable[[T], T]] = (), \ 10 | case_sensitive: bool = False, strip_whitespaces: bool = True) -> envvar.SingleEnvVar[T] 11 | 12 | Creates an EnvVar that reads from one environment variable. 13 | 14 | :param key: The key of the environment variable. 15 | :param type: A callable to use to parse the string value of the environment variable. 16 | :param default: The default value of the EnvVar if the environment variable is missing. If unset, an exception will 17 | be raised if the environment variable is missing. The default can also be a :class:`~envvar.Factory` to specify a default factory, 18 | or :attr:`~envvar.discard` to indicate to parent :class:`SchemaEnvVars ` that this env var should be discarded from the 19 | arguments if it is missing. 20 | :param description: A description of the EnvVar. See :ref:`describing:Describing Environment Variables`. 21 | :param validators: A list of callables to validate the value of the EnvVar. Validators can be added to the EnvVar 22 | after it is created with :func:`~envvar.EnvVar.validator`. 23 | :param case_sensitive: Whether the key of the EnvVar is case sensitive. 24 | :param strip_whitespaces: Whether to strip whitespaces from the value of the environment variable before parsing it. 25 | 26 | .. function:: env_var(key: str, *, type: collections.abc.Callable[..., T], \ 27 | default: T | missing | discard | Factory[T] = missing, \ 28 | args: dict[str, envvar.EnvVar | InferEnvVar] = ..., \ 29 | pos_args: collections.abc.Sequence[envvar.EnvVar | InferEnvVar] = ..., \ 30 | description: str | collections.abc.Sequence[str] | None = None,\ 31 | validators: collections.abc.Iterable[collections.abc.Callable[[T], T]] = (), \ 32 | on_partial: T | missing | as_default | discard = missing) -> envvar.SchemaEnvVar[T] 33 | :noindex: 34 | 35 | Creates an EnvVar that reads from multiple environment variables. 36 | 37 | :param key: The key of the environment variable. This will be a common prefix applied to all environment variables. 38 | :param type: A callable to call with ``pos_args`` and ``args`` to create the EnvVar value. 39 | :param default: The default value of the EnvVar if the environment variable is missing. If unset, an exception will 40 | be raised if the environment variable is missing. The default can also be a :class:`~envvar.Factory` to specify a default factory, 41 | or :attr:`~envvar.discard` to indicate to parent :class:`SchemaEnvVars ` that this env var should be discarded from the 42 | arguments if it is missing. 43 | :param pos_args: A sequence of EnvVars to to retrieve and use as positional arguments to ``type``. Arguments can be 44 | :ref:`inferred ` in some cases. 45 | :param args: A dictionary of EnvVars to to retrieve and use as arguments to ``type``. Arguments can be 46 | :ref:`inferred ` in some cases. Can also be :data:`ellipsis` to indicate that the arguments 47 | should be inferred from the type annotation of the ``type`` callable (see :ref:`infer:Automatic Argument Inferrence`). 48 | :param description: A description of the EnvVar. See :ref:`describing:Describing Environment Variables`. 49 | :param validators: A list of callables to validate the value of the EnvVar. Validators can be added to the EnvVar 50 | after it is created with :func:`~envvar.EnvVar.validator`. 51 | :param on_partial: The value to use if the EnvVar is partially missing. See :attr:`~envvar.SchemaEnvVar.on_partial`. 52 | 53 | .. class:: EnvVar 54 | 55 | This is the base class for all environment variables. 56 | 57 | .. attribute:: default 58 | :type: T | missing | discard | envvar.Factory[T] 59 | 60 | The default value of the EnvVar. If this attribute is set to anything other than :attr:`missing`, then it will 61 | be used as the default value if the environment variable is not set. If set to :attr:`discard`, then the 62 | value will not be used as an argument to parent :class:`SchemaEnvVars `. 63 | 64 | .. attribute:: description 65 | :type: str| collections.abc.Sequence[str] | None 66 | 67 | A description of the environment variable. Used when :ref:`describing:Describing Environment Variables`. Can also be 68 | set to a sequence of strings, in which case each string will be a separate paragraph in the description. 69 | 70 | .. attribute:: monkeypatch 71 | :type: T | missing | no_patch | discard 72 | 73 | If set to anything other than :attr:`no_patch`, then the environment variable will be monkeypatched. Any call to 74 | :meth:`get` will return the value of this attribute. If set to :attr:`missing`, then calling :meth:`get` will 75 | raise an :exc:`MissingEnvError` (even if a default is set for the EnvVar). See :ref:`testing_utilities:Testing Utilities` for 76 | usage examples. 77 | 78 | .. warning:: 79 | 80 | This method doesn't change the value within the environment. It only changes the value of the EnvVar. 81 | 82 | 83 | .. method:: get()->T 84 | 85 | Return the value of the environment variable. Different subclasses handle this operation differently. 86 | 87 | 88 | .. method:: validator(validator: collections.abc.Callable[[T], T]) -> collections.abc.Callable[[T], T] 89 | 90 | Add a validator to the environment variable. When an EnvVar's value is retrieved (using :meth:`get`), all its 91 | validators will be called in the order they were added (each validator will be called with the previous 92 | validator's return value). The result of the last validator will be the EnvVar's returned value. 93 | 94 | :param validator: A callable that will be added as a validator. 95 | :return: The validator, to allow usage of this function as a decorator. 96 | 97 | .. code-block:: python 98 | :caption: Using validators to assert that an environment variable is valid. 99 | 100 | connection_timeout_ev = env_var('CONNECTION_TIMEOUT_SECONDS', type=int) 101 | 102 | @connection_timeout_ev.validator 103 | def timeout_positive(value): 104 | if value <= 0: 105 | raise ValueError('Connection timeout must be positive') 106 | return value 107 | # getting the value of the environment variable will now raise an error 108 | # if the value is not positive 109 | 110 | .. code-block:: python 111 | :caption: Using validators to mutate the value of an environment variable. 112 | 113 | title_ev = env_var('TITLE', type=str) 114 | 115 | @title_ev.validator 116 | def title_capitalized(value): 117 | return value.capitalize() 118 | 119 | # now the value of title_ev will always be capitalized 120 | 121 | .. warning:: 122 | Even if the validator does not mutate the value, it should still return the original value. 123 | 124 | .. method:: with_prefix(prefix: str, *, default = ..., description = ...) -> EnvVar[T] 125 | 126 | Return a new EnvVar with the parameters but with a given prefix. This method can be used to re-use an env-var 127 | schema to multiple env-vars. Can also override additional parameters of the new EnvVar. 128 | 129 | :param prefix: The prefix to use. 130 | :param other: If specified, will override the parameters of the new EnvVar. If not specified, the 131 | parameters of the original EnvVar will be used. Different subclasses can allow to override additional parameters. 132 | :return: A new EnvVar with the given prefix, of the same type as the envar being used. 133 | 134 | .. method:: patch(value: T | missing | discard) -> typing.ContextManager 135 | 136 | Create a context manager that will monkeypatch the EnvVar to the given value, and then restore the original 137 | value when the context manager is exited. 138 | 139 | :param value: The value to set the environment variable to see :attr:`monkeypatch`. 140 | 141 | 142 | .. class:: SingleEnvVar 143 | 144 | An :class:`EnvVar` subclass that interfaces with a single environment variable. 145 | 146 | .. property:: key 147 | :type: str 148 | 149 | The name of the environment variable. (read only) 150 | 151 | .. property:: type 152 | :type: collections.abc.Callable[[str], T] 153 | 154 | The type of the environment variable. (read only) 155 | 156 | .. note:: 157 | 158 | This may not necessarily be equal to the ``type`` parameter the EnvVar was created with (see 159 | :ref:`string_parsing:special parsers`). 160 | 161 | .. attribute:: case_sensitive 162 | :type: bool 163 | 164 | If set to False, only case-exact environment variables will be considered. Defaults to True. 165 | 166 | .. warning:: 167 | 168 | This attribute has no effect on Windows, as all environment variables are always uppercase. 169 | 170 | .. attribute:: strip_whitespaces 171 | :type: bool 172 | 173 | If set to ``True`` (as is the default), whitespaces will be stripped from the environment variable value before 174 | it is processed. 175 | 176 | .. method:: get(**kwargs)->T 177 | 178 | Return the value of the environment variable. The value will be searched for in the following order: 179 | 180 | #. The environment variable with the name as the :attr:`key` of the EnvVar is considered. If it exists, it will be 181 | used. 182 | 183 | #. If :attr:`case_sensitive` is ``False``. Environment variables with case-insensitive names equivalent to 184 | :attr:`key` of the EnvVar is considered. If any exist, they will be used. If multiple exist, a 185 | :exc:`RuntimeError` will be raised. 186 | 187 | #. The :attr:`~EnvVar.default` value of the EnvVar is used, if it exists. If the :attr:`~EnvVar.default` is an instance of 188 | :class:`~envvar.Factory`, the factory will be called (without arguments) to create the value of the EnvVar. 189 | 190 | #. A :exc:`~exceptions.MissingEnvError` is raised. 191 | 192 | :param kwargs: Additional keyword arguments to pass to the :attr:`type` callable. 193 | :return: The value of the retrieved environment variable. 194 | 195 | .. code-block:: python 196 | :caption: Using SingleEnvVar to fetch a value from an environment variable, with additional keyword arguments. 197 | 198 | from dataclasses import dataclass 199 | 200 | def parse_users(value: str, *, reverse: bool=False) -> list[str]: 201 | return sorted(value.split(','), reverse=reverse) 202 | 203 | users_ev = env_var("USERNAMES", type=parse_users) 204 | 205 | if desc: 206 | users = users_ev.get(reverse=True) # will return a list of usernames sorted 207 | # in reverse order 208 | else: 209 | users = users_ev.get() # will return a list of usernames sorted in ascending order 210 | 211 | .. method:: with_prefix(prefix: str, *, default = ..., description = ..., type = ..., case_sensitive = ..., strip_whitespaces = ...) -> SingleEnvVar[T] 212 | 213 | See :meth:`Superclass method ` 214 | 215 | 216 | .. class:: SchemaEnvVar 217 | 218 | An :class:`EnvVar` subclass that interfaces with a multiple environment variables, combining them into a single 219 | object. 220 | 221 | When the value is retrieved, all its :attr:`args` and :attr:`pos_args` are retrieved, and are then used as keyword variables on the 222 | EnvVar's :attr:`type`. 223 | 224 | Users can also supply keyword arguments to the :meth:`get` method, which will be supplied to the :attr:`type` in addition/instead of 225 | the child EnvVars. 226 | 227 | .. property:: type 228 | :type: collections.abc.Callable[..., T] 229 | 230 | The factory callable that will be used to create the object. (read only) 231 | 232 | .. property:: args 233 | :type: collections.abc.Mapping[str, EnvVar] 234 | 235 | The mapping of keyword arguments to :class:`EnvVar` objects. (read only) 236 | 237 | .. property:: pos_args 238 | :type: typing.Sequence[EnvVar] 239 | 240 | The sequence of positional arguments to the :attr:`type` callable. (read only) 241 | 242 | .. attribute:: on_partial 243 | :type: T | as_default | missing | discard | envvar.Factory[T] 244 | 245 | This attribute dictates how the EnvVar should behave when only some of the keys are explicitly present (i.e. 246 | When only some of the expected environment variables exist in the environment). 247 | 248 | * If set to :data:`as_default`, the EnvVar's :attr:`~EnvVar.default` will be returned. 249 | 250 | .. note:: 251 | 252 | The EnvVar's :attr:`~EnvVar.default` must not be :data:`missing` if this option is used. 253 | 254 | * If set to :data:`missing`, a :exc:`~exceptions.MissingEnvError` will be raised, even if the EnvVar's 255 | :attr:`~EnvVar.default` is set. 256 | * If set to :class:`~envvar.Factory`, the factory will be called to create the value of the EnvVar. 257 | * If set to a value, that value will be returned. 258 | 259 | .. method:: get(**kwargs)->T 260 | 261 | Return the value of the environment variable. The value will be created by calling the :attr:`type` callable 262 | with the values of all the child EnvVars as keyword arguments, and the values of the ``kwargs`` parameter as 263 | additional keyword arguments. 264 | 265 | :param kwargs: Additional keyword arguments to pass to the :attr:`type` callable. 266 | :return: The value of the environment variable. 267 | 268 | .. code-block:: python 269 | :caption: Using SchemaEnvVar to create a class from multiple environment variables, with additional keyword arguments. 270 | 271 | from dataclasses import dataclass 272 | 273 | @dataclass 274 | class User: 275 | name: str 276 | age: int 277 | height: int 278 | 279 | user_ev = env_var("USER_", type=User, 280 | args={'name': env_var('NAME', type=str), 281 | 'age': env_var('AGE', type=int)}) 282 | 283 | user_ev.get(age=20, height=168) # will return a User object with the name taken from the 284 | # environment variables, but with the age and height overridden by the keyword arguments. 285 | 286 | .. method:: with_prefix(prefix: str, *, default = ..., description = ..., type = ..., on_partial = ...) -> SchemaEnvVar[T] 287 | 288 | See :meth:`Superclass method ` 289 | 290 | .. class:: Factory(callback: collections.abc.Callable[[], T]) 291 | 292 | A wrapped around a callable, indicating that the callable should be used as a factory for creating objects, rather than 293 | as a normal object. 294 | 295 | .. attribute:: callback 296 | :type: collections.abc.Callable[[], T] 297 | 298 | The callable that will be used to create the object. -------------------------------------------------------------------------------- /docs/exceptions.rst: -------------------------------------------------------------------------------- 1 | Exceptions 2 | ==================== 3 | 4 | .. module:: exceptions 5 | 6 | .. py:exception:: MissingEnvError(environment_variable_name: str) 7 | 8 | Raised when a required environment variable is missing. 9 | 10 | :param environment_variable_name: The name of the missing environment variable. -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to envolved's documentation! 2 | ====================================== 3 | .. toctree:: 4 | :maxdepth: 2 5 | :caption: Contents: 6 | 7 | introduction 8 | envvar 9 | constants 10 | exceptions 11 | infer 12 | string_parsing 13 | testing_utilities 14 | describing 15 | cookbook 16 | 17 | 18 | Indices and tables 19 | ================== 20 | 21 | * :ref:`genindex` 22 | * :ref:`modindex` 23 | * :ref:`search` 24 | -------------------------------------------------------------------------------- /docs/infer.rst: -------------------------------------------------------------------------------- 1 | Inferred Env Vars 2 | ==================================== 3 | 4 | .. currentmodule:: envvar 5 | 6 | For schema environment variables, you can sometimes skip specifying the type, default, or even name of single 7 | environment variable args by using :func:`inferred_env_var`. When this happens, the missing values of the envvar are 8 | extracted from the factory's type annotation. 9 | 10 | .. code-block:: python 11 | 12 | @dataclass 13 | class GridSize: 14 | width: int 15 | height: int = 10 16 | diagonal: bool = False 17 | 18 | grid_size_ev = env_var('GRID_', type=GridSize, args=dict( 19 | width=inferred_env_var('WIDTH'), # GRID_WIDTH will be parsed as int 20 | height=inferred_env_var('HEIGHT'), # GRID_HEIGHT will be parsed as int, and will have 21 | # default 10 22 | diagonal=inferred_env_var(), # GRID_DIAGONAL will be parsed as bool, and will have 23 | # default False 24 | )) 25 | 26 | Type inference can be performed for the following factory types: 27 | 28 | * dataclasses 29 | * annotated named tuples (:class:`typing.NamedTuple`) 30 | * annotated typed dicts (:class:`typing.TypedDict`) 31 | * any class with a type annotated `__init__` or `__new__` method 32 | * any callable with a type annotation 33 | 34 | .. function:: inferred_env_var(key: str | None = None, *, type: Callable[[str], T] = ...,\ 35 | default: T | missing | as_default | discard = as_default, **kwargs) -> InferEnvVar 36 | 37 | Create an inferred env var that can be filled in by a parent :class:`~envvar.SchemaEnvVar` factory's type 38 | annotation to create a :class:`~envvar.SingleEnvVar`. 39 | 40 | :param key: The environment variable key to use. If unspecified, the name of the argument key will be used (the 41 | argument must be keyword argument in this case). 42 | :param type: The type to use for parsing the environment variable. If unspecified, the type will be inferred from 43 | the parent factory's type annotation. 44 | :param default: The default value to use if the environment variable is not set. If unspecified, the default will 45 | be inferred from the parent factory. 46 | :param kwargs: All other parameters are passed to :func:`~envvar.env_var`. 47 | 48 | .. class:: InferEnvVar 49 | 50 | An inference env var that will be converted to a :class:`~envvar.SingleEnvVar` by a parent 51 | :class:`~envvar.SchemaEnvVar`. 52 | 53 | .. method:: validator(validator: collections.abc.Callable[[T], T]) -> collections.abc.Callable[[T], T] 54 | 55 | Add a validator to the resulting :class:`~envvar.SingleEnvVar`. 56 | 57 | .. py:currentmodule:: envvar 58 | 59 | There is also a legacy method to create inferred env vars, which is deprecated and will be removed in a future version. 60 | 61 | .. function:: env_var(key: str, **kwargs) -> InferEnvVar[T] 62 | :noindex: 63 | 64 | Create an inferred env var that infers only the type. 65 | 66 | Overriding Inferred Attributes in Annotation 67 | ---------------------------------------------------- 68 | 69 | Attributes inferred by :func:`inferred_env_var` can be overridden by specifying the attribute in the type annotation metadata with :data:`typing.Annotated`, and :class:`Env`. 70 | 71 | .. code-block:: python 72 | 73 | from typing import Annotated 74 | from envolved import Env, inferred_env_var, env_var 75 | 76 | @dataclass 77 | class GridSize: 78 | width: int 79 | height: Annotated[int, Env(default=5)] = 10 # GRID_HEIGHT will have default 5 80 | diagonal: Annotated[bool, Env(key='DIAG')] = False # GRID_DIAG will be parsed as bool 81 | 82 | grid_size_ev = env_var('GRID_', type=GridSize, args=dict( 83 | width=inferred_env_var(), # GRID_WIDTH will be parsed as int 84 | height=inferred_env_var(), # GRID_HEIGHT will be parsed as int, and will have 85 | # default 5 86 | diagonal=inferred_env_var(), # GRID_DIAG will be parsed as bool, and will have 87 | # default False 88 | )) 89 | 90 | .. currentmodule:: factory_spec 91 | 92 | .. class:: Env(*, key = ..., default = ..., type = ...) 93 | 94 | Metadata class to override inferred attributes in a type annotation. 95 | 96 | :param key: The environment variable key to use. 97 | :param default: The default value to use if the environment variable is not set. 98 | :param type: The type to use for parsing the environment variable. 99 | 100 | Automatic Argument Inferrence 101 | ------------------------------------ 102 | 103 | When using :func:`~envvar.env_var` to create schema environment variables, it might be useful to automatically infer the arguments from the type's argument annotation altogether. This can be done by supplying ``args=...`` to the :func:`~envvar.env_var` function. 104 | 105 | .. code-block:: python 106 | 107 | @dataclass 108 | class GridSize: 109 | width: Annotated[int, Env(key='WIDTH')] 110 | height: Annotated[int, Env(key='HEIGHT', default=5)] 111 | diagonal: Annotated[bool, Env(key='DIAG', default=False)] 112 | 113 | grid_size_ev = env_var('GRID_', type=GridSize, args=...) 114 | # this will be equivalent to 115 | grid_size_ev = env_var('GRID_', type=GridSize, args=dict( 116 | width=inferred_env_var('WIDTH'), 117 | height=inferred_env_var('HEIGHT', default=5), 118 | diagonal=inferred_env_var('DIAG', default=False), 119 | )) 120 | 121 | Note that only parameters annotated with :data:`typing.Annotated` and :class:`Env` will be inferred, all others will be ignored. 122 | 123 | .. code-block:: python 124 | 125 | @dataclass 126 | class GridSize: 127 | width: Annotated[int, Env(key='WIDTH')] 128 | height: Annotated[int, Env(key='HEIGHT', default=5)] = 10 129 | diagonal: bool = False 130 | 131 | grid_size_ev = env_var('GRID_', type=GridSize, args=...) 132 | # only width and height will be used as arguments in the env var 133 | 134 | Arguments can be annotated with an empty :class:`Env` to allow them to be inferred as well. 135 | 136 | .. code-block:: python 137 | 138 | @dataclass 139 | class GridSize: 140 | width: Annotated[int, Env(key='WIDTH')] 141 | height: Annotated[int, Env(key='HEIGHT', default=5)] 142 | diagonal: Annotated[bool, Env(key='DIAG')] = False 143 | 144 | grid_size_ev = env_var('GRID_', type=GridSize, args=...) 145 | # now all three arguments will be used as arguments in the env var -------------------------------------------------------------------------------- /docs/introduction.rst: -------------------------------------------------------------------------------- 1 | Introduction 2 | =============== 3 | Envolved is a python library that makes reading and parsing environment variables easy. 4 | 5 | .. code-block:: python 6 | 7 | from envolved import * 8 | 9 | # specify an environment variable that automatically converts to an int, and defaults to 10 10 | cache_timeout_env_var = env_var('CACHE_TIMEOUT', type=int, default=10) 11 | 12 | # to retrieve its value we just perform: 13 | cache_timeout: int = cache_timeout_env_var.get() 14 | 15 | # We can get a little fancier with more advanced environment variables 16 | @dataclass 17 | class ConnectionInfo: 18 | hostname: str 19 | port: int 20 | api_token: str 21 | use_ssl: bool 22 | 23 | # specify an environment variable that automatically converts to a ConnectionInfo, by drawing 24 | # from multiple environment variables 25 | connection_info_env_var = env_var('CONNECTION_INFO_', type=ConnectionInfo, args={ 26 | 'hostname': env_var('HOSTNAME', type=str), # note the prefix, we will look for the host 27 | # name under the environment variable 28 | # CONNECTION_INFO_HOSTNAME 29 | 'port': inferred_env_var('PORT'), # you can omit the type of the argument for many classes 30 | 'api_token': env_var('API_TOKEN', type=str, default=None), 31 | 'use_ssl': env_var('USE_SSL', type=bool, default=False) 32 | }) 33 | 34 | # to retrieve its value we just perform: 35 | connection_info: ConnectionInfo = connection_info_env_var.get() 36 | 37 | Envolved cuts down on boilerplate and allows for more reusable code. 38 | 39 | .. code-block:: python 40 | 41 | # If we to accept connection info for another API, we don't need to repeat ourselves 42 | 43 | secondary_connection_info_env_var = connection_info_env_var.with_prefix('SECONDARY_') 44 | 45 | # the hostname for our secondary connection info is now SECONDARY_CONNECTION_INFO_HOSTNAME 46 | 47 | -------------------------------------------------------------------------------- /docs/string_parsing.rst: -------------------------------------------------------------------------------- 1 | String Parsing- parsing EnvVars easily 2 | ========================================== 3 | 4 | Envolved comes with a rich suite of parsers out of the box, to be used the the ``type`` param for 5 | :func:`Single-Variable EnvVars `. 6 | 7 | Primitive parsers 8 | ----------------- 9 | 10 | By default, any callable that accepts a string can be used as a parser, this includes many built-in types and factories 11 | like ``int``, ``float``, ``datetime.fromisoformat``, ``json.loads``, ``re.compile``, and even ``str``. 12 | 13 | Special parsers 14 | --------------- 15 | 16 | Some built-in callables translate to special predefined parsers. For example, the ``bool`` type would be pretty 17 | ineffective on its own as a parser, which is why envolved knows to treat the ``bool`` type as a special parser that 18 | translates the string ``"True"`` and ``"False"`` to ``True`` and ``False`` respectively. 19 | 20 | .. code-block:: python 21 | 22 | enable_cache_ev = env_var("ENABLE_CACHE", type=bool) 23 | 24 | os.environ["ENABLE_CACHE"] = "False" 25 | 26 | assert enable_cache_ev.get() is False 27 | 28 | Users can disable the special meaning of some types by wrapping them in a dummy callable. 29 | 30 | .. code-block:: python 31 | 32 | enable_cache_ev = env_var("ENABLE_CACHE", type=lambda x: bool(x)) 33 | 34 | os.environ["ENABLE_CACHE"] = "False" 35 | 36 | assert enable_cache_ev.get() is True 37 | 38 | All the special parsers are: 39 | 40 | * ``bytes``: encodes the string as UTF-8 41 | * ``bool``: translates the string ``"True"`` and ``"False"`` to ``True`` and ``False`` respectively (equivalent to 42 | ``BoolParser(['true'], ['false'])``, see :class:`~parsers.BoolParser`). 43 | * ``complex``: parses the string as a complex number, treating "i" as an imaginary unit in addition to "j". 44 | * union type ``A | None`` or ``typing.Union[A, None]`` or ``typing.Optional[A]``: Will treat the parser as though it 45 | only parses ``A``. 46 | * enum type ``E``: translates each enum name to the corresponding enum member, ignoring cases (equivalent to 47 | ``LookupParser.case_insensitive(E)`` see :class:`~parsers.LookupParser`). 48 | * pydantic ``BaseModel``: parses the string as JSON and validates it against the model (both pydantic v1 and v2 49 | models are supported). 50 | * pydantic ``TypeAdapter``: parses the string as JSON and validates it against the adapted type. 51 | 52 | 53 | Utility Parsers 54 | --------------- 55 | .. module:: parsers 56 | 57 | .. class:: BoolParser(maps_to_true: collections.abc.Iterable[str] = (), \ 58 | maps_to_false: collections.abc.Iterable[str] = (), *, default: bool | None = None, \ 59 | case_sensitive: bool = False) 60 | 61 | A parser to translate strings to ``True`` or ``False``. 62 | 63 | :param maps_to_true: A list of strings that translate to ``True``. 64 | :param maps_to_false: A list of strings that translate to ``False``. 65 | :param default: The default value to return if the string is not mapped to either value. Set to ``None`` to raise an 66 | exception. 67 | :param case_sensitive: Whether the strings to match are case sensitive. 68 | 69 | .. class:: CollectionParser(delimiter: str | typing.Pattern, inner_parser: ParserInput[E], \ 70 | output_type: collections.abc.Callable[[collections.abc.Iterator[E]], G] = list, \ 71 | opener: str | typing.Pattern = '', closer: str | typing.Pattern = '', *, strip: bool = True) 72 | 73 | A parser to translate a delimited string to a collection of values. 74 | 75 | :param delimiter: The delimiter string or pattern to split the string on. 76 | :param inner_parser: The parser to use to parse the elements of the collection. Note this parser is treated the 77 | same an an EnvVar type, so :ref:`string_parsing:Special parsers` apply. 78 | :param output_type: The type to use to aggregate the parsed items to a collection. Defaults to list. 79 | :param opener: If set, specifies a string or pattern that should be at the beginning of the delimited string. 80 | :param closer: If set, specifies a string or pattern that should be at the end of the delimited string. Note that providing 81 | a pattern will slow down the parsing process. 82 | :param strip: Whether or not to strip whitespaces from the beginning and end of each item. 83 | 84 | .. code-block:: python 85 | 86 | countries = env_var("COUNTRIES", type=CollectionParser(",", str.lower, set)) 87 | 88 | os.environ["COUNTRIES"] = "United States,Canada,Mexico" 89 | 90 | assert countries.get() == {"united states", "canada", "mexico"} 91 | 92 | .. classmethod:: pair_wise_delimited(pair_delimiter: str | typing.Pattern, \ 93 | key_value_delimiter: str | typing.Pattern, \ 94 | key_type: ParserInput[K], \ 95 | value_type: ParserInput[V] | collections.abc.Mapping[K, ParserInput[V]], \ 96 | output_type: collections.abc.Callable[[collections.abc.Iterable[tuple[K,V]]], G] = ..., *, \ 97 | key_first: bool = True, opener: str | typing.Pattern = '', \ 98 | closer: str | typing.Pattern = '', strip: bool = True, strip_keys: bool = True, strip_values: bool = True) -> CollectionParser[G] 99 | 100 | A factory method to create a :class:`CollectionParser` where each item is a delimited key-value pair. 101 | 102 | :param pair_delimiter: The delimiter string or pattern between any two key-value pairs. 103 | :param key_value_delimiter: The delimiter string or pattern between the key and the value. 104 | :param key_type: The parser to use to parse the keys. Note this parser is treated the same an an EnvVar type, 105 | so :ref:`string_parsing:Special parsers` apply. 106 | :param value_type: The parser to use to parse the values. Note this parser is treated the same an an EnvVar 107 | type, so :ref:`string_parsing:Special parsers` apply. This can also be a mapping from keys to parsers, to 108 | specify different parsers for different keys. 109 | :param output_type: The type to use to aggregate the parsed key-value pairs to a collection. Defaults to a 110 | ``dict`` that raises an exception if a key appears more than once. 111 | :param key_first: If set to ``True`` (the default), the first element in each key-value pair will be interpreted 112 | as the key. If set to ``False``, the second element in each key-value pair will be interpreted as the key. 113 | :param opener: Acts the same as in the :class:`constructor `. 114 | :param closer: Acts the same as in the :class:`constructor `. 115 | :param strip: Acts the same as in the :class:`constructor `. 116 | :param strip_keys: Whether or not to strip whitespaces from the beginning and end of each key in every pair. 117 | :param strip_values: Whether or not to strip whitespaces from the beginning and end of each value in every pair. 118 | 119 | .. code-block:: python 120 | :caption: Using CollectionParser.pair_wise_delimited to parse arbitrary HTTP headers. 121 | 122 | headers_ev = env_var("HTTP_HEADERS", 123 | type=CollectionParser.pair_wise_delimited(";", ":", str.upper, 124 | str)) 125 | 126 | os.environ["HTTP_HEADERS"] = "Foo:bar;baz:qux" 127 | 128 | assert headers_ev.get() == {"FOO": "bar", "BAZ": "qux"} 129 | 130 | .. code-block:: python 131 | :caption: Using CollectionParser.pair_wise_delimited to parse a key-value collection with differing value 132 | types. 133 | 134 | server_params_ev = env_var("SERVER_PARAMS", 135 | type=CollectionParser.pair_wise_delimited(";", ":", str, { 136 | 'host': str, 137 | 'port': int, 138 | 'is_ssl': bool,})) 139 | 140 | os.environ["SERVER_PARAMS"] = "host:localhost;port:8080;is_ssl:false" 141 | 142 | assert server_params_ev.get() == {"host": "localhost", "port": 8080, "is_ssl": False} 143 | 144 | .. class:: FindIterCollectionParser(element_pattern: typing.Pattern, element_func: collections.abc.Callable[[re.Match], E], \ 145 | output_type: collections.abc.Callable[[collections.abc.Iterator[E]], G] = list, \ 146 | opener: str | typing.Pattern = '', closer: str | typing.Pattern = '') 147 | 148 | A parser to translate a string to a collection of values by splitting the string to continguous elements that match 149 | a regex pattern. This parser is useful for parsing strings that have a repeating, complex structure, or in cases where 150 | a :class:`naive split ` would split the string incorrectly. 151 | 152 | :param element_pattern: A regex pattern to find the elements in the string. 153 | :param element_func: A function that takes a regex match object and returns an element. 154 | :param output_type: The type to use to aggregate the parsed items to a collection. Defaults to list. 155 | :param opener: If set, specifies a string or pattern that should be at the beginning of the string. 156 | :param closer: If set, specifies a string or pattern that should be at the end of the string. Note that providing 157 | a pattern will slow down the parsing process. 158 | 159 | .. code-block:: python 160 | :caption: Using FindIterCollectionParser to parse a string of comma-separated groups of numbers. 161 | 162 | def parse_group(match: re.Match) -> set[int]: 163 | return {int(x) for x in match.group(1).split(',')} 164 | 165 | groups_ev = env_var("GROUPS", type=FindIterCollectionParser( 166 | re.compile(r"{([,\d]+)}(,|$)"), 167 | parse_group 168 | )) 169 | 170 | os.environ["GROUPS"] = "{1,2,3},{4,5,6},{7,8,9}" 171 | 172 | assert groups_ev.get() == [{1, 2, 3}, {4, 5, 6}, {7, 8, 9}] 173 | 174 | 175 | .. class:: MatchParser(cases: collections.abc.Iterable[tuple[typing.Pattern[str] | str, T]] | \ 176 | collections.abc.Mapping[str, T] | type[enum.Enum], fallback: T = ...) 177 | 178 | A parser that checks a string against a se of cases, returning the value of first case that matches. 179 | 180 | :param cases: An iterable of match-value pairs. The match can be a string or a regex pattern (which will need to 181 | fully match the string). The case list can also be a mapping of strings to values, or an enum type, in 182 | which case the names of the enum members will be used as the matches. 183 | :param fallback: The value to return if no case matches. If not specified, an exception will be raised. 184 | 185 | .. code-block:: python 186 | 187 | class Color(enum.Enum): 188 | RED = 1 189 | GREEN = 2 190 | BLUE = 3 191 | 192 | color_ev = env_var("COLOR", type=MatchParser(Color)) 193 | 194 | os.environ["COLOR"] = "RED" 195 | 196 | assert color_ev.get() == Color.RED 197 | 198 | .. classmethod:: case_insensitive(cases: collections.abc.Iterable[tuple[str, T]] | \ 199 | collections.abc.Mapping[str, T] | type[enum.Enum], fallback: T = ...) -> MatchParser[T] 200 | 201 | Create a :class:`MatchParser` where the matches are case insensitive. If two cases are equivalent under 202 | case-insensitivity, an error will be raised. 203 | 204 | :param cases: Acts the same as in the :class:`constructor `. Regex patterns are not supported. 205 | :param fallback: Acts the same as in the :class:`constructor `. 206 | 207 | .. class:: LookupParser(lookup: collection.abc.Iterable[tuple[str, T]] | \ 208 | collections.abc.Mapping[str, T] | type[enum.Enum], fallback: T = ...) 209 | 210 | A parser that checks a string against a set of cases, returning the value of the matching case. This is a more efficient 211 | version of :class:`MatchParser` that uses a dictionary to store the cases. 212 | 213 | :param lookup: An iterable of match-value pairs, a mapping of strings to values, or an enum type, 214 | in which case the names of the enum members will be used as the matches. 215 | :param fallback: The value to return if no case matches. If not specified, an exception will be raised. 216 | 217 | .. code-block:: python 218 | 219 | class Color(enum.Enum): 220 | RED = 1 221 | GREEN = 2 222 | BLUE = 3 223 | 224 | color_ev = env_var("COLOR", type=LookupParser(Color)) 225 | 226 | os.environ["COLOR"] = "RED" 227 | 228 | assert color_ev.get() == Color.RED 229 | 230 | .. classmethod:: case_insensitive(lookup: collection.abc.Iterable[tuple[str, T]] | \ 231 | collections.abc.Mapping[str, T] | type[enum.Enum], fallback: T = ...) -> LookupParser[T] 232 | 233 | Create a :class:`LookupParser` where the matches are case insensitive. If two cases are equivalent under 234 | case-insensitivity, an error will be raised. 235 | 236 | :param lookup: Acts the same as in the :class:`constructor `. 237 | :param fallback: Acts the same as in the :class:`constructor `. -------------------------------------------------------------------------------- /docs/testing_utilities.rst: -------------------------------------------------------------------------------- 1 | Testing Utilities 2 | ===================== 3 | 4 | Envolved makes testing environment variables easy with the :attr:`~envvar.EnvVar.monkeypatch` attribute and 5 | :meth:`~envvar.EnvVar.patch` context method. They allows you to set a predefined EnvVar value and then restore the 6 | original value when the test is finished. 7 | 8 | .. code-block:: python 9 | :emphasize-lines: 5-6 10 | 11 | cache_time_ev = env_var('CACHE_TIME', type=10) 12 | 13 | class TestAppStartup(unittest.TestCase): 14 | def test_startup(self): 15 | with cache_time_ev.patch(10): 16 | # now within this context, cache_time_ev.get() will return 10 17 | my_app.startup() 18 | self.assertEqual(my_app.cache_time, 10) 19 | 20 | note that `cache_time_ev.patch(10)` just sets attribute `cache_time_ev.monkeypatch` to ``10``, and restores it to its 21 | previous value when the context is exited. We might as well have done: 22 | 23 | .. code-block:: python 24 | :emphasize-lines: 5-6, 9 25 | 26 | cache_time_ev = env_var('CACHE_TIME', type=10) 27 | 28 | class TestAppStartup(unittest.TestCase): 29 | def test_startup(self): 30 | previous_cache_patch = cache_time_ev.monkeypatch 31 | cache_time_ev.monkeypatch = 10 32 | # now within this context, cache_time_ev.get() will return 10 33 | my_app.startup() 34 | cache_time_ev.monkeypatch = previous_cache_patch 35 | self.assertEqual(my_app.cache_time, 10) 36 | 37 | Unittest 38 | ------------- 39 | 40 | In :mod:`unittest` tests, we can use the :any:`unittest.mock.patch.object` method decorate a test method to the values we 41 | want to test with. 42 | 43 | .. code-block:: python 44 | :emphasize-lines: 4, 6 45 | 46 | cache_time_ev = env_var('CACHE_TIME', type=10) 47 | 48 | class TestAppStartup(unittest.TestCase): 49 | @unittest.patch.object(cache_time_ev, 'monkeypatch', 10) 50 | def test_startup(self): 51 | # now within this method, cache_time_ev.get() will return 10 52 | my_app.startup() 53 | self.assertEqual(my_app.cache_time, 10) 54 | 55 | Pytest 56 | ------------ 57 | 58 | When using :mod:`pytest` we can use the 59 | `monkeypatch fixture `_ fixture to patch our EnvVars. 60 | 61 | .. code-block:: python 62 | :emphasize-lines: 2 63 | 64 | def test_app_startup(monkeypatch): 65 | monkeypatch.setattr(cache_time_ev, 'monkeypatch', 10) 66 | # from now on within this method, cache_time_ev.get() will return 10 67 | my_app.startup() 68 | assert my_app.cache_time == 10 69 | 70 | Using monkeypatch for different scopes 71 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 72 | 73 | Sometimes we may want to apply a monkeypatch over a non-function-scope fixture. We will find an error in this case 74 | because the built-in monkeypatch fixture is only available in function scope. To overcome this, we can create our own 75 | monkeypatch fixture. 76 | 77 | .. code-block:: python 78 | 79 | from pytest import fixture, MonkeyPatch 80 | 81 | @fixture(scope='session') 82 | def session_monkeypatch(request): 83 | with MonkeyPatch.context() as m: 84 | yield m 85 | 86 | @fixture(scope='session') 87 | def app(session_monkeypatch): 88 | monkeypatch.setattr(cache_time_ev, 'monkeypatch', 10) 89 | app = MyApp() 90 | return app 91 | 92 | def test_app_cache_time(app): 93 | assert app.cache_time == 10 94 | 95 | ``monkeypatch`` doesn't affect the environment 96 | ---------------------------------------------- 97 | 98 | An important thing to note is that the ``monkeypatch`` fixture doesn't affect the actual environment, only the specific 99 | EnvVar that was patched. 100 | 101 | .. code-block:: python 102 | 103 | cache_time_ev = env_var('CACHE_TIME', type=int) 104 | 105 | def test_one(monkeypatch): 106 | monkeypatch.setattr(cache_time_ev, 'monkeypatch', 10) 107 | assert os.getenv('CACHE_TIME') == '10' # this will fail 108 | 109 | cache_time_2_ev = env_var('CACHE_TIME', type=int) 110 | 111 | def test_two(monkeypatch): 112 | monkeypatch.setattr(cache_time_ev, 'monkeypatch', 10) 113 | assert cache_time_2_ev.get() == 10 # this will fail too 114 | 115 | In cases where an environment variable is retrieved from different EnvVars, or with libraries other than envolved, we'll 116 | have to set the environment directly, by using the :attr:`envvar.SingleEnvVar.key` property to get the actual 117 | environment name. In pytest we can use the monkeypatch fixture to do this. 118 | 119 | .. code-block:: python 120 | 121 | cache_time_ev = env_var('CACHE_TIME', type=int) 122 | 123 | def test_one(monkeypatch): 124 | monkeypatch.setenv(cache_time_ev.key, '10') 125 | assert os.getenv('CACHE_TIME') == '10' 126 | 127 | cache_time_2_ev = env_var('CACHE_TIME', type=int) 128 | 129 | def test_two(monkeypatch): 130 | monkeypatch.setenv(cache_time_ev.key, '10') 131 | assert cache_time_2_ev.get() == 10 -------------------------------------------------------------------------------- /envolved/__init__.py: -------------------------------------------------------------------------------- 1 | from envolved._version import __version__ 2 | from envolved.absolute_name import AbsoluteName 3 | from envolved.describe import describe_env_vars 4 | from envolved.envvar import EnvVar, Factory, as_default, discard, env_var, inferred_env_var, missing, no_patch 5 | from envolved.exceptions import MissingEnvError 6 | from envolved.factory_spec import Env 7 | 8 | __all__ = [ 9 | "__version__", 10 | "EnvVar", 11 | "MissingEnvError", 12 | "as_default", 13 | "describe_env_vars", 14 | "discard", 15 | "env_var", 16 | "inferred_env_var", 17 | "missing", 18 | "no_patch", 19 | "Factory", 20 | "AbsoluteName", 21 | "Env", 22 | ] 23 | -------------------------------------------------------------------------------- /envolved/_version.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.7.0" 2 | -------------------------------------------------------------------------------- /envolved/absolute_name.py: -------------------------------------------------------------------------------- 1 | class AbsoluteName(str): 2 | __slots__ = () 3 | 4 | 5 | def with_prefix(prefix: str, name: str) -> str: 6 | if isinstance(name, AbsoluteName): 7 | return name 8 | return prefix + name 9 | -------------------------------------------------------------------------------- /envolved/basevar.py: -------------------------------------------------------------------------------- 1 | # this module is to preserved backwards compatibility 2 | from envolved.envvar import EnvVar, SchemaEnvVar, SingleEnvVar, as_default, discard, missing, no_patch 3 | 4 | __all__ = [ 5 | "EnvVar", 6 | "as_default", 7 | "discard", 8 | "missing", 9 | "no_patch", 10 | "SchemaEnvVar", 11 | "SingleEnvVar", 12 | ] 13 | -------------------------------------------------------------------------------- /envolved/describe/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from itertools import chain 4 | from typing import Any, Iterable, List, Mapping, Set, TypeVar, Union 5 | 6 | from envolved.describe.flat import FlatEnvVarsDescription 7 | from envolved.describe.nested import NestedEnvVarsDescription, RootNestedDescription 8 | from envolved.envvar import EnvVar, InferEnvVar, all_env_vars 9 | 10 | 11 | def describe_env_vars(**kwargs: Any) -> List[str]: 12 | ret = EnvVarsDescription().nested().wrap(**kwargs) 13 | assert isinstance(ret, list) 14 | return ret 15 | 16 | 17 | class EnvVarsDescription: 18 | def __init__(self, env_vars: Iterable[EnvVar] | None = None) -> None: 19 | self.env_var_roots = set() 20 | children: Set[EnvVar] = set() 21 | 22 | if env_vars is None: 23 | env_vars = all_env_vars 24 | to_exclude = roots_to_exclude_from_description | set( 25 | chain.from_iterable(r._get_descendants() for r in roots_to_exclude_from_description) 26 | ) 27 | else: 28 | to_exclude = set() 29 | 30 | for env_var in env_vars: 31 | self.env_var_roots.add(env_var) 32 | children.update(env_var._get_descendants()) 33 | # remove any children we found along the way 34 | self.env_var_roots -= children 35 | # remove any children we were asked to exclude 36 | self.env_var_roots -= to_exclude 37 | 38 | def flat(self) -> FlatEnvVarsDescription: 39 | return FlatEnvVarsDescription.from_envvars(self.env_var_roots) 40 | 41 | def nested(self) -> NestedEnvVarsDescription: 42 | return RootNestedDescription.from_envvars(self.env_var_roots) 43 | 44 | 45 | T = TypeVar( 46 | "T", 47 | bound=Union[EnvVar, InferEnvVar, Iterable[Union[EnvVar, InferEnvVar]], Mapping[Any, Union[EnvVar, InferEnvVar]]], 48 | ) 49 | 50 | roots_to_exclude_from_description: Set[EnvVar] = set() 51 | 52 | 53 | def exclude_from_description(to_exclude: T) -> T: 54 | if isinstance(to_exclude, EnvVar): 55 | roots_to_exclude_from_description.add(to_exclude) 56 | elif isinstance(to_exclude, Mapping): 57 | exclude_from_description(to_exclude.values()) 58 | elif isinstance(to_exclude, Iterable): 59 | for v in to_exclude: 60 | exclude_from_description(v) 61 | elif isinstance(to_exclude, InferEnvVar): 62 | pass 63 | else: 64 | raise TypeError(f"cannot exclude unrecognized type {type(to_exclude)!r}") 65 | 66 | return to_exclude 67 | -------------------------------------------------------------------------------- /envolved/describe/flat.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from itertools import chain, groupby 5 | from typing import Any, Iterable, List, Tuple 6 | from warnings import warn 7 | 8 | from envolved.describe.util import prefix_description, wrap_description as wrap 9 | from envolved.envvar import Description, EnvVar, SingleEnvVar 10 | 11 | 12 | @dataclass 13 | class SingleEnvVarDescription: 14 | path: Iterable[str] 15 | env_var: SingleEnvVar 16 | 17 | @property 18 | def key(self) -> str: 19 | key = self.env_var.key 20 | if not self.env_var.case_sensitive: 21 | key = key.upper() 22 | 23 | return key 24 | 25 | def wrap(self, **kwargs: Any) -> Iterable[str]: 26 | text: Description 27 | if self.env_var.description is None: 28 | text = self.key 29 | else: 30 | text = prefix_description(self.key + ": ", self.env_var.description) 31 | subsequent_indent_increment = len(self.key) + 2 32 | kwargs["subsequent_indent"] = kwargs.get("subsequent_indent", "") + " " * subsequent_indent_increment 33 | return wrap(text, **kwargs) 34 | 35 | @classmethod 36 | def from_envvar(cls, path: Tuple[str, ...], env_var: EnvVar) -> Iterable[SingleEnvVarDescription]: 37 | if isinstance(env_var, SingleEnvVar): 38 | yield cls( 39 | ( 40 | *path, 41 | env_var.key.upper(), 42 | ), 43 | env_var, 44 | ) 45 | else: 46 | min_child = min( 47 | (e.key.upper() for e in env_var._get_descendants() if isinstance(e, SingleEnvVar)), 48 | default=None, 49 | ) 50 | if min_child is not None: 51 | path = (*path, min_child) 52 | for child in env_var._get_children(): 53 | yield from cls.from_envvar(path, child) 54 | 55 | @classmethod 56 | def collate(cls, instances: Iterable[SingleEnvVarDescription]) -> SingleEnvVarDescription: 57 | # collate multiple descriptions of the same env var 58 | assert len({i.env_var.key for i in instances}) == 1 59 | # in case of conflict we choose arbitrarily, with a warning 60 | # first we prefer an env var with a description, if one exists 61 | with_description = [] 62 | without_description = [] 63 | for instance in instances: 64 | if instance.env_var.description is None: 65 | without_description.append(instance) 66 | else: 67 | with_description.append(instance) 68 | 69 | if with_description: 70 | if len(with_description) > 1 and len({i.env_var.description for i in with_description}) > 1: 71 | warn( 72 | f"multiple descriptions for env var {with_description[0].env_var.key!r}, choosing arbitrarily", 73 | stacklevel=2, 74 | ) 75 | return with_description[0] 76 | else: 77 | return without_description[0] 78 | 79 | 80 | class FlatEnvVarsDescription: 81 | def __init__(self, env_var_descriptions: Iterable[SingleEnvVarDescription]) -> None: 82 | self.env_var_descriptions = env_var_descriptions 83 | 84 | def wrap_sorted(self, *, unique_keys: bool = True, **kwargs: Any) -> Iterable[str]: 85 | def key(i: SingleEnvVarDescription) -> str: 86 | return i.key.upper() 87 | 88 | env_var_descriptions = sorted(self.env_var_descriptions, key=key) 89 | 90 | ret: List[str] = [] 91 | 92 | for _, group in groupby(env_var_descriptions, key=key): 93 | g = tuple(group) 94 | if len(g) > 1 and unique_keys: 95 | ret.extend(SingleEnvVarDescription.collate(g).wrap(**kwargs)) 96 | else: 97 | ret.extend(chain.from_iterable(i.wrap(**kwargs) for i in g)) 98 | 99 | return ret 100 | 101 | def wrap_grouped(self, **kwargs: Any) -> Iterable[str]: 102 | env_var_descriptions = sorted(self.env_var_descriptions, key=lambda i: (i.path, i.env_var.key)) 103 | ret = list( 104 | chain.from_iterable( 105 | chain.from_iterable(d.wrap(**kwargs) for d in group) 106 | for _, group in groupby(env_var_descriptions, key=lambda i: i.path) 107 | ) 108 | ) 109 | return ret 110 | 111 | @classmethod 112 | def from_envvars(cls, env_vars: Iterable[EnvVar]) -> FlatEnvVarsDescription: 113 | env_var_descriptions = list( 114 | chain.from_iterable(SingleEnvVarDescription.from_envvar((), env_var) for env_var in env_vars) 115 | ) 116 | return cls(env_var_descriptions) 117 | -------------------------------------------------------------------------------- /envolved/describe/nested.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import ABC, abstractmethod 4 | from dataclasses import dataclass 5 | from typing import Any, Iterable, Optional, Tuple 6 | 7 | from envolved.describe.util import prefix_description, suffix_description, wrap_description as wrap 8 | from envolved.envvar import Description, EnvVar, SchemaEnvVar, SingleEnvVar 9 | 10 | 11 | class NestedEnvVarsDescription(ABC): 12 | @abstractmethod 13 | def get_path(self) -> Tuple[str, ...]: ... 14 | 15 | @abstractmethod 16 | def wrap(self, *, indent_increment: str, **kwargs: Any) -> Iterable[str]: ... 17 | 18 | @classmethod 19 | def from_env_var(cls, path: Tuple[str, ...], env_var: EnvVar) -> NestedEnvVarsDescription: 20 | if isinstance(env_var, SingleEnvVar): 21 | path = (*path, env_var.key.upper()) 22 | return SingleNestedDescription(path, env_var) 23 | else: 24 | assert isinstance(env_var, SchemaEnvVar) 25 | min_child = min( 26 | (e.key.upper() for e in env_var._get_descendants() if isinstance(e, SingleEnvVar)), 27 | default=None, 28 | ) 29 | if min_child is not None: 30 | path = (*path, min_child) 31 | children = [cls.from_env_var(path, child) for child in env_var._get_children()] 32 | return SchemaNestedDescription(path, env_var, children) 33 | 34 | 35 | @dataclass 36 | class SingleNestedDescription(NestedEnvVarsDescription): 37 | path: Tuple[str, ...] 38 | env_var: SingleEnvVar 39 | 40 | @property 41 | def key(self) -> str: 42 | key = self.env_var.key 43 | if not self.env_var.case_sensitive: 44 | key = key.upper() 45 | 46 | return key 47 | 48 | def get_path(self) -> Tuple[str, ...]: 49 | return self.path 50 | 51 | def wrap(self, *, indent_increment: str, **kwargs: Any) -> Iterable[str]: 52 | text: Description 53 | if self.env_var.description is None: 54 | text = self.key 55 | else: 56 | prefix = self.key + ": " 57 | text = prefix_description(prefix, self.env_var.description) 58 | subsequent_indent_increment = len(prefix) 59 | kwargs["subsequent_indent"] = kwargs.get("subsequent_indent", "") + " " * subsequent_indent_increment 60 | return wrap(text, **kwargs) 61 | 62 | 63 | class NestedDescriptionWithChildren(NestedEnvVarsDescription): 64 | children: Iterable[NestedEnvVarsDescription] 65 | 66 | @abstractmethod 67 | def title(self) -> Description | None: ... 68 | 69 | def wrap(self, *, indent_increment: str, **kwargs: Any) -> Iterable[str]: 70 | title = self.title() 71 | if title is not None: 72 | yield from wrap(title, **kwargs) 73 | kwargs["subsequent_indent"] = kwargs.get("subsequent_indent", "") + indent_increment 74 | kwargs["initial_indent"] = kwargs.get("initial_indent", "") + indent_increment 75 | for child in sorted(self.children, key=lambda i: i.get_path()): 76 | yield from child.wrap(indent_increment=indent_increment, **kwargs) 77 | 78 | 79 | @dataclass 80 | class SchemaNestedDescription(NestedDescriptionWithChildren): 81 | path: Tuple[str, ...] 82 | env_var: SchemaEnvVar 83 | children: Iterable[NestedEnvVarsDescription] 84 | 85 | def get_path(self) -> Tuple[str, ...]: 86 | return self.path 87 | 88 | def title(self) -> Description | None: 89 | if self.env_var.description is None: 90 | return "" 91 | else: 92 | return suffix_description(self.env_var.description, ":") 93 | 94 | 95 | @dataclass 96 | class RootNestedDescription(NestedDescriptionWithChildren): 97 | children: Iterable[NestedEnvVarsDescription] 98 | 99 | def get_path(self) -> Tuple[str, ...]: 100 | return () 101 | 102 | def title(self) -> Description | None: 103 | return None 104 | 105 | @classmethod 106 | def from_envvars(cls, env_vars: Iterable[EnvVar]) -> RootNestedDescription: 107 | return cls([NestedEnvVarsDescription.from_env_var((), env_var) for env_var in env_vars]) 108 | 109 | def wrap(self, *, indent_increment: Optional[str] = None, **kwargs: Any) -> Iterable[str]: 110 | if indent_increment is None: 111 | indent_increment = kwargs.get("subsequent_indent", " ") 112 | assert isinstance(indent_increment, str) 113 | return list(super().wrap(indent_increment=indent_increment, **kwargs)) 114 | -------------------------------------------------------------------------------- /envolved/describe/util.py: -------------------------------------------------------------------------------- 1 | from textwrap import wrap 2 | from typing import Any, Iterable 3 | 4 | from envolved.envvar import Description 5 | 6 | 7 | def wrap_description(description: Description, **kwargs: Any) -> Iterable[str]: 8 | if isinstance(description, str): 9 | yield from wrap(description, **kwargs) 10 | else: 11 | is_first_paragraph = True 12 | for line in description: 13 | yield from wrap(line, **kwargs) 14 | if is_first_paragraph: 15 | kwargs["initial_indent"] = kwargs.get("subsequent_indent", "") 16 | is_first_paragraph = False 17 | 18 | 19 | def prefix_description(prefix: str, description: Description) -> Description: 20 | if isinstance(description, str): 21 | return prefix + description.lstrip() 22 | elif description: 23 | return [prefix + description[0].lstrip(), *description[1:]] 24 | else: 25 | return prefix 26 | 27 | 28 | def suffix_description(description: Description, suffix: str) -> Description: 29 | if isinstance(description, str): 30 | return description.rstrip() + suffix 31 | elif description: 32 | return [*description[:-1], description[-1].rstrip() + suffix] 33 | else: 34 | return suffix 35 | -------------------------------------------------------------------------------- /envolved/envparser.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from abc import ABC, abstractmethod 3 | from os import environ, getenv, name 4 | from threading import Lock 5 | from typing import Any, MutableMapping, Set, Tuple, Type 6 | 7 | 8 | class CaseInsensitiveAmbiguityError(Exception): 9 | """ 10 | The error raised if multiple external environment variables are equally valid for a case-insensitive 11 | environment variable 12 | """ 13 | 14 | 15 | def getenv_unsafe(key: str) -> str: 16 | ret = getenv(key, None) 17 | if ret is None: 18 | raise KeyError 19 | return ret 20 | 21 | 22 | def has_env(key: str) -> bool: 23 | return getenv(key, None) is not None 24 | 25 | 26 | class BaseEnvParser(ABC): 27 | @abstractmethod 28 | def get(self, case_sensitive: bool, key: str) -> str: 29 | """ 30 | Should raise KeyError if missing, and AmbiguiyError if there are multiple case-insensitive matches 31 | """ 32 | 33 | 34 | # on windows, we are always case insensitive 35 | class CaseInsensitiveEnvParser(BaseEnvParser): 36 | def get(self, case_sensitive: bool, key: str) -> str: 37 | return getenv_unsafe(key.upper()) 38 | 39 | 40 | class ReloadingEnvParser(BaseEnvParser, ABC): 41 | environ_case_insensitive: MutableMapping[str, Set[str]] 42 | 43 | def reload(self): 44 | if self.lock.locked(): 45 | # if the lock is already held by someone, we don't need to do any work, just wait until they're done 46 | with self.lock: 47 | return 48 | with self.lock: 49 | self.environ_case_insensitive = {} 50 | for k in environ.keys(): 51 | lower = k.lower() 52 | if lower not in self.environ_case_insensitive: 53 | self.environ_case_insensitive[lower] = set() 54 | self.environ_case_insensitive[lower].add(k) 55 | 56 | def __init__(self): 57 | self.lock = Lock() 58 | self.reload() 59 | 60 | 61 | class AuditingEnvParser(ReloadingEnvParser): 62 | def __init__(self): 63 | super().__init__() 64 | sys.addaudithook(self.audit_hook) 65 | 66 | def audit_hook(self, event: str, args: Tuple[Any, ...]): 67 | if event == "os.putenv": 68 | if not args: 69 | return 70 | key = args[0] 71 | if isinstance(key, bytes): 72 | try: 73 | key = key.decode("ascii") 74 | except UnicodeDecodeError: 75 | return 76 | elif not isinstance(key, str): 77 | return 78 | lower = key.lower() 79 | with self.lock: 80 | if lower not in self.environ_case_insensitive: 81 | self.environ_case_insensitive[lower] = set() 82 | self.environ_case_insensitive[lower].add(key) 83 | elif event == "os.unsetenv": 84 | if not args: 85 | return 86 | key = args[0] 87 | if isinstance(key, bytes): 88 | try: 89 | key = key.decode("ascii") 90 | except UnicodeDecodeError: 91 | return 92 | elif not isinstance(key, str): 93 | return 94 | lower = key.lower() 95 | with self.lock: 96 | if lower in self.environ_case_insensitive: 97 | self.environ_case_insensitive[lower].discard(key) 98 | 99 | def get(self, case_sensitive: bool, key: str) -> str: 100 | if case_sensitive: 101 | return getenv_unsafe(key) 102 | 103 | lowered = key.lower() 104 | candidates = self.environ_case_insensitive[lowered] # will raise KeyError if not found 105 | if not candidates: 106 | raise KeyError(key) 107 | if key in candidates: 108 | preferred_key = key 109 | elif len(candidates) == 1: 110 | (preferred_key,) = candidates 111 | else: 112 | raise CaseInsensitiveAmbiguityError(candidates) 113 | ret = getenv(preferred_key) 114 | if ret is None: 115 | # someone messed with the env without triggering the auditing hook 116 | self.reload() 117 | return self.get(case_sensitive, key) 118 | return ret 119 | 120 | 121 | EnvParser: Type[BaseEnvParser] 122 | if name == "nt": 123 | # in windows, all env vars are uppercase 124 | EnvParser = CaseInsensitiveEnvParser 125 | else: 126 | EnvParser = AuditingEnvParser 127 | 128 | 129 | env_parser = EnvParser() 130 | """ 131 | A global parser used by environment variables 132 | """ 133 | -------------------------------------------------------------------------------- /envolved/envvar.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from _weakrefset import WeakSet 5 | from abc import ABC, abstractmethod 6 | from contextlib import contextmanager 7 | from dataclasses import dataclass 8 | from enum import Enum, auto 9 | from itertools import chain 10 | from types import MappingProxyType 11 | from typing import ( 12 | Any, 13 | Callable, 14 | Dict, 15 | Generic, 16 | Iterable, 17 | Iterator, 18 | List, 19 | Mapping, 20 | MutableSet, 21 | NoReturn, 22 | Optional, 23 | Sequence, 24 | Type, 25 | TypeVar, 26 | Union, 27 | overload, 28 | ) 29 | 30 | from envolved.absolute_name import with_prefix 31 | from envolved.envparser import CaseInsensitiveAmbiguityError, env_parser 32 | from envolved.exceptions import MissingEnvError, SkipDefault 33 | from envolved.factory_spec import FactoryArgSpec, FactorySpec, factory_spec, missing as factory_spec_missing 34 | from envolved.parsers import Parser, ParserInput, parser 35 | 36 | if sys.version_info >= (3, 10): 37 | from types import EllipsisType 38 | else: 39 | EllipsisType = NoReturn # there's no right way to do this in 3.9 40 | 41 | T = TypeVar("T") 42 | Self = TypeVar("Self") 43 | 44 | K = TypeVar("K") 45 | V = TypeVar("V") 46 | 47 | 48 | class Missing(Enum): 49 | missing = auto() 50 | 51 | 52 | missing = Missing.missing 53 | 54 | 55 | class AsDefault(Enum): 56 | as_default = auto() 57 | 58 | 59 | as_default = AsDefault.as_default 60 | 61 | 62 | class NoPatch(Enum): 63 | no_patch = auto() 64 | 65 | 66 | no_patch = NoPatch.no_patch 67 | 68 | 69 | class Discard(Enum): 70 | discard = auto() 71 | 72 | 73 | discard = Discard.discard 74 | 75 | 76 | class Unchanged(Enum): 77 | unchanged = auto() 78 | 79 | 80 | unchanged = Unchanged.unchanged 81 | 82 | Description = Union[str, Sequence[str]] 83 | 84 | 85 | @dataclass 86 | class Factory(Generic[T]): 87 | callback: Callable[[], T] 88 | 89 | 90 | @dataclass 91 | class _EnvVarResult(Generic[T]): 92 | value: Union[T, Discard] 93 | exists: bool 94 | 95 | 96 | def unwrap_validator(func: Callable[[T], T]) -> Callable[[T], T]: 97 | if isinstance(func, staticmethod): 98 | func = func.__func__ 99 | return func 100 | 101 | 102 | class EnvVar(Generic[T], ABC): 103 | def __init__( 104 | self, 105 | default: Union[T, Factory[T], Missing, Discard], 106 | description: Optional[Description], 107 | validators: Iterable[Callable[[T], T]] = (), 108 | ): 109 | self._validators: List[Callable[[T], T]] = [unwrap_validator(v) for v in validators] 110 | self.default = default 111 | self.description = description 112 | self.monkeypatch: Union[T, Missing, Discard, NoPatch] = no_patch 113 | 114 | def get(self, **kwargs: Any) -> T: 115 | if self.monkeypatch is not no_patch: 116 | if self.monkeypatch is missing: 117 | key = getattr(self, "key", self) 118 | raise MissingEnvError(key) 119 | return self.monkeypatch # type: ignore[return-value] 120 | return self._get_validated(**kwargs).value # type: ignore[return-value] 121 | 122 | def validator(self, validator: Callable[[T], T]) -> EnvVar[T]: 123 | self._validators.append(validator) 124 | return self 125 | 126 | def _get_validated(self, **kwargs: Any) -> _EnvVarResult[T]: 127 | try: 128 | value = self._get(**kwargs) 129 | except SkipDefault as sd: 130 | raise sd.args[0] from None 131 | except MissingEnvError as mee: 132 | if self.default is missing: 133 | raise mee 134 | 135 | default: Union[T, Discard] 136 | if isinstance(self.default, Factory): 137 | default = self.default.callback() 138 | else: 139 | default = self.default 140 | 141 | return _EnvVarResult(default, exists=False) 142 | for validator in self._validators: 143 | value = validator(value) 144 | return _EnvVarResult(value, exists=True) 145 | 146 | @abstractmethod 147 | def _get(self, **kwargs: Any) -> T: 148 | pass 149 | 150 | @abstractmethod 151 | def with_prefix( 152 | self: Self, 153 | prefix: str, 154 | *, 155 | default: Union[T, Factory[T], Missing, Discard, Unchanged] = unchanged, 156 | description: Union[Description, None, Unchanged] = unchanged, 157 | ) -> Self: 158 | pass 159 | 160 | @abstractmethod 161 | def _get_children(self) -> Iterable[EnvVar]: 162 | pass 163 | 164 | def _get_descendants(self) -> Iterable[EnvVar]: 165 | for child in self._get_children(): 166 | yield child 167 | yield from child._get_descendants() 168 | 169 | @contextmanager 170 | def patch(self, value: Union[T, Missing, Discard]) -> Iterator[None]: 171 | previous = self.monkeypatch 172 | self.monkeypatch = value 173 | try: 174 | yield 175 | finally: 176 | self.monkeypatch = previous 177 | 178 | 179 | class SingleEnvVar(EnvVar[T]): 180 | def __init__( 181 | self, 182 | key: str, 183 | default: Union[T, Missing, Discard, Factory[T]] = missing, 184 | *, 185 | type: Union[Type[T], Parser[T]], 186 | description: Optional[Description] = None, 187 | case_sensitive: bool = False, 188 | strip_whitespaces: bool = True, 189 | validators: Iterable[Callable[[T], T]] = (), 190 | ): 191 | super().__init__(default, description, validators) 192 | self._key = key 193 | self._type = parser(type) 194 | self.case_sensitive = case_sensitive 195 | self.strip_whitespaces = strip_whitespaces 196 | 197 | @property 198 | def key(self) -> str: 199 | return self._key 200 | 201 | @property 202 | def type(self) -> Parser[T]: 203 | return self._type 204 | 205 | def _get(self, **kwargs: Any) -> T: 206 | try: 207 | raw_value = env_parser.get(self.case_sensitive, self._key) 208 | except KeyError as err: 209 | raise MissingEnvError(self._key) from err 210 | except CaseInsensitiveAmbiguityError as cia: 211 | raise RuntimeError(f"environment error: cannot choose between environment variables {cia.args[0]}") from cia 212 | 213 | if self.strip_whitespaces: 214 | raw_value = raw_value.strip() 215 | return self.type(raw_value, **kwargs) 216 | 217 | def with_prefix( 218 | self, 219 | prefix: str, 220 | *, 221 | default: Union[T, Factory[T], Missing, Discard, Unchanged] = unchanged, 222 | description: Union[Description, None, Unchanged] = unchanged, 223 | type: Union[Type[T], Parser[T], Unchanged] = unchanged, 224 | case_sensitive: Union[bool, Unchanged] = unchanged, 225 | strip_whitespaces: Union[bool, Unchanged] = unchanged, 226 | ) -> SingleEnvVar[T]: 227 | if default is unchanged: 228 | default = self.default 229 | if description is unchanged: 230 | description = self.description 231 | type_ = type 232 | if type_ is unchanged: 233 | type_ = self.type 234 | if case_sensitive is unchanged: 235 | case_sensitive = self.case_sensitive 236 | if strip_whitespaces is unchanged: 237 | strip_whitespaces = self.strip_whitespaces 238 | return register_env_var( 239 | SingleEnvVar( 240 | with_prefix(prefix, self._key), 241 | default, 242 | type=type_, 243 | description=description, 244 | case_sensitive=case_sensitive, 245 | strip_whitespaces=strip_whitespaces, 246 | validators=self._validators, 247 | ) 248 | ) 249 | 250 | def _get_children(self) -> Iterable[EnvVar[Any]]: 251 | return () 252 | 253 | 254 | class SchemaEnvVar(EnvVar[T]): 255 | def __init__( 256 | self, 257 | keys: Mapping[str, EnvVar[Any]], 258 | default: Union[T, Missing, Discard, Factory[T]] = missing, 259 | *, 260 | type: Callable[..., T], 261 | description: Optional[Description] = None, 262 | on_partial: Union[T, Missing, AsDefault, Discard, Factory[T]] = missing, 263 | validators: Iterable[Callable[[T], T]] = (), 264 | pos_args: Sequence[EnvVar[Any]] = (), 265 | ): 266 | super().__init__(default, description, validators) 267 | self._args = keys 268 | self._pos_args = pos_args 269 | self._type = type 270 | self.on_partial = on_partial 271 | 272 | @property 273 | def type(self) -> Callable[..., T]: 274 | return self._type 275 | 276 | @property 277 | def args(self) -> Mapping[str, EnvVar[Any]]: 278 | return MappingProxyType(self._args) 279 | 280 | @property 281 | def pos_args(self) -> Sequence[EnvVar[Any]]: 282 | return tuple(self._pos_args) 283 | 284 | @property 285 | def on_partial(self) -> Union[T, Missing, AsDefault, Discard, Factory[T]]: 286 | return self._on_partial 287 | 288 | @on_partial.setter 289 | def on_partial(self, value: Union[T, Missing, AsDefault, Discard, Factory[T]]): 290 | if value is as_default and self.default is missing: 291 | raise TypeError("on_partial cannot be as_default if default is missing") 292 | self._on_partial = value 293 | 294 | def _get(self, **kwargs: Any) -> T: 295 | pos_values = [] 296 | kw_values = kwargs 297 | any_exist = False 298 | errs: List[MissingEnvError] = [] 299 | for env_var in self._pos_args: 300 | try: 301 | result = env_var._get_validated() # noqa: SLF001 302 | except MissingEnvError as e: # noqa: PERF203 303 | errs.append(e) 304 | else: 305 | if result.value is discard: 306 | break 307 | pos_values.append(result.value) 308 | if result.exists: 309 | any_exist = True 310 | for key, env_var in self._args.items(): 311 | if key in kw_values: 312 | # key could be in kwargs because it was passed in as a positional argument, if so, we don't want to 313 | # overwrite it 314 | continue 315 | try: 316 | result = env_var._get_validated() # noqa: SLF001 317 | except MissingEnvError as e: 318 | errs.append(e) 319 | else: 320 | if result.value is not discard: 321 | kw_values[key] = result.value 322 | if result.exists: 323 | any_exist = True 324 | 325 | if errs: 326 | if self.on_partial is not as_default and any_exist: 327 | if self.on_partial is missing: 328 | raise SkipDefault(errs[0]) 329 | if isinstance(self.on_partial, Factory): 330 | return self.on_partial.callback() 331 | return self.on_partial # type: ignore[return-value] 332 | raise errs[0] 333 | return self._type(*pos_values, **kw_values) 334 | 335 | def with_prefix( 336 | self, 337 | prefix: str, 338 | *, 339 | default: Union[T, Factory[T], Missing, Discard, Unchanged] = unchanged, 340 | description: Union[Unchanged, None, Description] = unchanged, 341 | type: Union[Type[T], Parser[T], Unchanged] = unchanged, 342 | on_partial: Union[T, Missing, AsDefault, Discard, Factory[T], Unchanged] = unchanged, 343 | ) -> SchemaEnvVar[T]: 344 | if default is unchanged: 345 | default = self.default 346 | if description is unchanged: 347 | description = self.description 348 | type_ = type 349 | if type_ is unchanged: 350 | type_ = self.type 351 | if on_partial is unchanged: 352 | on_partial = self.on_partial 353 | return register_env_var( 354 | SchemaEnvVar( 355 | {k: v.with_prefix(prefix) for k, v in self._args.items()}, 356 | default, 357 | type=type_, 358 | description=description, 359 | on_partial=on_partial, 360 | validators=self._validators, 361 | pos_args=tuple(v.with_prefix(prefix) for v in self._pos_args), 362 | ) 363 | ) 364 | 365 | def _get_children(self) -> Iterable[EnvVar[Any]]: 366 | return chain(self._args.values(), self._pos_args) 367 | 368 | 369 | @overload 370 | def env_var( 371 | key: str, 372 | *, 373 | default: Union[T, Missing, AsDefault, Discard, Factory[T]] = missing, 374 | description: Optional[Description] = None, 375 | validators: Iterable[Callable[[T], T]] = (), 376 | case_sensitive: bool = False, 377 | strip_whitespaces: bool = True, 378 | ) -> InferEnvVar[T]: 379 | pass 380 | 381 | 382 | @overload 383 | def env_var( 384 | key: str, 385 | *, 386 | type: ParserInput[T], 387 | default: Union[T, Missing, Discard, Factory[T]] = missing, 388 | description: Optional[Description] = None, 389 | validators: Iterable[Callable[[T], T]] = (), 390 | case_sensitive: bool = False, 391 | strip_whitespaces: bool = True, 392 | ) -> SingleEnvVar[T]: 393 | pass 394 | 395 | 396 | @overload 397 | def env_var( 398 | key: str, 399 | *, 400 | type: Callable[..., T], 401 | default: Union[T, Missing, Discard, Factory[T]] = missing, 402 | pos_args: Sequence[Union[EnvVar[Any], InferEnvVar[Any]]], 403 | args: Union[Mapping[str, Union[EnvVar[Any], InferEnvVar[Any]]], EllipsisType] = MappingProxyType({}), 404 | description: Optional[Description] = None, 405 | validators: Iterable[Callable[[T], T]] = (), 406 | on_partial: Union[T, Missing, AsDefault, Discard, Factory[T]] = missing, 407 | ) -> SchemaEnvVar[T]: 408 | pass 409 | 410 | 411 | @overload 412 | def env_var( 413 | key: str, 414 | *, 415 | type: Callable[..., T], 416 | default: Union[T, Missing, Discard, Factory[T]] = missing, 417 | pos_args: Sequence[Union[EnvVar[Any], InferEnvVar[Any]]] = (), 418 | args: Union[Mapping[str, Union[EnvVar[Any], InferEnvVar[Any]]], EllipsisType], 419 | description: Optional[Description] = None, 420 | validators: Iterable[Callable[[T], T]] = (), 421 | on_partial: Union[T, Missing, AsDefault, Discard, Factory[T]] = missing, 422 | ) -> SchemaEnvVar[T]: 423 | pass 424 | 425 | 426 | def env_var( # type: ignore[misc] 427 | key: str, 428 | *, 429 | type: Optional[ParserInput[T]] = None, 430 | default: Union[T, Missing, AsDefault, Discard] = missing, 431 | description: Optional[Description] = None, 432 | validators: Iterable[Callable[[T], T]] = (), 433 | **kwargs: Any, 434 | ): 435 | pos_args = kwargs.pop("pos_args", ()) 436 | args: Mapping[str, Union[InferEnvVar[T], EnvVar[T]]] = kwargs.pop("args", {}) 437 | if args or pos_args: 438 | # schema var 439 | if type is None: 440 | raise TypeError("type cannot be omitted for schema env vars") 441 | on_partial = kwargs.pop("on_partial", missing) 442 | if kwargs: 443 | raise TypeError(f"Unexpected keyword arguments: {kwargs}") 444 | 445 | factory_specs: Optional[FactorySpec] = None 446 | 447 | if args is ...: 448 | factory_specs = factory_spec(type) 449 | args = {k: inferred_env_var() for k, v in factory_specs.keyword.items() if v.is_explicit_env} 450 | 451 | pos: List[EnvVar] = [] 452 | keys: Dict[str, EnvVar] = {} 453 | for p in pos_args: 454 | if isinstance(p, InferEnvVar): 455 | if factory_specs is None: 456 | factory_specs = factory_spec(type) 457 | idx = len(pos) 458 | if idx >= len(factory_specs.positional): 459 | raise TypeError(f"Cannot infer for positional parameter {len(pos)}") 460 | var_spec = factory_specs.positional[idx] 461 | arg: EnvVar[Any] = p.with_spec(idx, var_spec) 462 | else: 463 | arg = p 464 | pos.append(arg.with_prefix(key)) 465 | for k, v in args.items(): 466 | if isinstance(v, InferEnvVar): 467 | if factory_specs is None: 468 | factory_specs = factory_spec(type) 469 | kw_var_spec = factory_specs.keyword.get(k) 470 | arg = v.with_spec(k, kw_var_spec) 471 | else: 472 | arg = v 473 | keys[k] = arg.with_prefix(key) 474 | ev: EnvVar = SchemaEnvVar( 475 | keys, 476 | default, 477 | type=type, 478 | on_partial=on_partial, 479 | description=description, 480 | validators=validators, 481 | pos_args=tuple(pos), 482 | ) 483 | else: 484 | # single var 485 | case_sensitive = kwargs.pop("case_sensitive", False) 486 | strip_whitespaces = kwargs.pop("strip_whitespaces", True) 487 | if kwargs: 488 | raise TypeError(f"Unexpected keyword arguments: {kwargs}") 489 | if type is None: 490 | return inferred_env_var( 491 | key, 492 | default=default, 493 | description=description, 494 | validators=validators, 495 | case_sensitive=case_sensitive, 496 | strip_whitespaces=strip_whitespaces, 497 | ) 498 | ev = SingleEnvVar( 499 | key, 500 | default, 501 | type=type, 502 | case_sensitive=case_sensitive, 503 | strip_whitespaces=strip_whitespaces, 504 | description=description, 505 | validators=validators, 506 | ) 507 | return register_env_var(ev) 508 | 509 | 510 | all_env_vars: MutableSet[EnvVar] = WeakSet() 511 | 512 | EV = TypeVar("EV", bound=EnvVar) 513 | 514 | 515 | def register_env_var(ev: EV) -> EV: 516 | all_env_vars.add(ev) 517 | return ev 518 | 519 | 520 | class InferType(Enum): 521 | infer_type = auto() 522 | 523 | 524 | infer_type = InferType.infer_type 525 | 526 | 527 | @dataclass 528 | class InferEnvVar(Generic[T]): 529 | key: Optional[str] 530 | type: Any 531 | default: Union[T, Missing, AsDefault, Discard, Factory[T]] 532 | description: Optional[Description] 533 | validators: List[Callable[[T], T]] 534 | case_sensitive: bool 535 | strip_whitespaces: bool 536 | 537 | def with_spec(self, param_id: Union[str, int], spec: FactoryArgSpec | None) -> SingleEnvVar[T]: 538 | key = self.key 539 | if key is None: 540 | if spec and spec.key_override: 541 | key = spec.key_override 542 | elif not isinstance(param_id, str): 543 | raise ValueError(f"cannot infer key for positional parameter {param_id}, please specify a key") 544 | else: 545 | key = param_id 546 | 547 | default: Union[T, Missing, Discard, Factory[T]] 548 | if self.default is as_default: 549 | if spec is None: 550 | raise ValueError(f"cannot infer default for parameter {key}, parameter {param_id} not found in factory") 551 | 552 | if spec.default is factory_spec_missing: 553 | default = missing 554 | else: 555 | default = spec.default 556 | else: 557 | default = self.default 558 | 559 | if self.type is infer_type: 560 | if spec is None: 561 | raise ValueError(f"cannot infer type for parameter {key}, parameter {param_id} not found in factory") 562 | if spec.type is factory_spec_missing: 563 | raise ValueError( 564 | f"cannot infer type for parameter {key}, parameter {param_id} has no type hint in factory" 565 | ) 566 | ty = spec.type 567 | else: 568 | ty = self.type 569 | 570 | return register_env_var( 571 | SingleEnvVar( 572 | key=key, 573 | default=default, 574 | description=self.description, 575 | validators=self.validators, 576 | case_sensitive=self.case_sensitive, 577 | strip_whitespaces=self.strip_whitespaces, 578 | type=ty, 579 | ) 580 | ) 581 | 582 | def validator(self, func: Callable[[T], T]) -> Callable[[T], T]: 583 | self.validators.append(func) 584 | return func 585 | 586 | 587 | def inferred_env_var( 588 | key: Optional[str] = None, 589 | *, 590 | type: Union[ParserInput[T], InferType] = infer_type, 591 | default: Union[T, Missing, AsDefault, Discard, Factory[T]] = as_default, 592 | description: Optional[Description] = None, 593 | validators: Iterable[Callable[[T], T]] = (), 594 | case_sensitive: bool = False, 595 | strip_whitespaces: bool = True, 596 | ) -> InferEnvVar[T]: 597 | return InferEnvVar(key, type, default, description, list(validators), case_sensitive, strip_whitespaces) 598 | -------------------------------------------------------------------------------- /envolved/exceptions.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | 4 | class MissingEnvError(Exception): 5 | """ 6 | An exception raised when looking up a missing environment variable without a default. 7 | """ 8 | 9 | args: Tuple[str] 10 | 11 | 12 | class SkipDefault(BaseException): 13 | """ 14 | an exception raised when a missing env error should be raised, even if a default is defined 15 | """ 16 | 17 | args: Tuple[MissingEnvError] 18 | -------------------------------------------------------------------------------- /envolved/factory_spec.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from dataclasses import dataclass 5 | from inspect import Parameter, signature 6 | from itertools import zip_longest 7 | from typing import Any, Callable, Dict, Optional, Sequence, Type, Union, get_type_hints 8 | 9 | missing = object() 10 | 11 | 12 | @dataclass 13 | class FactoryArgSpec: 14 | default: Any 15 | type: Any 16 | key_override: Optional[str] = None 17 | is_explicit_env: bool = False 18 | 19 | @classmethod 20 | def from_type_annotation(cls, default: Any, ty: Any) -> FactoryArgSpec: 21 | key_override = None 22 | is_explicit_env = False 23 | md = getattr(ty, "__metadata__", None) 24 | if md: 25 | # ty is annotated 26 | ty = ty.__origin__ 27 | for m in md: 28 | if isinstance(m, Env): 29 | is_explicit_env = True 30 | if m.key is not None: 31 | key_override = m.key 32 | if m.default is not missing: 33 | default = m.default 34 | if m.type is not missing: 35 | ty = m.type 36 | 37 | return cls(default, ty, key_override, is_explicit_env) 38 | 39 | @classmethod 40 | def merge(cls, a: Optional[FactoryArgSpec], b: Optional[FactoryArgSpec]) -> FactoryArgSpec: 41 | if not (a and b): 42 | ret = a or b 43 | assert ret is not None 44 | return ret 45 | return FactoryArgSpec( 46 | default=a.default if a.default is not missing else b.default, 47 | type=a.type if a.type is not missing else b.type, 48 | key_override=a.key_override if a.key_override is not None else b.key_override, 49 | is_explicit_env=a.is_explicit_env or b.is_explicit_env, 50 | ) 51 | 52 | 53 | class Env: 54 | def __init__(self, *, key: str | None = None, default: Any = missing, type: Any = missing): 55 | self.key = key 56 | self.default = default 57 | self.type = type 58 | 59 | 60 | @dataclass 61 | class FactorySpec: 62 | positional: Sequence[FactoryArgSpec] 63 | keyword: Dict[str, FactoryArgSpec] 64 | 65 | def merge(self, other: FactorySpec) -> FactorySpec: 66 | positionals = [FactoryArgSpec.merge(a, b) for a, b in zip_longest(self.positional, other.positional)] 67 | keyword = { 68 | k: FactoryArgSpec.merge(self.keyword.get(k), other.keyword.get(k)) 69 | for k in {*self.keyword.keys(), *other.keyword.keys()} 70 | } 71 | return FactorySpec( 72 | positional=positionals, 73 | keyword=keyword, 74 | ) 75 | 76 | 77 | def compat_get_type_hints(obj: Any) -> Dict[str, Any]: 78 | if sys.version_info >= (3, 9): 79 | return get_type_hints(obj, include_extras=True) 80 | return get_type_hints(obj) 81 | 82 | 83 | def factory_spec(factory: Union[Callable[..., Any], Type], skip_pos: int = 0) -> FactorySpec: 84 | if isinstance(factory, type): 85 | initial_mapping = { 86 | k: FactoryArgSpec.from_type_annotation(getattr(factory, k, missing), v) 87 | for k, v in compat_get_type_hints(factory).items() 88 | } 89 | cls_spec = FactorySpec(positional=(), keyword=initial_mapping) 90 | init_spec = factory_spec(factory.__init__, skip_pos=1) # type: ignore[misc] 91 | new_spec = factory_spec(factory.__new__, skip_pos=1) 92 | # we arbitrarily decide that __init__ wins over __new__ 93 | return init_spec.merge(new_spec).merge(cls_spec) 94 | 95 | type_hints = compat_get_type_hints(factory) 96 | sign = signature(factory) 97 | pos = [] 98 | kwargs = {} 99 | for param in sign.parameters.values(): 100 | if param.kind not in (Parameter.POSITIONAL_OR_KEYWORD, Parameter.KEYWORD_ONLY, Parameter.POSITIONAL_ONLY): 101 | continue 102 | if param.default is not Parameter.empty: 103 | default = param.default 104 | else: 105 | default = missing 106 | 107 | ty = type_hints.get(param.name, missing) 108 | arg_spec = FactoryArgSpec.from_type_annotation(default, ty) 109 | if param.kind in (Parameter.POSITIONAL_OR_KEYWORD, Parameter.POSITIONAL_ONLY): 110 | pos.append(arg_spec) 111 | 112 | kwargs[param.name] = arg_spec 113 | if skip_pos: 114 | del pos[:skip_pos] 115 | return FactorySpec(pos, kwargs) 116 | -------------------------------------------------------------------------------- /envolved/infer_env_var.py: -------------------------------------------------------------------------------- 1 | from envolved.envvar import InferEnvVar, inferred_env_var 2 | 3 | __all__ = ["InferEnvVar", "inferred_env_var"] 4 | 5 | # this module is to preserved backwards compatibility 6 | 7 | AutoTypedEnvVar = InferEnvVar # alias for backwards compatibility 8 | -------------------------------------------------------------------------------- /envolved/parsers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from enum import Enum, auto 5 | from itertools import chain 6 | from sys import version_info 7 | from typing import ( 8 | Any, 9 | Callable, 10 | Dict, 11 | Generic, 12 | Iterable, 13 | Iterator, 14 | Mapping, 15 | Optional, 16 | Pattern, 17 | Tuple, 18 | Type, 19 | TypeVar, 20 | Union, 21 | ) 22 | 23 | from typing_extensions import Concatenate, TypeAlias 24 | 25 | from envolved.utils import extract_from_option 26 | 27 | __all__ = ["Parser", "BoolParser", "CollectionParser", "parser"] 28 | 29 | 30 | BaseModel1: Optional[Type] 31 | BaseModel2: Optional[Type] 32 | TypeAdapter: Optional[Type] 33 | 34 | try: # pydantic v2 35 | from pydantic import BaseModel as BaseModel2, TypeAdapter 36 | from pydantic.v1 import BaseModel as BaseModel1 37 | except ImportError: 38 | BaseModel2 = TypeAdapter = None 39 | try: # pydantic v1 40 | from pydantic import BaseModel as BaseModel1 41 | except ImportError: 42 | BaseModel1 = None 43 | 44 | T = TypeVar("T") 45 | 46 | if version_info >= (3, 11): 47 | # theoretically, I'd like to restrict this to keyword arguments only, but that's not possible yet in python 48 | Parser: TypeAlias = Callable[Concatenate[str, ...], T] 49 | else: 50 | # we can only use Concatenate[str, ...] in python 3.11+ 51 | Parser: TypeAlias = Callable[[str], T] # type: ignore[misc, no-redef] 52 | 53 | ParserInput = Union[Parser[T], Type[T]] 54 | 55 | special_parser_inputs: Dict[ParserInput[Any], Parser[Any]] = { 56 | bytes: str.encode, 57 | } 58 | 59 | parser_special_instances: Dict[Type, Callable[[Any], Parser]] = {} 60 | if TypeAdapter is not None: 61 | parser_special_instances[TypeAdapter] = lambda t: t.validate_json 62 | 63 | parser_special_superclasses: Dict[Type, Callable[[Type], Parser]] = {} 64 | if BaseModel1 is not None: 65 | parser_special_superclasses[BaseModel1] = lambda t: t.parse_raw 66 | if BaseModel2 is not None: 67 | parser_special_superclasses[BaseModel2] = lambda t: t.model_validate_json 68 | 69 | 70 | def complex_parser(x: str) -> complex: 71 | x = x.replace("i", "j") 72 | return complex(x) 73 | 74 | 75 | special_parser_inputs[complex] = complex_parser 76 | 77 | 78 | def parser(t: ParserInput[T]) -> Parser[T]: 79 | """ 80 | Coerce an object into a parser. 81 | :param t: The object to coerce to a parser. 82 | :return: The best-match parser for `t`. 83 | """ 84 | special_parser = special_parser_inputs.get(t) 85 | if special_parser is not None: 86 | return special_parser 87 | 88 | from_option = extract_from_option(t) 89 | if from_option is not None: 90 | return parser(from_option) 91 | 92 | for special_cls, parser_factory in parser_special_instances.items(): 93 | if isinstance(t, special_cls): 94 | return parser_factory(t) 95 | 96 | if isinstance(t, type): 97 | for supercls, parser_factory in parser_special_superclasses.items(): 98 | if issubclass(t, supercls): 99 | return parser_factory(t) 100 | 101 | if callable(t): 102 | return t 103 | 104 | raise TypeError(f"cannot coerce type {t!r} to a parser") 105 | 106 | 107 | E = TypeVar("E") 108 | G = TypeVar("G") 109 | 110 | empty_pattern = re.compile("") 111 | 112 | Needle = Union[str, Pattern[str]] 113 | 114 | _no_regex_flags = re.RegexFlag(0) 115 | 116 | 117 | def needle_to_pattern(n: Needle, flags: re.RegexFlag = _no_regex_flags) -> Pattern[str]: 118 | if isinstance(n, str): 119 | return re.compile(re.escape(n), flags) 120 | return n 121 | 122 | 123 | K = TypeVar("K") 124 | V = TypeVar("V") 125 | 126 | 127 | def _duplicate_avoiding_dict(pairs: Iterator[Tuple[K, V]]) -> Dict[K, V]: 128 | """ 129 | The default output_type of CollectionParser.delimited_pairwise. Returns a dict from key-value pairs while 130 | ensuring there are no duplicate keys. 131 | """ 132 | ret = {} 133 | for k, v in pairs: 134 | if k in ret: 135 | raise ValueError(f"duplicate key {k}") 136 | ret[k] = v 137 | return ret 138 | 139 | 140 | def strip_opener_idx(x: str, opener: Pattern[str]) -> int: 141 | opener_match = opener.match(x) 142 | if not opener_match: 143 | raise ValueError("position 0, expected opener") 144 | return opener_match.end() 145 | 146 | 147 | def strip_closer_idx(x: str, closer: Needle, pos: int) -> int: 148 | if isinstance(closer, str): 149 | if len(closer) + pos > len(x) or not x.endswith(closer): 150 | raise ValueError("expected string to end in closer") 151 | return len(x) - len(closer) 152 | else: 153 | assert isinstance(closer, Pattern) 154 | # now we have a problem, as the standard re module doesn't support reverse matches 155 | closer_matches = closer.finditer(x, pos) 156 | closer_match = None 157 | for closer_match in closer_matches: # noqa: B007 158 | # we iterate to find the last match 159 | pass 160 | if not closer_match: 161 | raise ValueError("expected string to end in closer") 162 | else: 163 | while closer_match.end() != len(x): 164 | # finditer could have missed an overlapping match, if there is an overlapping match 165 | # it will be found after the start of the last match (but before its end) 166 | closer_match = closer.search(x, closer_match.start() + 1) 167 | # if there is a match, it's an overlapping match, but it doesn't neccessarily end at 168 | # the end of the string 169 | if not closer_match: 170 | raise ValueError("expected string to end in closer") 171 | return closer_match.start() 172 | 173 | 174 | def strip_opener_and_closer(x: str, opener: Pattern[str], closer: Needle) -> str: 175 | start_idx = strip_opener_idx(x, opener) 176 | end_idx = strip_closer_idx(x, closer, start_idx) 177 | 178 | if start_idx != 0 or end_idx != len(x): 179 | return x[start_idx:end_idx] 180 | return x 181 | 182 | 183 | def value_parser_func(value_type: Union[ParserInput[V], Mapping[K, ParserInput[V]]]) -> Callable[[K], Parser[V]]: 184 | if isinstance(value_type, Mapping): 185 | value_parsers = {k: parser(v) for k, v in value_type.items()} 186 | 187 | def get_value_parser(key: K) -> Parser[V]: 188 | try: 189 | return value_parsers[key] 190 | except KeyError: 191 | # in case the mapping has a default value or the like 192 | return parser(value_type[key]) 193 | else: 194 | _value_parser = parser(value_type) 195 | 196 | def get_value_parser(key: K) -> Parser[V]: 197 | return _value_parser 198 | 199 | return get_value_parser 200 | 201 | 202 | class CollectionParser(Generic[G, E]): 203 | """ 204 | A parser that splits a string by a delimiter, and parses each part individually. 205 | """ 206 | 207 | def __init__( 208 | self, 209 | delimiter: Needle, 210 | inner_parser: ParserInput[E], 211 | output_type: Callable[[Iterator[E]], G] = list, # type: ignore[assignment] 212 | opener: Needle = empty_pattern, 213 | closer: Needle = "", 214 | *, 215 | strip: bool = True, 216 | ): 217 | self.delimiter_pattern = needle_to_pattern(delimiter) 218 | self.inner_parser = parser(inner_parser) 219 | self.output_type = output_type 220 | self.opener_pattern = needle_to_pattern(opener) 221 | self.closer = closer 222 | self.strip = strip 223 | 224 | def __call__(self, x: str) -> G: 225 | x = strip_opener_and_closer(x, self.opener_pattern, self.closer) 226 | raw_items = iter(self.delimiter_pattern.split(x)) 227 | if self.strip: 228 | raw_items = (r.strip() for r in raw_items) 229 | elements = (self.inner_parser(r) for r in raw_items) 230 | return self.output_type(elements) 231 | 232 | @classmethod 233 | def pair_wise_delimited( 234 | cls, 235 | pair_delimiter: Needle, 236 | key_value_delimiter: Needle, 237 | key_type: ParserInput[K], 238 | value_type: Union[ParserInput[V], Mapping[K, ParserInput[V]]], 239 | output_type: Callable[[Iterator[Tuple[K, V]]], G] = _duplicate_avoiding_dict, # type: ignore[assignment] 240 | key_first: bool = True, 241 | strip_keys: bool = True, 242 | strip_values: bool = True, 243 | **kwargs: Any, 244 | ) -> Parser[G]: 245 | key_value_delimiter = needle_to_pattern(key_value_delimiter) 246 | key_parser = parser(key_type) 247 | get_value_parser = value_parser_func(value_type) 248 | 249 | def combined_parser(s: str) -> Tuple[K, V]: 250 | split = key_value_delimiter.split(s, maxsplit=2) 251 | if len(split) != 2: 252 | raise ValueError(f"expecting key-value pair, got {s}") 253 | k, v = split 254 | if not key_first: 255 | k, v = v, k 256 | if strip_keys: 257 | k = k.strip() 258 | if strip_values: 259 | v = v.strip() 260 | key = key_parser(k) 261 | value = get_value_parser(key)(v) 262 | return key, value 263 | 264 | return cls(pair_delimiter, combined_parser, output_type, **kwargs) # type: ignore[arg-type] 265 | 266 | 267 | def find_iter_contingient(x: str, pattern: Pattern[str]) -> Iterator[re.Match[str]]: 268 | start_idx = 0 269 | while start_idx < len(x): 270 | match = pattern.match(x, start_idx) 271 | if match is None: 272 | raise ValueError(f"could not match pattern {pattern} at position {start_idx}") 273 | start_idx = match.end() 274 | yield match 275 | 276 | 277 | class FindIterCollectionParser(Generic[G, E]): 278 | def __init__( 279 | self, 280 | element_pattern: Pattern[str], 281 | element_func: Callable[[re.Match[str]], E], 282 | output_type: Callable[[Iterator[E]], G] = list, # type: ignore[assignment] 283 | opener: Needle = empty_pattern, 284 | closer: Needle = "", 285 | ): 286 | self.prefix_pattern = element_pattern 287 | self.element_func = element_func 288 | self.output_type = output_type 289 | self.opener_pattern = needle_to_pattern(opener) 290 | self.closer = closer 291 | 292 | def __call__(self, x: str) -> G: 293 | x = strip_opener_and_closer(x, self.opener_pattern, self.closer) 294 | raw_matches = find_iter_contingient(x, self.prefix_pattern) 295 | elements = (self.element_func(r) for r in raw_matches) 296 | return self.output_type(elements) 297 | 298 | 299 | class NoFallback(Enum): 300 | no_fallback = auto() 301 | 302 | 303 | no_fallback = NoFallback.no_fallback 304 | 305 | CasesInput = Union[Iterable[Tuple[Needle, T]], Mapping[str, T], Type[Enum]] 306 | CasesInputIgnoreCase = Union[Iterable[Tuple[str, T]], Mapping[str, T], Type[Enum]] 307 | 308 | 309 | class MatchParser(Generic[T]): 310 | @classmethod 311 | def _ensure_case_unique(cls, matches: Iterable[str]): 312 | seen_cases = set() 313 | for k in matches: 314 | key = k.lower() 315 | if key in seen_cases: 316 | raise ValueError(f"duplicate case-invariant key {k}") 317 | seen_cases.add(key) 318 | 319 | @classmethod 320 | def _cases(cls, x: CasesInput, ignore_case: bool) -> Iterable[Tuple[Pattern[str], T]]: 321 | if isinstance(x, Mapping): 322 | if ignore_case and __debug__: 323 | cls._ensure_case_unique(x.keys()) 324 | return cls._cases(x.items(), ignore_case) 325 | if isinstance(x, type) and issubclass(x, Enum): 326 | return cls._cases(x.__members__, ignore_case) 327 | flags = _no_regex_flags 328 | if ignore_case: 329 | flags |= re.IGNORECASE 330 | return ((needle_to_pattern(n, flags), v) for n, v in x) 331 | 332 | def __init__(self, cases: CasesInput, fallback: Union[T, NoFallback] = no_fallback): 333 | cases_inp = self._cases(cases, ignore_case=False) 334 | if fallback is not no_fallback: 335 | cases_inp = chain(cases_inp, [(re.compile(".*"), fallback)]) 336 | self.candidates = [(needle_to_pattern(n), v) for n, v in cases_inp] 337 | 338 | @classmethod 339 | def case_insensitive( 340 | cls, cases: CasesInputIgnoreCase, fallback: Union[T, NoFallback] = no_fallback 341 | ) -> MatchParser[T]: 342 | cases_inp = cls._cases(cases, ignore_case=True) 343 | return cls(cases_inp, fallback) 344 | 345 | def __call__(self, x: str) -> T: 346 | for pattern, value in self.candidates: 347 | if pattern.fullmatch(x): 348 | return value 349 | raise ValueError(f"no match for {x}") 350 | 351 | 352 | LookupCases = Union[Iterable[Tuple[str, T]], Mapping[str, T], Type[Enum]] 353 | 354 | 355 | class LookupParser(Generic[T]): 356 | def __init__( 357 | self, lookup: LookupCases, fallback: Union[T, NoFallback] = no_fallback, *, _case_sensitive: bool = True 358 | ): 359 | cases: Iterable[Tuple[str, T]] 360 | if isinstance(lookup, Mapping): 361 | cases = lookup.items() 362 | elif isinstance(lookup, type) and issubclass(lookup, Enum): 363 | cases = lookup.__members__.items() # type: ignore[assignment] 364 | else: 365 | cases = lookup 366 | 367 | if _case_sensitive: 368 | self.lookup = dict(cases) 369 | else: 370 | self.lookup = {k.lower(): v for k, v in cases} 371 | self.fallback = fallback 372 | self.case_sensitive = _case_sensitive 373 | 374 | @classmethod 375 | def case_insensitive(cls, lookup: LookupCases, fallback: Union[T, NoFallback] = no_fallback) -> LookupParser[T]: 376 | return cls(lookup, fallback, _case_sensitive=False) 377 | 378 | def __call__(self, x: str) -> T: 379 | if not self.case_sensitive: 380 | key = x.lower() 381 | else: 382 | key = x 383 | try: 384 | return self.lookup[key] 385 | except KeyError as e: 386 | if self.fallback is no_fallback: 387 | raise ValueError(f"no match for {x}") from e 388 | return self.fallback 389 | 390 | 391 | parser_special_superclasses[Enum] = LookupParser.case_insensitive # type: ignore[assignment] 392 | 393 | 394 | class BoolParser(LookupParser[bool]): 395 | """ 396 | A helper to parse boolean values from text 397 | """ 398 | 399 | def __init__( 400 | self, 401 | maps_to_true: Iterable[str] = (), 402 | maps_to_false: Iterable[str] = (), 403 | *, 404 | default: Optional[bool] = None, 405 | case_sensitive: bool = False, 406 | ): 407 | """ 408 | :param maps_to_true: An iterable of string values that should evaluate to True 409 | :param maps_to_false: An iterable of string values that should evaluate to True 410 | :param default: The behaviour for when the value is vacant from both the true iterable and the falsish iterable. 411 | :param case_sensitive: Whether the string values should match exactly or case-insensitivity. 412 | """ 413 | super().__init__( 414 | chain( 415 | ((x, True) for x in maps_to_true), 416 | ((x, False) for x in maps_to_false), 417 | ), 418 | fallback=default if default is not None else no_fallback, 419 | _case_sensitive=case_sensitive, 420 | ) 421 | 422 | 423 | special_parser_inputs[bool] = BoolParser(["true"], ["false"]) 424 | -------------------------------------------------------------------------------- /envolved/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bentheiii/envolved/7032560a76464b0e0a0710db7ecbb66360a3b7ec/envolved/py.typed -------------------------------------------------------------------------------- /envolved/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Optional, Type, Union 4 | 5 | UnionType: Optional[Type[Any]] 6 | try: 7 | from types import UnionType # type: ignore[attr-defined, no-redef] 8 | except ImportError: 9 | UnionType = None # type: ignore[misc] 10 | 11 | NoneType: Type[Any] 12 | try: 13 | from types import NoneType # type: ignore[attr-defined, no-redef] 14 | except ImportError: 15 | NoneType = type(None) # type: ignore[misc] 16 | 17 | 18 | def extract_from_option(t: Any) -> Optional[type]: 19 | if UnionType and isinstance(t, UnionType): 20 | parts = t.__args__ 21 | else: 22 | origin = getattr(t, "__origin__", None) 23 | if origin is Union: 24 | parts = t.__args__ 25 | else: 26 | return None 27 | 28 | if len(parts) == 2 and NoneType in parts: 29 | return next(p for p in parts if p is not NoneType) 30 | return None 31 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "envolved" 3 | version = "1.7.0" 4 | description = "" 5 | authors = ["ben avrahami "] 6 | license = "MIT" 7 | readme = "README.md" 8 | repository = "https://github.com/bentheiii/envolved" 9 | packages = [ 10 | {include="envolved"}, 11 | {include="envolved/py.typed"} 12 | ] 13 | 14 | [tool.poetry.dependencies] 15 | python = "^3.8" 16 | typing-extensions = "*" 17 | 18 | [tool.poetry.group.dev.dependencies] 19 | pytest = "*" 20 | mypy = {version="*", python=">=3.8"} 21 | pytest-cov = "^4.1.0" 22 | ruff = {version="*", python=">=3.8"} 23 | pydantic = "^2.5.2" 24 | 25 | [tool.poetry.group.docs] 26 | optional = true 27 | 28 | [tool.poetry.group.docs.dependencies] 29 | sphinx = {version="^7", python = ">=3.12"} 30 | furo = {version="*", python = ">=3.12"} 31 | sluth = {version="*", python = ">=3.12"} 32 | 33 | [build-system] 34 | requires = ["poetry>=0.12"] 35 | build-backend = "poetry.masonry.api" 36 | 37 | 38 | [tool.ruff] 39 | target-version = "py38" 40 | line-length = 120 41 | output-format = "full" 42 | [tool.ruff.lint] 43 | exclude = ["docs/**"] 44 | # https://beta.ruff.rs/docs/rules/ 45 | select = ["I", "E", "W", "F", "N", "S", "BLE", "COM", "C4", "ISC", "ICN", "G", "PIE", "T20", "PYI", "Q", "SLF", "SIM", 46 | "ERA", "PGH", "PLC", "PLE", "PLR", "PLW", "RUF", "PT", "UP", "B", "ANN", "ASYNC", "FBT003", "A", "INP", 47 | "SLOT", "TID", "PTH", "FLY", "PERF"] 48 | # should be included later: RET?, ARG, TRY, DTZ?, FA, RSE?, TCH? 49 | ignore = [ 50 | "A002", # argument shadowing builtin 51 | "UP006", # use tuple instead of Tuple 52 | "UP007", # use X|Y 53 | "PLR2004", # Magic value used in comparison 54 | "PLR0913", # Too many arguments to function call 55 | "SIM108", # Use ternary operator instead of `if`-`else`-block 56 | "SIM105", # Use `contextlib.suppress(...)` instead of try-except-pass 57 | "S101", # assert detected 58 | "C901", # too complex 59 | "SIM118", # Use `key in {}` instead of `key in {}.keys()` 60 | "SIM112", # Use capitalized environment variable 61 | "ANN101", # Missing type annotation for self in method 62 | "ANN102", # Missing type annotation for cls in classmethod 63 | "ANN401", # Dynamic type annotation 64 | "A003", # class attribute shadows built-in 65 | "PLR0912", # too many branches 66 | # disabled for formatter: 67 | 'COM812', 'COM819', 'E501', 'ISC001', 'Q000', 'Q001', 'Q002', 'Q003', 'W191' 68 | ] 69 | 70 | [tool.ruff.lint.isort] 71 | combine-as-imports=true 72 | 73 | [tool.ruff.lint.flake8-annotations] 74 | suppress-none-returning = true 75 | 76 | [tool.ruff.lint.flake8-self] 77 | ignore-names = ["_get_descendants", "_get_children"] 78 | 79 | [tool.ruff.lint.flake8-pytest-style] 80 | raises-require-match-for = [] 81 | 82 | [tool.ruff.lint.pyupgrade] 83 | keep-runtime-typing = true 84 | 85 | [tool.ruff.lint.per-file-ignores] 86 | "tests/**" = [ 87 | "ANN", # annotations 88 | "N802", # Function name should be lowercase 89 | "N803", # Argument name should be lowercase 90 | "S105", # Possible hardcoded password 91 | "S113", # Probable use of requests call without timeout 92 | "PIE804", # Unnecessary `dict` kwargs 93 | "PT013", # Found incorrect import of pytest, use simple `import pytest` instead 94 | "PT004", # Fixture does not return anything, add leading underscore 95 | "BLE001", # BLE001 Do not catch blind exception: `Exception` 96 | "F405", # name may be undefined, or defined from star imports 97 | "F403", # star import used; unable to detect undefined names 98 | "T201", # `print` found 99 | "SLF001", # Private member accessed 100 | "PLC1901", # simplify str == "" 101 | "B018", # useless expression 102 | "FBT", # boolean params 103 | "A", # builtin shadowing 104 | "INP", # implicit namespace packages 105 | "PTH", # use pathlib 106 | "PERF", # performance anti-patterns 107 | ] 108 | 109 | "type_checking/**" = [ 110 | "INP001", # implicit namespace packages 111 | ] -------------------------------------------------------------------------------- /scripts/build_doc.sh: -------------------------------------------------------------------------------- 1 | python -m sphinx -b html docs docs/_build/html -------------------------------------------------------------------------------- /scripts/format.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # run various linters 3 | set -e 4 | python -m ruff format . 5 | python -m ruff check . --select I,F401 --fix --show-fixes 6 | -------------------------------------------------------------------------------- /scripts/install.sh: -------------------------------------------------------------------------------- 1 | # install poetry and the dev-dependencies of the project 2 | python -m pip install poetry 3 | python -m poetry update --lock 4 | python -m poetry install -------------------------------------------------------------------------------- /scripts/lint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # run various linters 3 | set -e 4 | python -m ruff check . 5 | python -m ruff format . --check 6 | python -m mypy --show-error-codes envolved -------------------------------------------------------------------------------- /scripts/test_type_hinting.sh: -------------------------------------------------------------------------------- 1 | python -m mypy --show-error-codes --check-untyped-defs type_checking -------------------------------------------------------------------------------- /scripts/unittest.sh: -------------------------------------------------------------------------------- 1 | # run the unittests with branch coverage 2 | set -e 3 | coverage run --branch --include="envolved/*" -m pytest tests/ "$@" 4 | coverage html 5 | coverage report -m 6 | coverage xml -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bentheiii/envolved/7032560a76464b0e0a0710db7ecbb66360a3b7ec/tests/__init__.py -------------------------------------------------------------------------------- /tests/unittests/test_absolute_name.py: -------------------------------------------------------------------------------- 1 | from types import SimpleNamespace 2 | 3 | from envolved.absolute_name import AbsoluteName 4 | from envolved.envvar import env_var 5 | 6 | 7 | def test_absolute_name(monkeypatch): 8 | a = env_var( 9 | "a_", 10 | type=SimpleNamespace, 11 | args={ 12 | "a": env_var("A", type=str), 13 | "b": env_var(AbsoluteName("B"), type=str), 14 | "b2": env_var("b", type=str), 15 | }, 16 | ) 17 | 18 | monkeypatch.setenv("a_a", "1") 19 | monkeypatch.setenv("B", "2") 20 | monkeypatch.setenv("a_b", "3") 21 | 22 | assert a.get() == SimpleNamespace(a="1", b="2", b2="3") 23 | -------------------------------------------------------------------------------- /tests/unittests/test_describe.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | from types import SimpleNamespace 3 | 4 | from envolved import env_var 5 | from envolved.describe import describe_env_vars, exclude_from_description 6 | 7 | 8 | def test_describe(): 9 | a = env_var( # noqa: F841 10 | "a", 11 | type=str, 12 | description=""" 13 | full description of A 14 | """, 15 | ) 16 | 17 | point_args = { 18 | "x": env_var("x", type=int, description="x coordinate"), 19 | "y": env_var("y", type=int, description="y coordinate"), 20 | } 21 | exclude_from_description(point_args) 22 | 23 | _p = exclude_from_description(env_var("_p_", type=SimpleNamespace, args=point_args)) 24 | 25 | p = env_var("p_", type=SimpleNamespace, args=point_args) 26 | 27 | _w_p = exclude_from_description(p.with_prefix("_w_")) 28 | 29 | j_p = _p.with_prefix("j") 30 | j_p.description = "j point" 31 | 32 | q = env_var( # noqa: F841 33 | "q_", 34 | type=SimpleNamespace, 35 | args=point_args, 36 | description=dedent( 37 | """ 38 | point Q 39 | next line 40 | """ 41 | ).strip(), 42 | ) 43 | 44 | b = env_var("b", type=str) # noqa: F841 45 | 46 | t = env_var( # noqa: F841 47 | "t_", 48 | type=SimpleNamespace, 49 | args={"p": env_var("p_", type=SimpleNamespace, args=point_args), "n": env_var("n", type=int)}, 50 | ) 51 | 52 | d = env_var("d", type=int) 53 | exclude_from_description(d) 54 | 55 | e_f_g = env_var("e", type=int), env_var("f", type=int), env_var("g", type=int) 56 | exclude_from_description(e_f_g) 57 | 58 | assert describe_env_vars() == [ 59 | "A: full description of A", 60 | "B", 61 | "j point:", 62 | " J_P_X: x coordinate", 63 | " J_P_Y: y coordinate", 64 | " P_X: x coordinate", 65 | " P_Y: y coordinate", 66 | "point Q next line:", 67 | " Q_X: x coordinate", 68 | " Q_Y: y coordinate", 69 | " T_N", 70 | " T_P_X: x coordinate", 71 | " T_P_Y: y coordinate", 72 | ] 73 | -------------------------------------------------------------------------------- /tests/unittests/test_describe_flat_grouped.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | from types import SimpleNamespace 3 | 4 | from pytest import mark 5 | 6 | from envolved import env_var 7 | from envolved.describe import EnvVarsDescription 8 | 9 | 10 | def test_describe_single_flat(): 11 | d = EnvVarsDescription( 12 | [ 13 | env_var("a", type=int, description="Apple"), 14 | env_var("b", type=int, description="Bee"), 15 | ] 16 | ).flat() 17 | 18 | assert d.wrap_grouped() == [ 19 | "A: Apple", 20 | "B: Bee", 21 | ] 22 | 23 | 24 | def test_describe_single_sensitive(): 25 | d = EnvVarsDescription( 26 | [ 27 | env_var("a", type=int, description="Apple"), 28 | env_var("b", type=int, description="Bee", case_sensitive=True), 29 | env_var("c", type=int), 30 | ] 31 | ).flat() 32 | 33 | assert d.wrap_grouped() == ["A: Apple", "b: Bee", "C"] 34 | 35 | 36 | def test_describe_single_flat_multiline(): 37 | d = EnvVarsDescription( 38 | [ 39 | env_var( 40 | "a", 41 | type=int, 42 | description=dedent( 43 | """ 44 | Apple 45 | Banana 46 | """ 47 | ) 48 | .strip() 49 | .splitlines(), 50 | ), 51 | env_var("b", type=int, description="Bee"), 52 | ] 53 | ).flat() 54 | 55 | assert d.wrap_grouped() == [ 56 | "A: Apple", 57 | " Banana", 58 | "B: Bee", 59 | ] 60 | 61 | 62 | def test_describe_single_flat_long(): 63 | d = EnvVarsDescription( 64 | [ 65 | env_var("a", type=int, description="I'm a yankee doodle dandy, a yankee doodle do or die"), 66 | env_var("b", type=int, description="Bee"), 67 | ] 68 | ).flat() 69 | 70 | assert d.wrap_grouped(width=20) == [ 71 | "A: I'm a yankee", 72 | " doodle dandy, a", 73 | " yankee doodle do", 74 | " or die", 75 | "B: Bee", 76 | ] 77 | 78 | 79 | @mark.parametrize( 80 | "schema_desc", 81 | [ 82 | None, 83 | "Cee", 84 | ["Cee", "Fee", "Ree"], 85 | "I'm a yankee doodle dandy, a yankee doodle do or die", 86 | ], 87 | ) 88 | def test_describe_multi_flat(schema_desc): 89 | d = EnvVarsDescription( 90 | [ 91 | env_var("a", type=int, description="Apple"), 92 | env_var("d", type=int, description="Bee"), 93 | env_var( 94 | "c_", 95 | type=SimpleNamespace, 96 | args={ 97 | "x": env_var("x", type=int, description="x coordinate"), 98 | "y": env_var("y", type=int, description="y coordinate"), 99 | }, 100 | description=schema_desc, 101 | ), 102 | ] 103 | ).flat() 104 | 105 | assert d.wrap_grouped() == [ 106 | "A: Apple", 107 | "C_X: x coordinate", 108 | "C_Y: y coordinate", 109 | "D: Bee", 110 | ] 111 | 112 | 113 | @mark.parametrize( 114 | "schema_desc", 115 | [ 116 | None, 117 | "Cee", 118 | ["Cee", "Fee", "Ree"], 119 | "I'm a yankee doodle dandy, a yankee doodle do or die", 120 | ], 121 | ) 122 | def test_describe_multi_flat_dragup(schema_desc): 123 | d = EnvVarsDescription( 124 | [ 125 | env_var("B", type=int, description="Apple"), 126 | env_var("d", type=int, description="Bee"), 127 | env_var( 128 | "", 129 | type=SimpleNamespace, 130 | args={ 131 | "a": env_var("a", type=int, description="A coordinate"), 132 | "x": env_var("c_x", type=int, description="x coordinate"), 133 | "y": env_var("c_y", type=int, description="y coordinate"), 134 | }, 135 | description=schema_desc, 136 | ), 137 | ] 138 | ).flat() 139 | 140 | assert d.wrap_grouped() == [ 141 | "A: A coordinate", 142 | "C_X: x coordinate", 143 | "C_Y: y coordinate", 144 | "B: Apple", 145 | "D: Bee", 146 | ] 147 | -------------------------------------------------------------------------------- /tests/unittests/test_describe_flat_sorted.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | from types import SimpleNamespace 3 | 4 | from pytest import mark 5 | 6 | from envolved import env_var 7 | from envolved.describe import EnvVarsDescription 8 | 9 | 10 | def test_describe_single_flat(): 11 | d = EnvVarsDescription( 12 | [ 13 | env_var("a", type=int, description="Apple"), 14 | env_var("b", type=int, description="Bee"), 15 | ] 16 | ).flat() 17 | 18 | assert d.wrap_sorted() == [ 19 | "A: Apple", 20 | "B: Bee", 21 | ] 22 | 23 | 24 | def test_describe_single_sensitive(): 25 | d = EnvVarsDescription( 26 | [ 27 | env_var("a", type=int, description="Apple"), 28 | env_var("b", type=int, description="Bee", case_sensitive=True), 29 | env_var("c", type=int), 30 | ] 31 | ).flat() 32 | 33 | assert d.wrap_sorted() == ["A: Apple", "b: Bee", "C"] 34 | 35 | 36 | def test_describe_single_flat_multiline(): 37 | d = EnvVarsDescription( 38 | [ 39 | env_var( 40 | "a", 41 | type=int, 42 | description=dedent( 43 | """ 44 | Apple 45 | Banana 46 | """ 47 | ) 48 | .strip() 49 | .splitlines(), 50 | ), 51 | env_var("b", type=int, description="Bee"), 52 | ] 53 | ).flat() 54 | 55 | assert d.wrap_sorted() == [ 56 | "A: Apple", 57 | " Banana", 58 | "B: Bee", 59 | ] 60 | 61 | 62 | def test_describe_single_flat_long(): 63 | d = EnvVarsDescription( 64 | [ 65 | env_var("a", type=int, description="I'm a yankee doodle dandy, a yankee doodle do or die"), 66 | env_var("b", type=int, description="Bee"), 67 | ] 68 | ).flat() 69 | 70 | assert d.wrap_sorted(width=20) == [ 71 | "A: I'm a yankee", 72 | " doodle dandy, a", 73 | " yankee doodle do", 74 | " or die", 75 | "B: Bee", 76 | ] 77 | 78 | 79 | @mark.parametrize( 80 | "schema_desc", 81 | [ 82 | None, 83 | "Cee", 84 | ["Cee", "Fee", "Ree"], 85 | "I'm a yankee doodle dandy, a yankee doodle do or die", 86 | ], 87 | ) 88 | def test_describe_multi_flat(schema_desc): 89 | d = EnvVarsDescription( 90 | [ 91 | env_var("a", type=int, description="Apple"), 92 | env_var("d", type=int, description="Bee"), 93 | env_var( 94 | "c_", 95 | type=SimpleNamespace, 96 | args={ 97 | "x": env_var("x", type=int, description="x coordinate"), 98 | "y": env_var("y", type=int, description="y coordinate"), 99 | }, 100 | description=schema_desc, 101 | ), 102 | ] 103 | ).flat() 104 | 105 | assert d.wrap_sorted() == [ 106 | "A: Apple", 107 | "C_X: x coordinate", 108 | "C_Y: y coordinate", 109 | "D: Bee", 110 | ] 111 | 112 | 113 | def test_describe_flat_collision(): 114 | d = EnvVarsDescription( 115 | [ 116 | env_var( 117 | "", 118 | type=SimpleNamespace, 119 | args={ 120 | "x": env_var("x", type=int, description="x coordinate"), 121 | "y": env_var("y", type=int, description="y coordinate"), 122 | }, 123 | ), 124 | env_var("x", type=int), 125 | env_var("z", type=int, description="z coordinate"), 126 | ] 127 | ).flat() 128 | 129 | assert d.wrap_sorted() == [ 130 | "X: x coordinate", 131 | "Y: y coordinate", 132 | "Z: z coordinate", 133 | ] 134 | 135 | 136 | def test_describe_flat_cousins(): 137 | d = EnvVarsDescription( 138 | [ 139 | env_var( 140 | "", 141 | type=SimpleNamespace, 142 | args={ 143 | "x": env_var("x", type=int, description="x coordinate"), 144 | "y": env_var("y", type=int, description="y coordinate"), 145 | }, 146 | ), 147 | env_var( 148 | "", 149 | type=SimpleNamespace, 150 | args={ 151 | "x": env_var("x", type=int), 152 | "a": env_var("a", type=int), 153 | }, 154 | ), 155 | env_var("z", type=int, description="z coordinate"), 156 | ] 157 | ).flat() 158 | 159 | assert d.wrap_sorted() == [ 160 | "A", 161 | "X: x coordinate", 162 | "Y: y coordinate", 163 | "Z: z coordinate", 164 | ] 165 | 166 | 167 | def test_describe_flat_collision_nodesc(): 168 | d = EnvVarsDescription( 169 | [ 170 | env_var( 171 | "", 172 | type=SimpleNamespace, 173 | args={ 174 | "x": env_var("x", type=int), 175 | "y": env_var("y", type=int, description="y coordinate"), 176 | }, 177 | ), 178 | env_var("x", type=int), 179 | env_var("z", type=int, description="z coordinate"), 180 | ] 181 | ).flat() 182 | 183 | assert d.wrap_sorted() == [ 184 | "X", 185 | "Y: y coordinate", 186 | "Z: z coordinate", 187 | ] 188 | 189 | 190 | def test_describe_flat_collision_warning(): 191 | d = EnvVarsDescription( 192 | [ 193 | env_var( 194 | "", 195 | type=SimpleNamespace, 196 | args={ 197 | "x": env_var("x", type=int, description="ex"), 198 | }, 199 | ), 200 | env_var("x", type=int, description="x coordinate"), 201 | ] 202 | ).flat() 203 | 204 | (x_desc,) = d.wrap_sorted() 205 | 206 | assert x_desc in [ 207 | "X: ex", 208 | "X: x coordinate", 209 | ] 210 | 211 | 212 | def test_describe_flat_collision_dup(): 213 | d = EnvVarsDescription( 214 | [ 215 | env_var( 216 | "", 217 | type=SimpleNamespace, 218 | args={ 219 | "x": env_var("x", type=int, description="x coordinate"), 220 | "y": env_var("y", type=int, description="y coordinate"), 221 | }, 222 | ), 223 | env_var("x", type=int), 224 | env_var("z", type=int, description="z coordinate"), 225 | ] 226 | ).flat() 227 | 228 | assert sorted(d.wrap_sorted(unique_keys=False)) == [ 229 | "X", 230 | "X: x coordinate", 231 | "Y: y coordinate", 232 | "Z: z coordinate", 233 | ] 234 | 235 | 236 | def test_describe_flat_collision_nodesc_dup(): 237 | d = EnvVarsDescription( 238 | [ 239 | env_var( 240 | "", 241 | type=SimpleNamespace, 242 | args={ 243 | "x": env_var("x", type=int), 244 | "y": env_var("y", type=int, description="y coordinate"), 245 | }, 246 | ), 247 | env_var("x", type=int), 248 | env_var("z", type=int, description="z coordinate"), 249 | ] 250 | ).flat() 251 | 252 | assert d.wrap_sorted(unique_keys=False) == [ 253 | "X", 254 | "X", 255 | "Y: y coordinate", 256 | "Z: z coordinate", 257 | ] 258 | 259 | 260 | def test_describe_flat_collision_warning_dup(): 261 | d = EnvVarsDescription( 262 | [ 263 | env_var( 264 | "", 265 | type=SimpleNamespace, 266 | args={ 267 | "x": env_var("x", type=int, description="ex"), 268 | }, 269 | ), 270 | env_var("x", type=int, description="x coordinate"), 271 | ] 272 | ).flat() 273 | 274 | assert sorted(d.wrap_sorted(unique_keys=False)) == [ 275 | "X: ex", 276 | "X: x coordinate", 277 | ] 278 | -------------------------------------------------------------------------------- /tests/unittests/test_describe_multi.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | from types import SimpleNamespace 3 | 4 | from envolved import env_var 5 | from envolved.describe import EnvVarsDescription 6 | 7 | 8 | def test_describe_single_nested(): 9 | d = EnvVarsDescription( 10 | [ 11 | env_var("a", type=int, description="Apple"), 12 | env_var("b", type=int, description="Bee"), 13 | ] 14 | ).nested() 15 | 16 | assert d.wrap() == [ 17 | "A: Apple", 18 | "B: Bee", 19 | ] 20 | 21 | 22 | def test_describe_single_sensitive(): 23 | d = EnvVarsDescription( 24 | [ 25 | env_var("a", type=int, description="Apple"), 26 | env_var("b", type=int, description="Bee", case_sensitive=True), 27 | env_var("c", type=int), 28 | ] 29 | ).nested() 30 | 31 | assert d.wrap() == ["A: Apple", "b: Bee", "C"] 32 | 33 | 34 | def test_describe_single_nested_multiline(): 35 | d = EnvVarsDescription( 36 | [ 37 | env_var( 38 | "a", 39 | type=int, 40 | description=dedent( 41 | """ 42 | Apple 43 | Banana 44 | """ 45 | ) 46 | .strip() 47 | .splitlines(), 48 | ), 49 | env_var("b", type=int, description="Bee"), 50 | ] 51 | ).nested() 52 | 53 | assert d.wrap() == [ 54 | "A: Apple", 55 | " Banana", 56 | "B: Bee", 57 | ] 58 | 59 | 60 | def test_describe_single_nested_long(): 61 | d = EnvVarsDescription( 62 | [ 63 | env_var("a", type=int, description="I'm a yankee doodle dandy, a yankee doodle do or die"), 64 | env_var("b", type=int, description="Bee"), 65 | ] 66 | ).nested() 67 | 68 | assert d.wrap(width=20) == [ 69 | "A: I'm a yankee", 70 | " doodle dandy, a", 71 | " yankee doodle do", 72 | " or die", 73 | "B: Bee", 74 | ] 75 | 76 | 77 | def test_describe_multi_nested(): 78 | d = EnvVarsDescription( 79 | [ 80 | env_var("a", type=int, description="Apple"), 81 | env_var("d", type=int, description="Bee"), 82 | env_var( 83 | "c_", 84 | type=SimpleNamespace, 85 | args={ 86 | "x": env_var("x", type=int, description="x coordinate"), 87 | "y": env_var("y", type=int, description="y coordinate"), 88 | }, 89 | description="Cee", 90 | ), 91 | ] 92 | ).nested() 93 | 94 | assert d.wrap() == [ 95 | "A: Apple", 96 | "Cee:", 97 | " C_X: x coordinate", 98 | " C_Y: y coordinate", 99 | "D: Bee", 100 | ] 101 | 102 | 103 | def test_describe_multi_nested_multiline(): 104 | d = EnvVarsDescription( 105 | [ 106 | env_var("a", type=int, description="Apple"), 107 | env_var("d", type=int, description="Bee"), 108 | env_var( 109 | "c_", 110 | type=SimpleNamespace, 111 | args={ 112 | "x": env_var("x", type=int, description="x coordinate"), 113 | "y": env_var("y", type=int, description="y coordinate"), 114 | }, 115 | description=["Cee", "Fee", "Ree"], 116 | ), 117 | ] 118 | ).nested() 119 | 120 | assert d.wrap() == [ 121 | "A: Apple", 122 | "Cee", 123 | "Fee", 124 | "Ree:", 125 | " C_X: x coordinate", 126 | " C_Y: y coordinate", 127 | "D: Bee", 128 | ] 129 | 130 | 131 | def test_describe_multi_nested_long(): 132 | d = EnvVarsDescription( 133 | [ 134 | env_var("a", type=int, description="Apple"), 135 | env_var("d", type=int, description="Bee"), 136 | env_var( 137 | "c_", 138 | type=SimpleNamespace, 139 | args={ 140 | "x": env_var("x", type=int, description="x coordinate"), 141 | "y": env_var("y", type=int, description="y coordinate"), 142 | }, 143 | description="I'm a yankee doodle dandy, a yankee doodle do or die", 144 | ), 145 | ] 146 | ).nested() 147 | 148 | assert d.wrap(width=20) == [ 149 | "A: Apple", 150 | "I'm a yankee doodle", 151 | "dandy, a yankee", 152 | "doodle do or die:", 153 | " C_X: x coordinate", 154 | " C_Y: y coordinate", 155 | "D: Bee", 156 | ] 157 | 158 | 159 | def test_describe_multi_nested_nodescription(): 160 | d = EnvVarsDescription( 161 | [ 162 | env_var("a", type=int, description="Apple"), 163 | env_var("d", type=int, description="Bee"), 164 | env_var( 165 | "c_", 166 | type=SimpleNamespace, 167 | args={ 168 | "x": env_var("x", type=int, description="x coordinate"), 169 | "y": env_var("y", type=int, description="y coordinate"), 170 | }, 171 | ), 172 | ] 173 | ).nested() 174 | 175 | assert d.wrap() == [ 176 | "A: Apple", 177 | " C_X: x coordinate", 178 | " C_Y: y coordinate", 179 | "D: Bee", 180 | ] 181 | -------------------------------------------------------------------------------- /tests/unittests/test_envparser.py: -------------------------------------------------------------------------------- 1 | from os import name 2 | 3 | from pytest import raises, skip 4 | 5 | from envolved.envparser import env_parser 6 | 7 | if name == "nt": 8 | skip("windows is always case-insensitive", allow_module_level=True) 9 | 10 | 11 | def test_parse_real_time(monkeypatch): 12 | with raises(KeyError): 13 | env_parser.get(False, "a") 14 | monkeypatch.setenv("a", "x") 15 | assert env_parser.get(False, "a") == "x" 16 | 17 | 18 | def test_exact_override(monkeypatch): 19 | monkeypatch.setenv("A", "0") 20 | assert env_parser.get(False, "a") == "0" 21 | monkeypatch.setenv("a", "1") 22 | assert env_parser.get(False, "a") == "1" 23 | monkeypatch.delenv("a") 24 | assert env_parser.get(False, "a") == "0" 25 | -------------------------------------------------------------------------------- /tests/unittests/test_examples.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import enum 4 | import re 5 | from types import SimpleNamespace 6 | 7 | from pytest import MonkeyPatch, fixture 8 | 9 | from envolved import env_var 10 | from envolved.parsers import CollectionParser, FindIterCollectionParser, LookupParser, MatchParser 11 | 12 | 13 | class FakeEnviron: 14 | def __init__(self, monkeypatch: MonkeyPatch) -> None: 15 | self.monkeypatch = monkeypatch 16 | 17 | def __setitem__(self, key: str, value: str) -> None: 18 | self.monkeypatch.setenv(key, value) 19 | 20 | 21 | @fixture() 22 | def os(): 23 | return SimpleNamespace(environ=FakeEnviron(MonkeyPatch())) 24 | 25 | 26 | def test_bool_special_parser(os): 27 | enable_cache_ev = env_var("ENABLE_CACHE", type=bool) 28 | 29 | os.environ["ENABLE_CACHE"] = "False" 30 | 31 | assert enable_cache_ev.get() is False 32 | 33 | 34 | def test_bypass_bool_parser(os): 35 | enable_cache_ev = env_var("ENABLE_CACHE", type=lambda x: bool(x)) 36 | 37 | os.environ["ENABLE_CACHE"] = "False" 38 | 39 | assert enable_cache_ev.get() is True 40 | 41 | 42 | def test_collection_parser(os): 43 | countries = env_var("COUNTRIES", type=CollectionParser(",", str.lower, set)) 44 | 45 | os.environ["COUNTRIES"] = "United States,Canada,Mexico" 46 | 47 | assert countries.get() == {"united states", "canada", "mexico"} 48 | 49 | 50 | def test_collection_parser_pairwise(os): 51 | headers_ev = env_var("HTTP_HEADERS", type=CollectionParser.pair_wise_delimited(";", ":", str.upper, str)) 52 | 53 | os.environ["HTTP_HEADERS"] = "Foo:bar;baz:qux" 54 | 55 | assert headers_ev.get() == {"FOO": "bar", "BAZ": "qux"} 56 | 57 | 58 | def test_collection_parser_pairwise_2(os): 59 | server_params_ev = env_var( 60 | "SERVER_PARAMS", 61 | type=CollectionParser.pair_wise_delimited( 62 | ";", 63 | ":", 64 | str, 65 | { 66 | "host": str, 67 | "port": int, 68 | "is_ssl": bool, 69 | }, 70 | ), 71 | ) 72 | 73 | os.environ["SERVER_PARAMS"] = "host:localhost;port:8080;is_ssl:false" 74 | 75 | assert server_params_ev.get() == {"host": "localhost", "port": 8080, "is_ssl": False} 76 | 77 | 78 | def test_find_iter_collection_parser(os): 79 | def parse_group(match: re.Match) -> set[int]: 80 | return {int(x) for x in match.group(1).split(",")} 81 | 82 | groups_ev = env_var("GROUPS", type=FindIterCollectionParser(re.compile(r"{([,\d]+)}(,|$)"), parse_group)) 83 | 84 | os.environ["GROUPS"] = "{1,2,3},{4,5,6},{7,8,9}" 85 | 86 | assert groups_ev.get() == [{1, 2, 3}, {4, 5, 6}, {7, 8, 9}] 87 | 88 | 89 | def test_match_parser(os): 90 | class Color(enum.Enum): 91 | RED = 1 92 | GREEN = 2 93 | BLUE = 3 94 | 95 | color_ev = env_var("COLOR", type=MatchParser(Color)) 96 | 97 | os.environ["COLOR"] = "RED" 98 | 99 | assert color_ev.get() == Color.RED 100 | 101 | 102 | def test_lookup_parser(os): 103 | class Color(enum.Enum): 104 | RED = 1 105 | GREEN = 2 106 | BLUE = 3 107 | 108 | color_ev = env_var("COLOR", type=LookupParser(Color)) 109 | 110 | os.environ["COLOR"] = "RED" 111 | 112 | assert color_ev.get() == Color.RED 113 | -------------------------------------------------------------------------------- /tests/unittests/test_mock.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from os import getenv 3 | from unittest import mock 4 | 5 | from pytest import MonkeyPatch, fixture, raises 6 | 7 | from envolved import MissingEnvError, env_var 8 | from envolved.describe import exclude_from_description 9 | from envolved.envvar import missing 10 | 11 | 12 | def test_monkeypatch_setenviron(monkeypatch): 13 | a = env_var("a", type=int) 14 | monkeypatch.setenv(a.key, "1") 15 | assert a.get() == 1 16 | assert getenv("a") == "1" 17 | 18 | 19 | def test_monkeypatch_cleanup(): 20 | assert getenv("a") is None 21 | 22 | 23 | def test_monkeypatch_append(monkeypatch): 24 | a = env_var("a", type=int) 25 | monkeypatch.setenv(a.key, "1") 26 | assert a.get() == 1 27 | monkeypatch.setenv(a.key, "2", prepend="3") 28 | assert a.get() == 231 29 | 30 | 31 | def test_delenviron(monkeypatch): 32 | a = env_var("a", type=int, default=5) 33 | monkeypatch.setenv(a.key, "6") 34 | monkeypatch.delenv(a.key) 35 | assert a.get() == 5 36 | 37 | 38 | def test_delenviron_raising(monkeypatch): 39 | a = env_var("a", type=int, default=5) 40 | with raises(KeyError): 41 | monkeypatch.delenv(a.key) 42 | assert a.get() == 5 43 | 44 | 45 | def test_delenviron_missing_ok(monkeypatch): 46 | a = env_var("a", type=int, default=5) 47 | monkeypatch.delenv(a.key, raising=False) 48 | assert a.get() == 5 49 | 50 | 51 | _a = env_var("a", type=int, default=5) 52 | exclude_from_description(_a) 53 | 54 | 55 | def test_setenv(monkeypatch): 56 | monkeypatch.setattr(_a, "monkeypatch", 6.25) 57 | assert _a.get() == 6.25 58 | 59 | 60 | def test_monkeypatch_setenv_cleanup(): 61 | assert _a.get() == 5 62 | 63 | 64 | def test_delenv(monkeypatch): 65 | monkeypatch.setattr(_a, "monkeypatch", missing) 66 | with raises(MissingEnvError): 67 | _a.get() 68 | 69 | 70 | @fixture(scope="module") 71 | def module_level_mp(): 72 | with MonkeyPatch.context() as mp: 73 | yield mp 74 | 75 | 76 | def test_mlmp(module_level_mp): 77 | a = env_var("a", type=int, default=5) 78 | module_level_mp.setenv(a.key, "6") 79 | assert a.get() == 6 80 | 81 | 82 | def follow_up_test_mlmp(module_level_mp): 83 | a = env_var("a", type=int, default=5) 84 | assert a.get() == 6 85 | 86 | 87 | class TestUnittests(unittest.TestCase): 88 | @mock.patch.object(_a, "monkeypatch", 0.65) 89 | def test_unittest(self): 90 | assert _a.get() == 0.65 91 | -------------------------------------------------------------------------------- /tests/unittests/test_parsers.py: -------------------------------------------------------------------------------- 1 | import re 2 | from collections import defaultdict 3 | from dataclasses import dataclass 4 | from enum import Enum 5 | from typing import List 6 | 7 | from pydantic import BaseModel as BaseModel2, RootModel, TypeAdapter 8 | from pydantic.v1 import BaseModel as BaseModel1 9 | from pytest import mark, raises 10 | 11 | from envolved.parsers import ( 12 | BoolParser, 13 | CollectionParser, 14 | FindIterCollectionParser, 15 | LookupParser, 16 | MatchParser, 17 | complex_parser, 18 | parser, 19 | ) 20 | 21 | 22 | def test_complex(): 23 | assert complex_parser("0") == 0 24 | assert complex_parser("1+1i") == 1 + 1j 25 | assert complex_parser("3i") == 3j 26 | 27 | 28 | def test_bool_parser(): 29 | p = BoolParser(("y", "yes"), ("n", "no"), case_sensitive=True) 30 | 31 | assert p("y") 32 | assert not p("no") 33 | with raises(ValueError): 34 | p("Yes") 35 | 36 | 37 | def test_bool_default(): 38 | p = BoolParser(("y", "yes"), ("n", "no"), default=False) 39 | assert not p("Hi") 40 | 41 | 42 | def test_delimited(): 43 | p = CollectionParser(re.compile(r"(? AsyncIterator[int]: 17 | yield a 18 | 19 | 20 | base_ev: EnvVar[AbstractAsyncContextManager[int | None]] = exclude_from_description( 21 | env_var("SEQ_", type=cont, args={"a": inferred_env_var(), "b": inferred_env_var()}) 22 | ) 23 | seq_ev = base_ev.with_prefix("SEQ_") 24 | seq_ev.default = nullcontext() 25 | 26 | 27 | async def test_cont() -> int | None: 28 | async with seq_ev.get() as t: 29 | return t 30 | --------------------------------------------------------------------------------