From d01381676ac6cbf054ae2117754f0c284f77a5b6 Mon Sep 17 00:00:00 2001 From: Ben Avrahami Date: Wed, 7 Feb 2024 23:02:39 +0200 Subject: [PATCH] Single type factory args (#12) * added args to single env vars, default factory * fix email * maybe * fix * it's working now I'm pretty sure * once more with feeling * doc fix --------- Co-authored-by: Ben Avrahami --- .github/workflows/test.yml | 4 +- CHANGELOG.md | 8 +++ docs/conf.py | 4 +- docs/cookbook.rst | 11 +++-- docs/envvar.rst | 78 ++++++++++++++++++++++-------- docs/string_parsing.rst | 4 +- envolved/__init__.py | 3 +- envolved/_version.py | 2 +- envolved/envparser.py | 20 +------- envolved/envvar.py | 60 ++++++++++++----------- envolved/parsers.py | 11 ++++- pyproject.toml | 25 ++++++---- scripts/benchmark.sh | 1 - tests/unittests/test_schema.py | 65 +++++++++++++++++++++++-- tests/unittests/test_single_var.py | 26 +++++++++- 15 files changed, 227 insertions(+), 95 deletions(-) delete mode 100644 scripts/benchmark.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 08d5c35..6dadb29 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,8 +11,9 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.7, 3.8, 3.9, "3.10", "3.11", "3.12"] # format: 3.7, 3.8, 3.9 + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] # format: 3.7, 3.8, 3.9 platform: [ubuntu-latest, macos-latest, windows-latest] + fail-fast: false steps: - uses: actions/checkout@v2 - name: Set up Python @@ -26,6 +27,7 @@ jobs: run: | sh scripts/install.sh - name: Lint + if: matrix.python-version != '3.7' run: | poetry run sh scripts/lint.sh - name: Tests diff --git a/CHANGELOG.md b/CHANGELOG.md index c7cc14a..202a426 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,12 @@ # envolved Changelog +## 1.3.0 +### Added +* single-environment variable can now be given additional arguments, that are passed to the parser. +* env-var defaults can now be wrapped in a `Factory` to allow for a default Factory. +### Changed +* type annotation correctness is no longer supported for python 3.7 +### Documentation +* Fixed some typos in the documentation ## 1.2.1 ### Fixed * The children of envvars that are excluded from the description are now also excluded. diff --git a/docs/conf.py b/docs/conf.py index bffb71d..5bcba53 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -20,8 +20,8 @@ # -- Project information ----------------------------------------------------- project = "envolved" -copyright = "2020, ben avrahami " -author = "ben avrahami " +copyright = "2020, ben avrahami" +author = "ben avrahami" # -- General configuration --------------------------------------------------- diff --git a/docs/cookbook.rst b/docs/cookbook.rst index cdd4f13..152bf6f 100644 --- a/docs/cookbook.rst +++ b/docs/cookbook.rst @@ -73,6 +73,7 @@ We can actually use :func:`~envvar.inferred_env_var` to infer the name of :class we want to prototype a schema without having to create a schema class. .. code-block:: + from envolved import ... my_schema_ev = env_var('FOO_', type=SimpleNamespace, args={ @@ -80,12 +81,14 @@ we want to prototype a schema without having to create a schema class. 'y': inferred_env_var(type=string, default='hello'), }) - # this will result in a namespace that fills `x` and `y` with the values of `FOO_X` and `FOO_Y` respectively + # this will result in a namespace that fills `x` and `y` with the values of `FOO_X` + # and `FOO_Y` respectively -Note a sticking point here, he have to specify not only the type of the inferred env var, but also the default value. +Note a sticking point here, we have to specify not only the type of the inferred env var, but also the default value. .. code-block:: + from envolved import ... my_schema_ev = env_var('FOO_', type=SimpleNamespace, args={ @@ -102,6 +105,7 @@ Note a sticking point here, he have to specify not only the type of the inferred We can specify that an inferred env var is required by explicitly stating `default=missing` .. code-block:: + from envolved import ..., missing my_schema_ev = env_var('FOO_', type=SimpleNamespace, args={ @@ -109,4 +113,5 @@ We can specify that an inferred env var is required by explicitly stating `defau 'y': inferred_env_var(type=string, default='hello'), }) - # this will result in a namespace that fills `x` with the value of `FOO_X` and will raise an exception if `FOO_X` is not set + # this will result in a namespace that fills `x` with the value of `FOO_X` + # and will raise an exception if `FOO_X` is not set diff --git a/docs/envvar.rst b/docs/envvar.rst index f564769..98afd22 100644 --- a/docs/envvar.rst +++ b/docs/envvar.rst @@ -4,7 +4,7 @@ EnvVars .. module:: envvar .. function:: env_var(key: str, *, type: collections.abc.Callable[[str], T],\ - default: T | missing | discard = missing,\ + default: T | missing | discard | Factory[T] = missing,\ description: str | collections.abc.Sequence[str] | None = None, \ validators: collections.abc.Iterable[collections.abc.Callable[[T], T]] = (), \ case_sensitive: bool = False, strip_whitespaces: bool = True) -> envvar.SingleEnvVar[T] @@ -14,8 +14,8 @@ EnvVars :param key: The key of the environment variable. :param type: A callable to use to parse the string value of the environment variable. :param default: The default value of the EnvVar if the environment variable is missing. If unset, an exception will - be raised if the environment variable is missing. The default can also be set to :attr:`~envvar.discard` to - indicate to parent :class:`SchemaEnvVars ` that this env var should be discarded from the + be raised if the environment variable is missing. The default can also be a :class:`~envvar.Factory` to specify a default factory, + or :attr:`~envvar.discard` to indicate to parent :class:`SchemaEnvVars ` that this env var should be discarded from the arguments if it is missing. :param description: A description of the EnvVar. See :ref:`describing:Describing Environment Variables`. :param validators: A list of callables to validate the value of the EnvVar. Validators can be added to the EnvVar @@ -23,7 +23,8 @@ EnvVars :param case_sensitive: Whether the key of the EnvVar is case sensitive. :param strip_whitespaces: Whether to strip whitespaces from the value of the environment variable before parsing it. -.. function:: env_var(key: str, *, type: collections.abc.Callable[..., T], default: T | missing = missing, \ +.. function:: env_var(key: str, *, type: collections.abc.Callable[..., T], \ + default: T | missing | discard | Factory[T] = missing, \ args: dict[str, envvar.EnvVar | InferEnvVar] = ..., \ pos_args: collections.base.Sequence[envvar.EnvVar | InferEnvVar] = ..., \ description: str | collections.abc.Sequence[str] | None = None,\ @@ -36,8 +37,8 @@ EnvVars :param key: The key of the environment variable. This will be a common prefix applied to all environment variables. :param type: A callable to call with ``pos_args`` and ``args`` to create the EnvVar value. :param default: The default value of the EnvVar if the environment variable is missing. If unset, an exception will - be raised if the environment variable is missing. The default can also be set to :attr:`~envvar.discard` to - indicate to parent :class:`SchemaEnvVars ` that this env var should be discarded from the + be raised if the environment variable is missing. The default can also be a :class:`~envvar.Factory` to specify a default factory, + or :attr:`~envvar.discard` to indicate to parent :class:`SchemaEnvVars ` that this env var should be discarded from the arguments if it is missing. :param pos_args: A sequence of EnvVars to to retrieve and use as positional arguments to ``type``. Arguments can be :ref:`inferred ` in some cases. @@ -46,15 +47,14 @@ EnvVars :param description: A description of the EnvVar. See :ref:`describing:Describing Environment Variables`. :param validators: A list of callables to validate the value of the EnvVar. Validators can be added to the EnvVar after it is created with :func:`~envvar.EnvVar.validator`. - :param on_partial: The value to use if the EnvVar is partially missing. See - :attr:`~envvar.SchemaEnvVar.on_partial`. + :param on_partial: The value to use if the EnvVar is partially missing. See :attr:`~envvar.SchemaEnvVar.on_partial`. .. class:: EnvVar This is the base class for all environment variables. .. attribute:: default - :type: T | missing | discard + :type: T | missing | discard | envvar.Factory[T] The default value of the EnvVar. If this attribute is set to anything other than :attr:`missing`, then it will be used as the default value if the environment variable is not set. If set to :attr:`discard`, then the @@ -139,16 +139,6 @@ EnvVars An :class:`EnvVar` subclass that interfaces with a single environment variable. - When the value is retrieved, it will be searched for in the following order: - - #. The environment variable with the name as the :attr:`key` of the EnvVar is considered. If it exists, it will be - used. - #. If :attr:`case_sensitive` is ``False``. Environment variables with case-insensitive names equivalent to - :attr:`key` of the EnvVar is considered. If any exist, they will be used. If multiple exist, a - :exc:`RuntimeError` will be raised. - #. The :attr:`default` value of the EnvVar is used, if it exists. - #. A :exc:`~exceptions.MissingEnvError` is raised. - .. property:: key :type: str @@ -179,6 +169,41 @@ EnvVars If set to ``True`` (as is the default), whitespaces will be stripped from the environment variable value before it is processed. + .. method:: get(**kwargs)->T + + Return the value of the environment variable. The value will be searched for in the following order: + + #. The environment variable with the name as the :attr:`key` of the EnvVar is considered. If it exists, it will be + used. + + #. If :attr:`case_sensitive` is ``False``. Environment variables with case-insensitive names equivalent to + :attr:`key` of the EnvVar is considered. If any exist, they will be used. If multiple exist, a + :exc:`RuntimeError` will be raised. + + #. The :attr:`~EnvVar.default` value of the EnvVar is used, if it exists. If the :attr:`~EnvVar.default` is an instance of + :class:`~envvar.Factory`, the factory will be called (without arguments) to create the value of the EnvVar. + + #. A :exc:`~exceptions.MissingEnvError` is raised. + + :param kwargs: Additional keyword arguments to pass to the :attr:`type` callable. + :return: The value of the retrieved environment variable. + + .. code-block:: + :caption: Using SingleEnvVar to fetch a value from an environment variable, with additional keyword arguments. + + from dataclasses import dataclass + + def parse_users(value: str, *, reverse: bool=False) -> list[str]: + return sorted(value.split(','), reverse=reverse) + + users_ev = env_var("USERNAMES", type=parse_users) + + if desc: + users = users_ev.get(reverse=True) # will return a list of usernames sorted in reverse order + else: + users = users_ev.get() # will return a list of usernames sorted in ascending order + + .. class:: SchemaEnvVar An :class:`EnvVar` subclass that interfaces with a multiple environment variables, combining them into a single @@ -206,7 +231,7 @@ EnvVars The sequence of positional arguments to the :attr:`type` callable. (read only) .. attribute:: on_partial - :type: T | as_default | missing | discard + :type: T | as_default | missing | discard | envvar.Factory[T] This attribute dictates how the EnvVar should behave when only some of the keys are explicitly present (i.e. When only some of the expected environment variables exist in the environment). @@ -215,10 +240,11 @@ EnvVars .. note:: - The EnvVar's :attr:`default` must not be :data:`missing` if this option is used. + The EnvVar's :attr:`~EnvVar.default` must not be :data:`missing` if this option is used. * If set to :data:`missing`, a :exc:`~exceptions.MissingEnvError` will be raised, even if the EnvVar's :attr:`~EnvVar.default` is set. + * If set to :class:`~envvar.Factory`, the factory will be called to create the value of the EnvVar. * If set to a value, that value will be returned. .. method:: get(**kwargs)->T @@ -247,3 +273,13 @@ EnvVars user_ev.get(age=20, height=168) # will return a User object with the name taken from the environment variables, # but with the age and height overridden by the keyword arguments. + +.. class:: Factory(callback: collections.abc.Callable[[], T]) + + A wrapped around a callable, indicating that the callable should be used as a factory for creating objects, rather than + as a normal object. + + .. attribute:: callback + :type: collections.abc.Callable[[], T] + + The callable that will be used to create the object. \ No newline at end of file diff --git a/docs/string_parsing.rst b/docs/string_parsing.rst index cd2babd..86a1d28 100644 --- a/docs/string_parsing.rst +++ b/docs/string_parsing.rst @@ -43,9 +43,9 @@ All the special parsers are: * ``complex``: parses the string as a complex number, treating "i" as an imaginary unit in addition to "j". * union type ``A | None`` or ``typing.Union[A, None]`` or ``typing.Optional[A]``: Will treat the parser as though it only parses ``A``. -* enum type ``E``: translates each enum name to the corresponding enum member, disregarding cases (equivalent to +* enum type ``E``: translates each enum name to the corresponding enum member, ignoring cases (equivalent to ``LookupParser.case_insensitive(E)`` see :class:`~parsers.LookupParser`). -* pydantic ``BaseModel``: parses the string as JSON and validates it against the model (both pydnatic v1 and v2 +* pydantic ``BaseModel``: parses the string as JSON and validates it against the model (both pydantic v1 and v2 models are supported). * pydantic ``TypeAdapter``: parses the string as JSON and validates it against the adapted type. diff --git a/envolved/__init__.py b/envolved/__init__.py index eeae457..6c9608a 100644 --- a/envolved/__init__.py +++ b/envolved/__init__.py @@ -1,6 +1,6 @@ from envolved._version import __version__ from envolved.describe import describe_env_vars -from envolved.envvar import EnvVar, as_default, discard, env_var, inferred_env_var, missing, no_patch +from envolved.envvar import EnvVar, Factory, as_default, discard, env_var, inferred_env_var, missing, no_patch from envolved.exceptions import MissingEnvError __all__ = [ @@ -14,4 +14,5 @@ "inferred_env_var", "missing", "no_patch", + "Factory", ] diff --git a/envolved/_version.py b/envolved/_version.py index a955fda..67bc602 100644 --- a/envolved/_version.py +++ b/envolved/_version.py @@ -1 +1 @@ -__version__ = "1.2.1" +__version__ = "1.3.0" diff --git a/envolved/envparser.py b/envolved/envparser.py index 91504f5..b9a124b 100644 --- a/envolved/envparser.py +++ b/envolved/envparser.py @@ -44,7 +44,7 @@ def reload(self): if self.lock.locked(): # if the lock is already held by someone, we don't need to do any work, just wait until they're done with self.lock: - pass + return with self.lock: self.environ_case_insensitive = {} for k in environ.keys(): @@ -91,15 +91,6 @@ def audit_hook(self, event: str, args: Tuple[Any, ...]): # pragma: no cover self.environ_case_insensitive[lower].discard(key) def get(self, case_sensitive: bool, key: str) -> str: - """ - look up the value of an environment variable. - :param case_sensitive: Whether to make the lookup case-sensitive. - :param key: The environment variable name. - :return: the string value of the environment variable - :raises KeyError: if the variable is missing - :raises CaseInsensitiveAmbiguity: if there is ambiguity over multiple case-insensitive matches. - """ - if case_sensitive: return getenv_unsafe(key) @@ -123,15 +114,6 @@ def get(self, case_sensitive: bool, key: str) -> str: class NonAuditingEnvParser(ReloadingEnvParser): def get(self, case_sensitive: bool, key: str) -> str: - """ - look up the value of an environment variable. - :param case_sensitive: Whether to make the lookup case-sensitive. - :param key: The environment variable name. - :return: the string value of the environment variable - :raises KeyError: if the variable is missing - :raises CaseInsensitiveAmbiguity: if there is ambiguity over multiple case-insensitive matches. - """ - if case_sensitive: return getenv_unsafe(key) diff --git a/envolved/envvar.py b/envolved/envvar.py index ef62f3d..b95005d 100644 --- a/envolved/envvar.py +++ b/envolved/envvar.py @@ -68,9 +68,14 @@ class Discard(Enum): Description = Union[str, Sequence[str]] +@dataclass +class Factory(Generic[T]): + callback: Callable[[], T] + + @dataclass class _EnvVarResult(Generic[T]): - value: T | Discard + value: Union[T, Discard] exists: bool @@ -83,7 +88,7 @@ def unwrap_validator(func: Callable[[T], T]) -> Callable[[T], T]: class EnvVar(Generic[T], ABC): def __init__( self, - default: Union[T, Missing, Discard], + default: Union[T, Factory[T], Missing, Discard], description: Optional[Description], validators: Iterable[Callable[[T], T]] = (), ): @@ -92,10 +97,7 @@ def __init__( self.description = description self.monkeypatch: Union[T, Missing, Discard, NoPatch] = no_patch - def get(self) -> T: - return self._get_with() - - def _get_with(self, **kwargs: Any) -> T: + def get(self, **kwargs: Any) -> T: if self.monkeypatch is not no_patch: if self.monkeypatch is missing: key = getattr(self, "key", self) @@ -115,7 +117,14 @@ def _get_validated(self, **kwargs: Any) -> _EnvVarResult[T]: except MissingEnvError as mee: if self.default is missing: raise mee - return _EnvVarResult(self.default, exists=False) + + default: Union[T, Discard] + if isinstance(self.default, Factory): + default = self.default.callback() + else: + default = self.default + + return _EnvVarResult(default, exists=False) for validator in self._validators: value = validator(value) return _EnvVarResult(value, exists=True) @@ -151,7 +160,7 @@ class SingleEnvVar(EnvVar[T]): def __init__( self, key: str, - default: Union[T, Missing, Discard] = missing, + default: Union[T, Missing, Discard, Factory[T]] = missing, *, type: Union[Type[T], Parser[T]], description: Optional[Description] = None, @@ -174,8 +183,6 @@ def type(self) -> Parser[T]: return self._type def _get(self, **kwargs: Any) -> T: - if kwargs: - raise TypeError(f"unexpected keyword arguments {kwargs!r}") try: raw_value = env_parser.get(self.case_sensitive, self._key) except KeyError as err: @@ -185,7 +192,7 @@ def _get(self, **kwargs: Any) -> T: if self.strip_whitespaces: raw_value = raw_value.strip() - return self.type(raw_value) + return self.type(raw_value, **kwargs) def with_prefix(self, prefix: str) -> SingleEnvVar[T]: return register_env_var( @@ -208,11 +215,11 @@ class SchemaEnvVar(EnvVar[T]): def __init__( self, keys: Mapping[str, EnvVar[Any]], - default: Union[T, Missing, Discard] = missing, + default: Union[T, Missing, Discard, Factory[T]] = missing, *, type: Callable[..., T], description: Optional[Description] = None, - on_partial: Union[T, Missing, AsDefault, Discard] = missing, + on_partial: Union[T, Missing, AsDefault, Discard, Factory[T]] = missing, validators: Iterable[Callable[[T], T]] = (), pos_args: Sequence[EnvVar[Any]] = (), ): @@ -235,18 +242,15 @@ def pos_args(self) -> Sequence[EnvVar[Any]]: return tuple(self._pos_args) @property - def on_partial(self) -> Union[T, Missing, AsDefault, Discard]: + def on_partial(self) -> Union[T, Missing, AsDefault, Discard, Factory[T]]: return self._on_partial @on_partial.setter - def on_partial(self, value: Union[T, Missing, AsDefault, Discard]): + def on_partial(self, value: Union[T, Missing, AsDefault, Discard, Factory[T]]): if value is as_default and self.default is missing: raise TypeError("on_partial cannot be as_default if default is missing") self._on_partial = value - def get(self, **kwargs: Any) -> T: - return super()._get_with(**kwargs) - def _get(self, **kwargs: Any) -> T: pos_values = [] kw_values = kwargs @@ -282,6 +286,8 @@ def _get(self, **kwargs: Any) -> T: if self.on_partial is not as_default and any_exist: if self.on_partial is missing: raise SkipDefault(errs[0]) + if isinstance(self.on_partial, Factory): + return self.on_partial.callback() return self.on_partial # type: ignore[return-value] raise errs[0] return self._type(*pos_values, **kw_values) @@ -307,7 +313,7 @@ def _get_children(self) -> Iterable[EnvVar[Any]]: def env_var( key: str, *, - default: Union[T, Missing, AsDefault, Discard] = missing, + default: Union[T, Missing, AsDefault, Discard, Factory[T]] = missing, description: Optional[Description] = None, validators: Iterable[Callable[[T], T]] = (), case_sensitive: bool = False, @@ -321,7 +327,7 @@ def env_var( key: str, *, type: ParserInput[T], - default: Union[T, Missing, Discard] = missing, + default: Union[T, Missing, Discard, Factory[T]] = missing, description: Optional[Description] = None, validators: Iterable[Callable[[T], T]] = (), case_sensitive: bool = False, @@ -335,12 +341,12 @@ def env_var( key: str, *, type: Callable[..., T], - default: Union[T, Missing, Discard] = missing, + default: Union[T, Missing, Discard, Factory[T]] = missing, pos_args: Sequence[Union[EnvVar[Any], InferEnvVar[Any]]], args: Mapping[str, Union[EnvVar[Any], InferEnvVar[Any]]] = {}, description: Optional[Description] = None, validators: Iterable[Callable[[T], T]] = (), - on_partial: Union[T, Missing, AsDefault, Discard] = missing, + on_partial: Union[T, Missing, AsDefault, Discard, Factory[T]] = missing, ) -> SchemaEnvVar[T]: pass @@ -350,12 +356,12 @@ def env_var( key: str, *, type: Callable[..., T], - default: Union[T, Missing, Discard] = missing, + default: Union[T, Missing, Discard, Factory[T]] = missing, pos_args: Sequence[Union[EnvVar[Any], InferEnvVar[Any]]] = (), args: Mapping[str, Union[EnvVar[Any], InferEnvVar[Any]]], description: Optional[Description] = None, validators: Iterable[Callable[[T], T]] = (), - on_partial: Union[T, Missing, AsDefault, Discard] = missing, + on_partial: Union[T, Missing, AsDefault, Discard, Factory[T]] = missing, ) -> SchemaEnvVar[T]: pass @@ -459,7 +465,7 @@ class InferType(Enum): class InferEnvVar(Generic[T]): key: Optional[str] type: Any - default: Union[T, Missing, AsDefault, Discard] + default: Union[T, Missing, AsDefault, Discard, Factory[T]] description: Optional[Description] validators: List[Callable[[T], T]] case_sensitive: bool @@ -472,7 +478,7 @@ def with_spec(self, param_id: Union[str, int], spec: FactoryArgSpec | None) -> S raise ValueError(f"cannot infer key for positional parameter {param_id}, please specify a key") key = param_id - default: Union[T, Missing, Discard] + default: Union[T, Missing, Discard, Factory[T]] if self.default is as_default: if spec is None: raise ValueError(f"cannot infer default for parameter {key}, parameter {param_id} not found in factory") @@ -516,7 +522,7 @@ def inferred_env_var( key: Optional[str] = None, *, type: Union[ParserInput[T], InferType] = infer_type, - default: Union[T, Missing, AsDefault, Discard] = as_default, + default: Union[T, Missing, AsDefault, Discard, Factory[T]] = as_default, description: Optional[Description] = None, validators: Iterable[Callable[[T], T]] = (), case_sensitive: bool = False, diff --git a/envolved/parsers.py b/envolved/parsers.py index 56ceb7c..8b5574a 100644 --- a/envolved/parsers.py +++ b/envolved/parsers.py @@ -4,6 +4,7 @@ from enum import Enum, auto from functools import lru_cache from itertools import chain +from sys import version_info from typing import ( Any, Callable, @@ -20,6 +21,8 @@ Union, ) +from typing_extensions import Concatenate, TypeAlias + from envolved.utils import extract_from_option __all__ = ["Parser", "BoolParser", "CollectionParser", "parser"] @@ -41,7 +44,13 @@ T = TypeVar("T") -Parser = Callable[[str], T] +if version_info >= (3, 11): + # theoretically, I'd like to restrict this to keyword arguments only, but that's not possible yet in python + Parser: TypeAlias = Callable[Concatenate[str, ...], T] +else: + # we can only use Concatenate[str, ...] in python 3.11+ + Parser: TypeAlias = Callable[[str], T] # type: ignore[misc, no-redef] + ParserInput = Union[Parser[T], Type[T]] special_parser_inputs: Dict[ParserInput[Any], Parser[Any]] = { diff --git a/pyproject.toml b/pyproject.toml index f380202..54db4f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "envolved" -version = "1.2.1" +version = "1.3.0" description = "" authors = ["ben avrahami "] license = "MIT" @@ -13,14 +13,18 @@ packages = [ [tool.poetry.dependencies] python = "^3.7" +typing-extensions = [ + {version="<4.8.0", python=">=3.7, <3.8"}, + {version="*", python=">=3.8"}, +] [tool.poetry.group.dev.dependencies] pytest = "*" sphinx = {version="^7", python = ">=3.12"} furo = {version="*", python = ">=3.12"} -mypy = "*" +mypy = {version="*", python=">=3.8"} pytest-cov = "^4.1.0" -ruff = "*" +ruff = {version="*", python=">=3.8"} pydantic = "^2.5.2" [build-system] @@ -30,6 +34,9 @@ build-backend = "poetry.masonry.api" [tool.ruff] target-version = "py37" +line-length = 120 +output-format = "full" +[tool.ruff.lint] exclude = ["docs/**"] # https://beta.ruff.rs/docs/rules/ select = ["I", "E", "W", "F", "N", "S", "BLE", "COM", "C4", "ISC", "ICN", "G", "PIE", "T20", "PYI", "Q", "SLF", "SIM", @@ -56,25 +63,23 @@ ignore = [ # disabled for formatter: 'COM812', 'COM819', 'E501', 'ISC001', 'Q000', 'Q001', 'Q002', 'Q003', 'W191' ] -line-length = 120 -show-source = true -[tool.ruff.isort] +[tool.ruff.lint.isort] combine-as-imports=true -[tool.ruff.flake8-annotations] +[tool.ruff.lint.flake8-annotations] suppress-none-returning = true [tool.ruff.lint.flake8-self] ignore-names = ["_get_descendants", "_get_children"] -[tool.ruff.flake8-pytest-style] +[tool.ruff.lint.flake8-pytest-style] raises-require-match-for = [] -[tool.ruff.pyupgrade] +[tool.ruff.lint.pyupgrade] keep-runtime-typing = true -[tool.ruff.per-file-ignores] +[tool.ruff.lint.per-file-ignores] "tests/**" = [ "ANN", # annotations "N802", # Function name should be lowercase diff --git a/scripts/benchmark.sh b/scripts/benchmark.sh deleted file mode 100644 index 7e08629..0000000 --- a/scripts/benchmark.sh +++ /dev/null @@ -1 +0,0 @@ -poetry run python -c "from tests.benchmarking.write_to_doc import main; main()" \ No newline at end of file diff --git a/tests/unittests/test_schema.py b/tests/unittests/test_schema.py index bc1e457..adefc7f 100644 --- a/tests/unittests/test_schema.py +++ b/tests/unittests/test_schema.py @@ -5,7 +5,7 @@ from pytest import mark, raises, skip -from envolved import MissingEnvError, as_default, env_var, missing +from envolved import Factory, MissingEnvError, as_default, env_var, missing from envolved.envvar import discard, inferred_env_var @@ -217,6 +217,33 @@ def test_partial_schema_ok(monkeypatch, A): assert a_pos.get() is None +@a +def test_partial_schema_ok_factory(monkeypatch, A): + a = env_var( + "a_", + type=A, + default=Factory(list), + args={"a": env_var("A"), "b": env_var("B"), "c": env_var("C", type=str)}, + on_partial=as_default, + ) + + a_pos = env_var( + "a_", + type=A, + default=Factory(list), + pos_args=(env_var("A"), env_var("B"), env_var("C", type=str)), + on_partial=as_default, + ) + + monkeypatch.setenv("a_a", "hi") + monkeypatch.setenv("a_b", "36") + assert a.get() == [] + assert a_pos.get() == [] + a0 = a.get() + a1 = a.get() + assert a0 is not a1 + + @a def test_schema_all_missing(monkeypatch, A): a = env_var("a_", type=A, default=None, args={"a": env_var("A"), "b": env_var("B"), "c": env_var("C", type=str)}) @@ -226,6 +253,17 @@ def test_schema_all_missing(monkeypatch, A): assert a_pos.get() is None +@a +def test_schema_all_missing_factory(monkeypatch, A): + a = env_var( + "a_", type=A, default=Factory(list), args={"a": env_var("A"), "b": env_var("B"), "c": env_var("C", type=str)} + ) + a_pos = env_var("a_", type=A, default=Factory(list), pos_args=(env_var("A"), env_var("B"), env_var("C", type=str))) + + assert a.get() == [] + assert a_pos.get() == [] + + @a def test_partial_schema_with_default(monkeypatch, A): a = env_var( @@ -251,20 +289,20 @@ def test_partial_schema_ok_with_default(monkeypatch, A): type=A, default=object(), args={"a": env_var("A"), "b": env_var("B"), "c": env_var("C", type=str)}, - on_partial=None, + on_partial=Factory(list), ) a_pos = env_var( "a_", type=A, default=object(), pos_args=(env_var("A"), env_var("B"), env_var("C", type=str)), - on_partial=None, + on_partial=Factory(list), ) monkeypatch.setenv("a_a", "hi") - assert a.get() is None - assert a_pos.get() is None + assert a.get() == [] + assert a_pos.get() == [] @a @@ -373,6 +411,23 @@ def test_schema_discard(monkeypatch): assert a_pos.get() == ("hi",) +def test_schema_discard_from_factory(monkeypatch): + a = env_var( + "a_", + type=SimpleNamespace, + args={ + "a": env_var("A", type=str), + "b": env_var("B", type=bool, default=Factory(lambda: discard)), + "c": env_var("C", type=str), + }, + ) + + monkeypatch.setenv("a_a", "hi") + monkeypatch.setenv("a_c", "blue") + + assert a.get() == SimpleNamespace(a="hi", c="blue") + + @a def test_infer_everything(A, monkeypatch): a = env_var("a_", type=A, args={"a": inferred_env_var(), "b": inferred_env_var(), "c": inferred_env_var(type=str)}) diff --git a/tests/unittests/test_single_var.py b/tests/unittests/test_single_var.py index 5271dc9..761399b 100644 --- a/tests/unittests/test_single_var.py +++ b/tests/unittests/test_single_var.py @@ -3,7 +3,7 @@ from pytest import mark, raises -from envolved import EnvVar, MissingEnvError, env_var +from envolved import EnvVar, Factory, MissingEnvError, env_var def test_get_int(monkeypatch): @@ -45,6 +45,15 @@ def test_default(monkeypatch): assert t.get() is ... +def test_default_factory(monkeypatch): + monkeypatch.delenv("t", raising=False) + t = env_var("t", type=str, default=Factory(list)) + t0 = t.get() + t1 = t.get() + assert t0 == t1 == [] + assert t0 is not t1 + + def test_missing(monkeypatch): monkeypatch.delenv("t", raising=False) t = env_var("t", type=str) @@ -168,3 +177,18 @@ def test_no_strip(monkeypatch): a: EnvVar[int] = env_var("a", type=len, strip_whitespaces=False) monkeypatch.setenv("a", " \thi ") assert a.get() == 7 + + +def test_get_with_args(monkeypatch): + a = env_var("a", type=lambda x, mul: x * mul) + monkeypatch.setenv("a", "na") + assert a.get(mul=5) == "nanananana" + with raises(TypeError): + a.get() + + +def test_get_with_args_optional(monkeypatch): + a = env_var("a", type=lambda x, mul=1: x * mul) + monkeypatch.setenv("a", "na") + assert a.get(mul=5) == "nanananana" + assert a.get() == "na"