From 5ffc34e86ccd4fd6e511df283c58062ed3ad52fa Mon Sep 17 00:00:00 2001 From: Ben Avrahami Date: Tue, 2 Jan 2024 02:32:34 +0200 Subject: [PATCH] ready for 1.2.0 (#10) * code is working * doc done * . --------- Co-authored-by: Ben Avrahami --- CHANGELOG.md | 16 +- docs/basevar.rst | 202 ---------- docs/constants.rst | 10 +- docs/cookbook.rst | 49 ++- docs/describing.rst | 50 ++- docs/envvar.rst | 227 ++++++++++- docs/index.rst | 1 - docs/infer.rst | 12 +- docs/string_parsing.rst | 8 +- docs/testing_utilities.rst | 6 +- envolved/__init__.py | 17 +- envolved/_version.py | 2 +- envolved/basevar.py | 342 +--------------- envolved/describe.py | 35 -- envolved/describe/__init__.py | 58 +++ envolved/describe/flat.py | 116 ++++++ envolved/describe/nested.py | 116 ++++++ envolved/describe/util.py | 34 ++ envolved/envvar.py | 377 +++++++++++++++++- envolved/infer_env_var.py | 79 +--- envolved/parsers.py | 16 +- pyproject.toml | 5 +- tests/unittests/test_describe.py | 29 +- tests/unittests/test_describe_flat_grouped.py | 146 +++++++ tests/unittests/test_describe_flat_sorted.py | 277 +++++++++++++ tests/unittests/test_describe_multi.py | 180 +++++++++ tests/unittests/test_mock.py | 2 +- tests/unittests/test_parsers.py | 22 +- tests/unittests/test_schema.py | 18 +- 29 files changed, 1732 insertions(+), 720 deletions(-) delete mode 100644 docs/basevar.rst delete mode 100644 envolved/describe.py create mode 100644 envolved/describe/__init__.py create mode 100644 envolved/describe/flat.py create mode 100644 envolved/describe/nested.py create mode 100644 envolved/describe/util.py create mode 100644 tests/unittests/test_describe_flat_grouped.py create mode 100644 tests/unittests/test_describe_flat_sorted.py create mode 100644 tests/unittests/test_describe_multi.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b02e198..c86ad85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,21 @@ # envolved Changelog -## Next +## 1.2.0 +### Added +* new argument `strip_items` for `CollectionParser`. +* new arguments `strip_keys` and `strip_values` for `CollectionParser.pairwise_delimited`. +* `missing`, `as_default`, `no_patch`, and `discard` consts are now available in the `envolved` namespace. +* envvar descriptions can now also be a sequence of strings to denote multiple paragraphs. +* many new options for describing env vars +* 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. +### Fixed +* the default `case_sensitive` value for `inferred_env_var`s is now `False` instead of `True`. +* envvars create with `with_prefix` are now correctly added to the description +* calling `describe_env_vars` without any envvars defined no longer raises an error ### Docs * changed documentation theme with furo +### Deprecations +* usage of the `basevar` and `infer_env_var` modules is deprecated +* usage of the `envvar` function to create inferred envvars is deprecated ## 1.1.2 ### Fixed * changed type of `args` to be an invariant `Mapping` instead of a `dict` diff --git a/docs/basevar.rst b/docs/basevar.rst deleted file mode 100644 index c4bc5a9..0000000 --- a/docs/basevar.rst +++ /dev/null @@ -1,202 +0,0 @@ -EnvVar Classes -============================= - -.. module:: basevar - -.. class:: EnvVar - - This is the base class for all environment variables. - - .. attribute:: default - :type: T | missing | discard - - 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 - value will not be used as an argument to parent :class:`SchemaEnvVars `. - - .. attribute:: description - :type: str | None - - A description of the environment variable. Used when :ref:`describing:Describing Environment Variables`. - - .. attribute:: monkeypatch - :type: T | missing | no_patch | discard - - If set to anything other than :attr:`no_patch`, then the environment variable will be monkeypatched. Any call to - :meth:`get` will return the value of this attribute. If set to :attr:`missing`, then calling :meth:`get` will - raise an :exc:`MissingEnvError` (even if a default is set for the EnvVar). See :ref:`testing_utilities:Testing Utilities` for - usage examples. - - .. warning:: - - This method doesn't change the value within the environment. It only changes the value of the EnvVar. - - - .. method:: get()->T - - Return the value of the environment variable. Different subclasses handle this operation differently. - - - .. method:: validator(validator: collections.abc.Callable[[T], T]) -> collections.abc.Callable[[T], T] - - Add a validator to the environment variable. When an EnvVar's value is retrieved (using :meth:`get`), all its - validators will be called in the order they were added (each validator will be called with the previous - validator's return value). The result of the last validator will be the EnvVar's returned value. - - :param validator: A callable that will be added as a validator. - :return: The validator, to allow usage of this function as a decorator. - - .. code-block:: - :caption: Using validators to assert that an environment variable is valid. - - connection_timeout_ev = env_var('CONNECTION_TIMEOUT_SECONDS', type=int) - - @connection_timeout_ev.validator - def timeout_positive(value): - if value <= 0: - raise ValueError('Connection timeout must be positive') - return value - # getting the value of the environment variable will now raise an error if the value is not positive - - .. code-block:: - :caption: Using validators to mutate the value of an environment variable. - - title_ev = env_var('TITLE', type=str) - - @title_ev.validator - def title_capitalized(value): - return value.capitalize() - - # now the value of title_ev will always be capitalized - - .. warning:: - Even if the validator does not mutate the value, it should still return the original value. - - .. method:: with_prefix(prefix: str) -> EnvVar[T] - - Return a new EnvVar with the parameters but with a given prefix. This method can be used to re-use an env-var - schema to multiple env-vars. - - :param prefix: The prefix to use. - :return: A new EnvVar with the given prefix, of the same type as the envar being used. - - .. method:: patch(value: T | missing | discard) -> typing.ContextManager - - Create a context manager that will monkeypatch the EnvVar to the given value, and then restore the original - value when the context manager is exited. - - :param value: The value to set the environment variable to see :attr:`monkeypatch`. - - -.. class:: SingleEnvVar - - 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 - - The name of the environment variable. (read only) - - .. property:: type - :type: collections.abc.Callable[[str], T] - - The type of the environment variable. (read only) - - .. note:: - - This may not necessarily be equal to the ``type`` parameter the EnvVar was created with (see - :ref:`string_parsing:special parsers`). - - .. attribute:: case_sensitive - :type: bool - - If set to False, only case-exact environment variables will be considered. Defaults to True. - - .. warning:: - - This attribute has no effect on Windows, as all environment variables are always uppercase. - - .. attribute:: strip_whitespaces - :type: bool - - If set to ``True`` (as is the default), whitespaces will be stripped from the environment variable value before - it is processed. - -.. class:: SchemaEnvVar - - An :class:`EnvVar` subclass that interfaces with a multiple environment variables, combining them into a single - object. - - When the value is retrieved, all its :attr:`args` and :attr:`pos_args` are retrieved, and are then used as keyword variables on the - EnvVar's :attr:`type`. - - Users can also supply keyword arguments to the :meth:`get` method, which will be supplied to the :attr:`type` in addition/instead of - the child EnvVars. - - .. property:: type - :type: collections.abc.Callable[..., T] - - The factory callable that will be used to create the object. (read only) - - .. property:: args - :type: collections.abc.Mapping[str, EnvVar] - - The mapping of keyword arguments to :class:`EnvVar` objects. (read only) - - .. property:: pos_args - :type: typing.Sequence[EnvVar] - - The sequence of positional arguments to the :attr:`type` callable. (read only) - - .. attribute:: on_partial - :type: T | as_default | missing | discard - - 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). - - * If set to :data:`as_default`, the EnvVar's :attr:`~EnvVar.default` will be returned. - - .. note:: - - The EnvVar's :attr:`default` must not be :data:`missing` if this option is used. - - * If set to :data:`missing`, an :exc:`~exceptions.MissingEnvError` will be raised, even if the EnvVar's - :attr:`~EnvVar.default` is set. - * If set to a value, that value will be returned. - - .. method:: get(**kwargs)->T - - Return the value of the environment variable. The value will be created by calling the :attr:`type` callable - with the values of all the child EnvVars as keyword arguments, and the values of the ``kwargs`` parameter as - additional keyword arguments. - - :param kwargs: Additional keyword arguments to pass to the :attr:`type` callable. - :return: The value of the environment variable. - - .. code-block:: - :caption: Using SchemaEnvVar to create a class from multiple environment variables, with additional keyword arguments. - - from dataclasses import dataclass - - @dataclass - class User: - name: str - age: int - height: int - - user_ev = env_var("USER_", type=User, - args={'name': env_var('NAME', type=str), - 'age': env_var('AGE', type=int)}) - - 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. diff --git a/docs/constants.rst b/docs/constants.rst index 56a899f..d446d18 100644 --- a/docs/constants.rst +++ b/docs/constants.rst @@ -3,27 +3,27 @@ Constants Runtime constants for the envolved library -.. py:currentmodule:: basevar +.. py:currentmodule:: envvar .. py:data:: missing :type: object - Used to indicate that an EnvVar has no default value. Can also be used in :attr:`~basevar.SchemaEnvVar.on_partial` + Used to indicate that an EnvVar has no default value. Can also be used in :attr:`~envvar.SchemaEnvVar.on_partial` to specify that an error should be raised on partial environments. .. py:data:: as_default :type: object - Used in :attr:`~basevar.SchemaEnvVar.on_partial` to specify that the default should be returned on partial + Used in :attr:`~envvar.SchemaEnvVar.on_partial` to specify that the default should be returned on partial environments. .. py:data:: no_patch :type: object - Used in :attr:`~basevar.EnvVar.monkeypatch` to specify that the EnvVar should not be patched. + Used in :attr:`~envvar.EnvVar.monkeypatch` to specify that the EnvVar should not be patched. .. py:data:: discard :type: object - When returned by child env vars of :class:`~basevar.SchemaEnvVar`, the value, and argument, will be discarded. If + When returned by child env vars of :class:`~envvar.SchemaEnvVar`, the value, and argument, will be discarded. If a positional argument returns this value, all positional arguments after it will also be discarded. \ No newline at end of file diff --git a/docs/cookbook.rst b/docs/cookbook.rst index 724b430..cdd4f13 100644 --- a/docs/cookbook.rst +++ b/docs/cookbook.rst @@ -31,7 +31,7 @@ external changes to environment variables are not handled. Common Factories ----------------- -Here are some common types and factories to use when creating a :class:`~basevar.SchemaEnvVar` +Here are some common types and factories to use when creating a :class:`~envvar.SchemaEnvVar` * :class:`types.SimpleNamespace`: This will create a namespace with whatever arguments you pass to it. * :class:`typing.NamedTuple`: A quick and easy way to create an annotated named tuple. @@ -64,4 +64,49 @@ Here are some common types and factories to use when creating a :class:`~basevar 'y': inferred_env_var(), }) - # this will result in a dict that has ints for keys "x" and "y" \ No newline at end of file + # this will result in a dict that has ints for keys "x" and "y" + +Inferring Schema Parameter Names Without a Schema +-------------------------------------------------- + +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 +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={ + 'x': inferred_env_var(type=int, default=0), + '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 + + +Note a sticking point here, he 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={ + 'x': inferred_env_var(type=int), # <-- this code will raise an exception + }) + +.. note:: Why is this the behaviour? + + 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 + 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 + 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, + ehan it might be 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={ + 'x': inferred_env_var(type=int, default=missing), + '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 diff --git a/docs/describing.rst b/docs/describing.rst index 8153b8c..fac141c 100644 --- a/docs/describing.rst +++ b/docs/describing.rst @@ -14,7 +14,7 @@ Another feature of envolved is the ability to describe all EnvVars. 'level': env_var('_LEVEL', type=int, default=20), }) - print('\n'.describe_env_vars()) + print('\n'.join(describe_env_vars())) # OUTPUT: # BACKLOG_SIZE: Backlog size @@ -33,7 +33,7 @@ Another feature of envolved is the ability to describe all EnvVars. .. function:: describe_env_vars(**kwargs)->List[str] Returns a list of string lines that describe all the EnvVars. All keyword arguments are passed to - :class:`textwrap.wrap` to wrap the lines. + :func:`textwrap.wrap` to wrap the lines. .. note:: @@ -70,3 +70,49 @@ In some cases it is useful to exclude some EnvVars from the description. This ca of EnvVar names to EnvVars. :return: `env_vars`, to allow for piping. +.. class:: EnvVarsDescription(env_vars: collections.abc.Iterable[EnvVar] | None) + + A class that allows for more fine-grained control over the description of EnvVars. + + :param env_vars: A collection of EnvVars to describe. If None, all alive EnvVars will be described. If the collection + includes two EnvVars, one which is a parent of the other, only the parent will be described. + + .. method:: flat()->FlatEnvVarsDescription + + Returns a flat description of the EnvVars. + + .. method:: nested()->NestedEnvVarsDescription + + Returns a nested description of the EnvVars. + +.. class:: FlatEnvVarsDescription + + A flat representation of the EnvVars description. Only single-environment variable EnvVars (or single-environment variable children of envars) will be described. + + .. method:: wrap_sorted(*, unique_keys: bool = True, **kwargs)->List[str] + + Returns a list of string lines that describe the EnvVars, sorted by their environment variable key. + + :param unique_keys: If True, and if any EnvVars share an environment variable key, they will be combined into one description. + :param kwargs: Keyword arguments to pass to :func:`textwrap.wrap`. + :return: A list of string lines that describe the EnvVars. + + .. method:: wrap_grouped(**kwargs)->List[str] + + 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. + + :param kwargs: Keyword arguments to pass to :func:`textwrap.wrap`. + :return: A list of string lines that describe the EnvVars. + +.. class:: NestedEnvVarsDescription + + A nested representation of the EnvVars description. All EnvVars will be described. + + .. method:: wrap(indent_increment: str = ..., **kwargs)->List[str] + + Returns a list of string lines that describe the EnvVars in a tree structure. + + :param indent_increment: The string to use to increment the indentation of the description with each level. If not provided, + will use the keyword argument "subsequent_indent" from :func:`textwrap.wrap`, if provided. Otherwise, will use a single space. + :param kwargs: Keyword arguments to pass to :func:`textwrap.wrap`. + :return: A list of string lines that describe the EnvVars. \ No newline at end of file diff --git a/docs/envvar.rst b/docs/envvar.rst index e53330b..4c9a077 100644 --- a/docs/envvar.rst +++ b/docs/envvar.rst @@ -1,34 +1,34 @@ -Creating EnvVars +EnvVars ========================================================= .. module:: envvar .. function:: env_var(key: str, *, type: collections.abc.Callable[[str], T],\ default: T | missing | discard = missing,\ - description: str | None = None, \ + 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) -> basevar.SingleEnvVar[T] + case_sensitive: bool = False, strip_whitespaces: bool = True) -> envvar.SingleEnvVar[T] Creates an EnvVar that reads from one environment variable. :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:`~basevar.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 set to :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 - after it is created with :func:`~basevar.EnvVar.validator`. + after it is created with :func:`~envvar.EnvVar.validator`. :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, \ - args: dict[str, basevar.EnvVar | InferEnvVar] = ..., \ - pos_args: collections.base.Sequence[basevar.EnvVar | InferEnvVar] = ..., \ - description: str | None = None,\ + args: dict[str, envvar.EnvVar | InferEnvVar] = ..., \ + pos_args: collections.base.Sequence[envvar.EnvVar | InferEnvVar] = ..., \ + description: str | collections.abc.Sequence[str] | None = None,\ validators: collections.abc.Iterable[collections.abc.Callable[[T], T]] = (), \ - on_partial: T | missing | as_default | discard = missing) -> basevar.SchemaEnvVar[T]: + on_partial: T | missing | as_default | discard = missing) -> envvar.SchemaEnvVar[T]: :noindex: Creates an EnvVar that reads from multiple environment variables. @@ -36,8 +36,8 @@ Creating 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:`~basevar.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 set to :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. @@ -45,6 +45,205 @@ Creating EnvVars :ref:`inferred ` in some cases. :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:`~basevar.EnvVar.validator`. + after it is created with :func:`~envvar.EnvVar.validator`. :param on_partial: The value to use if the EnvVar is partially missing. See - :attr:`~basevar.SchemaEnvVar.on_partial`. \ No newline at end of file + :attr:`~envvar.SchemaEnvVar.on_partial`. + +.. class:: EnvVar + + This is the base class for all environment variables. + + .. attribute:: default + :type: T | missing | discard + + 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 + value will not be used as an argument to parent :class:`SchemaEnvVars `. + + .. attribute:: description + :type: str| collections.abc.Sequence[str] | None + + A description of the environment variable. Used when :ref:`describing:Describing Environment Variables`. Can also be + set to a sequence of strings, in which case each string will be a separate paragraph in the description. + + .. attribute:: monkeypatch + :type: T | missing | no_patch | discard + + If set to anything other than :attr:`no_patch`, then the environment variable will be monkeypatched. Any call to + :meth:`get` will return the value of this attribute. If set to :attr:`missing`, then calling :meth:`get` will + raise an :exc:`MissingEnvError` (even if a default is set for the EnvVar). See :ref:`testing_utilities:Testing Utilities` for + usage examples. + + .. warning:: + + This method doesn't change the value within the environment. It only changes the value of the EnvVar. + + + .. method:: get()->T + + Return the value of the environment variable. Different subclasses handle this operation differently. + + + .. method:: validator(validator: collections.abc.Callable[[T], T]) -> collections.abc.Callable[[T], T] + + Add a validator to the environment variable. When an EnvVar's value is retrieved (using :meth:`get`), all its + validators will be called in the order they were added (each validator will be called with the previous + validator's return value). The result of the last validator will be the EnvVar's returned value. + + :param validator: A callable that will be added as a validator. + :return: The validator, to allow usage of this function as a decorator. + + .. code-block:: + :caption: Using validators to assert that an environment variable is valid. + + connection_timeout_ev = env_var('CONNECTION_TIMEOUT_SECONDS', type=int) + + @connection_timeout_ev.validator + def timeout_positive(value): + if value <= 0: + raise ValueError('Connection timeout must be positive') + return value + # getting the value of the environment variable will now raise an error if the value is not positive + + .. code-block:: + :caption: Using validators to mutate the value of an environment variable. + + title_ev = env_var('TITLE', type=str) + + @title_ev.validator + def title_capitalized(value): + return value.capitalize() + + # now the value of title_ev will always be capitalized + + .. warning:: + Even if the validator does not mutate the value, it should still return the original value. + + .. method:: with_prefix(prefix: str) -> EnvVar[T] + + Return a new EnvVar with the parameters but with a given prefix. This method can be used to re-use an env-var + schema to multiple env-vars. + + :param prefix: The prefix to use. + :return: A new EnvVar with the given prefix, of the same type as the envar being used. + + .. method:: patch(value: T | missing | discard) -> typing.ContextManager + + Create a context manager that will monkeypatch the EnvVar to the given value, and then restore the original + value when the context manager is exited. + + :param value: The value to set the environment variable to see :attr:`monkeypatch`. + + +.. class:: SingleEnvVar + + 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 + + The name of the environment variable. (read only) + + .. property:: type + :type: collections.abc.Callable[[str], T] + + The type of the environment variable. (read only) + + .. note:: + + This may not necessarily be equal to the ``type`` parameter the EnvVar was created with (see + :ref:`string_parsing:special parsers`). + + .. attribute:: case_sensitive + :type: bool + + If set to False, only case-exact environment variables will be considered. Defaults to True. + + .. warning:: + + This attribute has no effect on Windows, as all environment variables are always uppercase. + + .. attribute:: strip_whitespaces + :type: bool + + If set to ``True`` (as is the default), whitespaces will be stripped from the environment variable value before + it is processed. + +.. class:: SchemaEnvVar + + An :class:`EnvVar` subclass that interfaces with a multiple environment variables, combining them into a single + object. + + When the value is retrieved, all its :attr:`args` and :attr:`pos_args` are retrieved, and are then used as keyword variables on the + EnvVar's :attr:`type`. + + Users can also supply keyword arguments to the :meth:`get` method, which will be supplied to the :attr:`type` in addition/instead of + the child EnvVars. + + .. property:: type + :type: collections.abc.Callable[..., T] + + The factory callable that will be used to create the object. (read only) + + .. property:: args + :type: collections.abc.Mapping[str, EnvVar] + + The mapping of keyword arguments to :class:`EnvVar` objects. (read only) + + .. property:: pos_args + :type: typing.Sequence[EnvVar] + + The sequence of positional arguments to the :attr:`type` callable. (read only) + + .. attribute:: on_partial + :type: T | as_default | missing | discard + + 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). + + * If set to :data:`as_default`, the EnvVar's :attr:`~EnvVar.default` will be returned. + + .. note:: + + The EnvVar's :attr:`default` must not be :data:`missing` if this option is used. + + * If set to :data:`missing`, an :exc:`~exceptions.MissingEnvError` will be raised, even if the EnvVar's + :attr:`~EnvVar.default` is set. + * If set to a value, that value will be returned. + + .. method:: get(**kwargs)->T + + Return the value of the environment variable. The value will be created by calling the :attr:`type` callable + with the values of all the child EnvVars as keyword arguments, and the values of the ``kwargs`` parameter as + additional keyword arguments. + + :param kwargs: Additional keyword arguments to pass to the :attr:`type` callable. + :return: The value of the environment variable. + + .. code-block:: + :caption: Using SchemaEnvVar to create a class from multiple environment variables, with additional keyword arguments. + + from dataclasses import dataclass + + @dataclass + class User: + name: str + age: int + height: int + + user_ev = env_var("USER_", type=User, + args={'name': env_var('NAME', type=str), + 'age': env_var('AGE', type=int)}) + + 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. diff --git a/docs/index.rst b/docs/index.rst index 20d9225..2a132ad 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -6,7 +6,6 @@ Welcome to envolved's documentation! introduction envvar - basevar constants exceptions infer diff --git a/docs/infer.rst b/docs/infer.rst index daeb153..ce84faa 100644 --- a/docs/infer.rst +++ b/docs/infer.rst @@ -1,7 +1,7 @@ Inferred Env Vars ==================================== -.. module:: infer_env_var +.. currentmodule:: envvar For schema environment variables, you can sometimes skip specifying the type, default, or even name of single environment variable args by using :func:`inferred_env_var`. When this happens, the missing values of the envvar are @@ -32,8 +32,8 @@ Type inference can be performed for the following factory types: .. function:: inferred_env_var(key: str | None = None, *, type: Callable[[str], T] = ...,\ default: T | missing | as_default | discard = as_default, **kwargs) -> InferEnvVar - Create an inferred env var that can be filled in by a parent :class:`~basevar.SchemaEnvVar` factory's type - annotation to create a :class:`~basevar.SingleEnvVar`. + Create an inferred env var that can be filled in by a parent :class:`~envvar.SchemaEnvVar` factory's type + annotation to create a :class:`~envvar.SingleEnvVar`. :param key: The environment variable key to use. If unspecified, the name of the argument key will be used (the argument must be keyword argument in this case). @@ -45,12 +45,12 @@ Type inference can be performed for the following factory types: .. class:: InferEnvVar - An inference env var that will be converted to a :class:`~basevar.SingleEnvVar` by a parent - :class:`~basevar.SchemaEnvVar`. + An inference env var that will be converted to a :class:`~envvar.SingleEnvVar` by a parent + :class:`~envvar.SchemaEnvVar`. .. method:: validator(validator: collections.abc.Callable[[T], T]) -> collections.abc.Callable[[T], T] - Add a validator to the resulting :class:`~basevar.SingleEnvVar`. + Add a validator to the resulting :class:`~envvar.SingleEnvVar`. .. py:currentmodule:: envvar diff --git a/docs/string_parsing.rst b/docs/string_parsing.rst index 1a3f37f..cd2babd 100644 --- a/docs/string_parsing.rst +++ b/docs/string_parsing.rst @@ -68,7 +68,7 @@ Utility Parsers .. class:: CollectionParser(delimiter: str | typing.Pattern, inner_parser: ParserInput[E], \ output_type: collections.abc.Callable[[collections.abc.Iterator[E]], G] = list, \ - opener: str | typing.Pattern = '', closer: str | typing.Pattern = '') + opener: str | typing.Pattern = '', closer: str | typing.Pattern = '', *, strip: bool = True) A parser to translate a delimited string to a collection of values. @@ -78,6 +78,7 @@ Utility Parsers :param output_type: The type to use to aggregate the parsed items to a collection defaults to list. :param opener: If set, specifies a string or pattern that should be at the beginning of the delimited string. :param closer: If set, specifies a string or pattern that should be at the end of the delimited string. + :param strip: Whether or not to strip whitespaces from the beginning and end of each item. .. code-block:: @@ -93,7 +94,7 @@ Utility Parsers value_type: ParserInput[V] | collections.abc.Mapping[K, ParserInput[V]], \ output_type: collections.abc.Callable[[collections.abc.Iterable[tuple[K,V]]], G] = ..., *, \ key_first: bool = True, opener: str | typing.Pattern = '', \ - closer: str | typing.Pattern = '') -> CollectionParser[G] + closer: str | typing.Pattern = '', strip: bool = True, strip_keys: bool = True, strip_values: bool = True) -> CollectionParser[G] A factory method to create a :class:`CollectionParser` where each item is a delimited key-value pair. @@ -110,6 +111,9 @@ Utility Parsers as the key. If set to ``False``, the second element in each key-value pair will be interpreted as the key. :param opener: Acts the same as in the :class:`constructor `. :param closer: Acts the same as in the :class:`constructor `. + :param strip: Acts the same as in the :class:`constructor `. + :param strip_keys: Whether or not to strip whitespaces from the beginning and end of each key in every pair. + :param strip_values: Whether or not to strip whitespaces from the beginning and end of each value in every pair. .. code-block:: :caption: Using CollectionParser.pair_wise_delimited to parse arbitrary HTTP headers. diff --git a/docs/testing_utilities.rst b/docs/testing_utilities.rst index 3d622da..5b12191 100644 --- a/docs/testing_utilities.rst +++ b/docs/testing_utilities.rst @@ -1,8 +1,8 @@ Testing Utilities ===================== -Envolved makes testing environment variables easy with the :attr:`~basevar.EnvVar.monkeypatch` attribute and -:meth:`~basevar.EnvVar.patch` context method. They allows you to set a predefined EnvVar value and then restore the +Envolved makes testing environment variables easy with the :attr:`~envvar.EnvVar.monkeypatch` attribute and +:meth:`~envvar.EnvVar.patch` context method. They allows you to set a predefined EnvVar value and then restore the original value when the test is finished. .. code-block:: @@ -113,7 +113,7 @@ EnvVar that was patched. assert cache_time_2_ev.get() == 10 # this will fail too In cases where an environment variable is retrieved from different EnvVars, or with libraries other than envolved, we'll -have to set the environment directly, by using the :attr:`basevar.SingleEnvVar.key` property to get the actual +have to set the environment directly, by using the :attr:`envvar.SingleEnvVar.key` property to get the actual environment name. In pytest we can use the monkeypatch fixture to do this. .. code-block:: diff --git a/envolved/__init__.py b/envolved/__init__.py index 44120c2..eeae457 100644 --- a/envolved/__init__.py +++ b/envolved/__init__.py @@ -1,8 +1,17 @@ from envolved._version import __version__ -from envolved.basevar import EnvVar, as_default from envolved.describe import describe_env_vars -from envolved.envvar import env_var +from envolved.envvar import EnvVar, as_default, discard, env_var, inferred_env_var, missing, no_patch from envolved.exceptions import MissingEnvError -from envolved.infer_env_var import inferred_env_var -__all__ = ["__version__", "env_var", "EnvVar", "MissingEnvError", "describe_env_vars", "as_default", "inferred_env_var"] +__all__ = [ + "__version__", + "EnvVar", + "MissingEnvError", + "as_default", + "describe_env_vars", + "discard", + "env_var", + "inferred_env_var", + "missing", + "no_patch", +] diff --git a/envolved/_version.py b/envolved/_version.py index 72f26f5..c68196d 100644 --- a/envolved/_version.py +++ b/envolved/_version.py @@ -1 +1 @@ -__version__ = "1.1.2" +__version__ = "1.2.0" diff --git a/envolved/basevar.py b/envolved/basevar.py index f3d90e7..a0a30cd 100644 --- a/envolved/basevar.py +++ b/envolved/basevar.py @@ -1,330 +1,12 @@ -from __future__ import annotations - -from abc import ABC, abstractmethod -from contextlib import contextmanager -from dataclasses import dataclass -from enum import Enum, auto -from itertools import chain -from textwrap import wrap -from types import MappingProxyType -from typing import ( - Any, - Callable, - Generic, - Iterable, - Iterator, - List, - Mapping, - Optional, - Sequence, - Type, - TypeVar, - Union, -) - -from envolved.envparser import CaseInsensitiveAmbiguityError, env_parser -from envolved.exceptions import MissingEnvError, SkipDefault -from envolved.parsers import Parser, parser - -T = TypeVar("T") -Self = TypeVar("Self") - - -class Missing(Enum): - missing = auto() - - -missing = Missing.missing - - -class AsDefault(Enum): - as_default = auto() - - -as_default = AsDefault.as_default - - -class NoPatch(Enum): - no_patch = auto() - - -no_patch = NoPatch.no_patch - - -class Discard(Enum): - discard = auto() - - -discard = Discard.discard - - -@dataclass -class _EnvVarResult(Generic[T]): - value: T | Discard - exists: bool - - -@dataclass(order=True) -class _Description: - min_key: str - lines: List[str] - - @classmethod - def combine(cls, descs: List[_Description], preamble: List[str], allow_blanks: bool = False) -> _Description: - descs = sorted(descs) - lines = list(preamble) - for d in descs: - if allow_blanks: - part_lines: Iterable[str] = d.lines - else: - part_lines = (line for line in d.lines if line and not line.isspace()) - lines.extend(part_lines) - return cls(descs[0].min_key, lines) - - -def unwrap_validator(func: Callable[[T], T]) -> Callable[[T], T]: - if isinstance(func, staticmethod): - func = func.__func__ - return func - - -class EnvVar(Generic[T], ABC): - def __init__( - self, - default: Union[T, Missing, Discard], - description: Optional[str], - validators: Iterable[Callable[[T], T]] = (), - ): - self._validators: List[Callable[[T], T]] = [unwrap_validator(v) for v in validators] - self.default = default - 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: - if self.monkeypatch is not no_patch: - if self.monkeypatch is missing: - raise MissingEnvError(self.describe()) - return self.monkeypatch # type: ignore[return-value] - return self._get_validated(**kwargs).value # type: ignore[return-value] - - def validator(self, validator: Callable[[T], T]) -> EnvVar[T]: - self._validators.append(validator) - return self - - def _get_validated(self, **kwargs: Any) -> _EnvVarResult[T]: - try: - value = self._get(**kwargs) - except SkipDefault as sd: - raise sd.args[0] from None - except MissingEnvError as mee: - if self.default is missing: - raise mee - return _EnvVarResult(self.default, exists=False) - for validator in self._validators: - value = validator(value) - return _EnvVarResult(value, exists=True) - - @abstractmethod - def _get(self, **kwargs: Any) -> T: - pass - - @abstractmethod - def describe(self, **text_wrapper_args: Any) -> _Description: - pass - - @abstractmethod - def with_prefix(self: Self, prefix: str) -> Self: - pass - - @abstractmethod - def _get_children(self) -> Iterable[EnvVar]: - pass - - @contextmanager - def patch(self, value: Union[T, Missing, Discard]) -> Iterator[None]: - previous = self.monkeypatch - self.monkeypatch = value - try: - yield - finally: - self.monkeypatch = previous - - -class SingleEnvVar(EnvVar[T]): - def __init__( - self, - key: str, - default: Union[T, Missing, Discard] = missing, - *, - type: Union[Type[T], Parser[T]], - description: Optional[str] = None, - case_sensitive: bool = False, - strip_whitespaces: bool = True, - validators: Iterable[Callable[[T], T]] = (), - ): - super().__init__(default, description, validators) - self._key = key - self._type = parser(type) - self.case_sensitive = case_sensitive - self.strip_whitespaces = strip_whitespaces - - @property - def key(self) -> str: - return self._key - - @property - 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: - raise MissingEnvError(self._key) from err - except CaseInsensitiveAmbiguityError as cia: - raise RuntimeError(f"environment error: cannot choose between environment variables {cia.args[0]}") from cia - - if self.strip_whitespaces: - raw_value = raw_value.strip() - return self.type(raw_value) - - def describe(self, **text_wrapper_args: Any) -> _Description: - key = self._key - if not self.case_sensitive: - key = key.upper() - - if self.description: - desc = " ".join(self.description.strip().split()) - description_text = f"{key}: {desc}" - else: - description_text = key - return _Description(key, wrap(description_text, **text_wrapper_args)) - - def with_prefix(self, prefix: str) -> SingleEnvVar[T]: - return SingleEnvVar( - prefix + self._key, - self.default, - type=self.type, - description=self.description, - case_sensitive=self.case_sensitive, - strip_whitespaces=self.strip_whitespaces, - validators=self._validators, - ) - - def _get_children(self) -> Iterable[EnvVar[Any]]: - return () - - -class SchemaEnvVar(EnvVar[T]): - def __init__( - self, - keys: Mapping[str, EnvVar[Any]], - default: Union[T, Missing, Discard] = missing, - *, - type: Callable[..., T], - description: Optional[str] = None, - on_partial: Union[T, Missing, AsDefault, Discard] = missing, - validators: Iterable[Callable[[T], T]] = (), - pos_args: Sequence[EnvVar[Any]] = (), - ): - super().__init__(default, description, validators) - self._args = keys - self._pos_args = pos_args - self._type = type - self.on_partial = on_partial - - @property - def type(self) -> Callable[..., T]: - return self._type - - @property - def args(self) -> Mapping[str, EnvVar[Any]]: - return MappingProxyType(self._args) - - @property - def pos_args(self) -> Sequence[EnvVar[Any]]: - return tuple(self._pos_args) - - @property - def on_partial(self) -> Union[T, Missing, AsDefault, Discard]: - return self._on_partial - - @on_partial.setter - def on_partial(self, value: Union[T, Missing, AsDefault, Discard]): - 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 - any_exist = False - errs: List[MissingEnvError] = [] - for env_var in self._pos_args: - try: - result = env_var._get_validated() # noqa: SLF001 - except MissingEnvError as e: # noqa: PERF203 - errs.append(e) - else: - if result.value is discard: - break - pos_values.append(result.value) - if result.exists: - any_exist = True - for key, env_var in self._args.items(): - if key in kw_values: - # key could be in kwargs because it was passed in as a positional argument, if so, we don't want to - # overwrite it - continue - try: - result = env_var._get_validated() # noqa: SLF001 - except MissingEnvError as e: - errs.append(e) - else: - if result.value is not discard: - kw_values[key] = result.value - if result.exists: - any_exist = True - - if errs: - if self.on_partial is not as_default and any_exist: - if self.on_partial is missing: - raise SkipDefault(errs[0]) - return self.on_partial # type: ignore[return-value] - raise errs[0] - return self._type(*pos_values, **kw_values) - - def describe(self, **text_wrapper_args: Any) -> _Description: - if self.description: - desc = " ".join(self.description.strip().split()) + ":" - preamble = wrap(desc, **text_wrapper_args) - else: - # if there's no title, we need to add a newline to make the output look nice - preamble = [""] - inner_wrapper_args = dict(text_wrapper_args) - inner_wrapper_args["initial_indent"] = "\t" + inner_wrapper_args.get("initial_indent", "") - inner_wrapper_args["subsequent_indent"] = "\t" + inner_wrapper_args.get("subsequent_indent", "") - parts = [env_var.describe(**inner_wrapper_args) for env_var in chain(self._pos_args, self._args.values())] - return _Description.combine(parts, preamble) - - def with_prefix(self, prefix: str) -> SchemaEnvVar[T]: - return SchemaEnvVar( - {k: v.with_prefix(prefix) for k, v in self._args.items()}, - self.default, - type=self._type, - description=self.description, - on_partial=self.on_partial, - validators=self._validators, - pos_args=tuple(v.with_prefix(prefix) for v in self._pos_args), - ) - - def _get_children(self) -> Iterable[EnvVar[Any]]: - return chain(self._args.values(), self._pos_args) +# this module is to preserved backwards compatibility +from envolved.envvar import EnvVar, SchemaEnvVar, SingleEnvVar, as_default, discard, missing, no_patch + +__all__ = [ + "EnvVar", + "as_default", + "discard", + "missing", + "no_patch", + "SchemaEnvVar", + "SingleEnvVar", +] diff --git a/envolved/describe.py b/envolved/describe.py deleted file mode 100644 index 98a86ad..0000000 --- a/envolved/describe.py +++ /dev/null @@ -1,35 +0,0 @@ -from typing import Any, Iterable, List, Mapping, TypeVar, Union - -from envolved.basevar import _Description -from envolved.envvar import EnvVar, top_level_env_vars -from envolved.infer_env_var import InferEnvVar - - -def describe_env_vars(**kwargs: Any) -> List[str]: - descriptions: List[_Description] = sorted(env_var.describe(**kwargs) for env_var in top_level_env_vars) - return _Description.combine(descriptions, [], allow_blanks=True).lines - - -T = TypeVar( - "T", - bound=Union[EnvVar, InferEnvVar, Iterable[Union[EnvVar, InferEnvVar]], Mapping[Any, Union[EnvVar, InferEnvVar]]], -) - - -def exclude_from_description(to_exclude: T) -> T: - global top_level_env_vars # noqa: PLW0603 - - if isinstance(to_exclude, EnvVar): - evs = frozenset((to_exclude,)) - elif isinstance(to_exclude, InferEnvVar): - evs = frozenset() - elif isinstance(to_exclude, Mapping): - evs = frozenset(to_exclude.values()) - elif isinstance(to_exclude, Iterable): - evs = frozenset(to_exclude) - else: - raise TypeError(f"cannot exclude unrecognized type {type(to_exclude)!r}") - - top_level_env_vars -= evs - - return to_exclude diff --git a/envolved/describe/__init__.py b/envolved/describe/__init__.py new file mode 100644 index 0000000..8ae3bf8 --- /dev/null +++ b/envolved/describe/__init__.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from typing import Any, Iterable, List, Mapping, Set, TypeVar, Union + +from envolved.describe.flat import FlatEnvVarsDescription +from envolved.describe.nested import NestedEnvVarsDescription, RootNestedDescription +from envolved.envvar import EnvVar, InferEnvVar, top_level_env_vars + + +def describe_env_vars(**kwargs: Any) -> List[str]: + ret = EnvVarsDescription().nested().wrap(**kwargs) + assert isinstance(ret, list) + return ret + + +class EnvVarsDescription: + def __init__(self, env_vars: Iterable[EnvVar] | None = None) -> None: + self.env_var_roots = set() + children: Set[EnvVar] = set() + + if env_vars is None: + env_vars = top_level_env_vars + for env_var in env_vars: + self.env_var_roots.add(env_var) + children.update(env_var._get_descendants()) + # remove any children we found along the way + self.env_var_roots -= children + + def flat(self) -> FlatEnvVarsDescription: + return FlatEnvVarsDescription.from_envvars(self.env_var_roots) + + def nested(self) -> NestedEnvVarsDescription: + return RootNestedDescription.from_envvars(self.env_var_roots) + + +T = TypeVar( + "T", + bound=Union[EnvVar, InferEnvVar, Iterable[Union[EnvVar, InferEnvVar]], Mapping[Any, Union[EnvVar, InferEnvVar]]], +) + + +def exclude_from_description(to_exclude: T) -> T: + global top_level_env_vars # noqa: PLW0603 + + if isinstance(to_exclude, EnvVar): + evs = frozenset((to_exclude,)) + elif isinstance(to_exclude, InferEnvVar): + evs = frozenset() + elif isinstance(to_exclude, Mapping): + evs = frozenset(to_exclude.values()) + elif isinstance(to_exclude, Iterable): + evs = frozenset(to_exclude) + else: + raise TypeError(f"cannot exclude unrecognized type {type(to_exclude)!r}") + + top_level_env_vars -= evs + + return to_exclude diff --git a/envolved/describe/flat.py b/envolved/describe/flat.py new file mode 100644 index 0000000..95c3308 --- /dev/null +++ b/envolved/describe/flat.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +from dataclasses import dataclass +from itertools import chain, groupby +from typing import Any, Iterable, List, Tuple +from warnings import warn + +from envolved.describe.util import prefix_description, wrap_description as wrap +from envolved.envvar import Description, EnvVar, SingleEnvVar + + +@dataclass +class SingleEnvVarDescription: + path: Iterable[str] + env_var: SingleEnvVar + + @property + def key(self) -> str: + key = self.env_var.key + if not self.env_var.case_sensitive: + key = key.upper() + + return key + + def wrap(self, **kwargs: Any) -> Iterable[str]: + text: Description + if self.env_var.description is None: + text = self.key + else: + text = prefix_description(self.key + ": ", self.env_var.description) + subsequent_indent_increment = len(self.key) + 2 + kwargs["subsequent_indent"] = kwargs.get("subsequent_indent", "") + " " * subsequent_indent_increment + return wrap(text, **kwargs) + + @classmethod + def from_envvar(cls, path: Tuple[str, ...], env_var: EnvVar) -> Iterable[SingleEnvVarDescription]: + if isinstance(env_var, SingleEnvVar): + yield cls( + ( + *path, + env_var.key.upper(), + ), + env_var, + ) + else: + min_child = min( + (e.key.upper() for e in env_var._get_descendants() if isinstance(e, SingleEnvVar)), + default=None, + ) + if min_child is not None: + path = (*path, min_child) + for child in env_var._get_children(): + yield from cls.from_envvar(path, child) + + @classmethod + def collate(cls, instances: Iterable[SingleEnvVarDescription]) -> SingleEnvVarDescription: + # collate multiple descriptions of the same env var + assert len({i.env_var.key for i in instances}) == 1 + # in case of conflict we choose arbitrarily, with a warning + # first we prefer an env var with a description, if one exists + with_description = [] + without_description = [] + for instance in instances: + if instance.env_var.description is None: + without_description.append(instance) + else: + with_description.append(instance) + + if with_description: + if len(with_description) > 1 and len({i.env_var.description for i in with_description}) > 1: + warn( + f"multiple descriptions for env var {with_description[0].env_var.key!r}, choosing arbitrarily", + stacklevel=2, + ) + return with_description[0] + else: + return without_description[0] + + +class FlatEnvVarsDescription: + def __init__(self, env_var_descriptions: Iterable[SingleEnvVarDescription]) -> None: + self.env_var_descriptions = env_var_descriptions + + def wrap_sorted(self, *, unique_keys: bool = True, **kwargs: Any) -> Iterable[str]: + def key(i: SingleEnvVarDescription) -> str: + return i.key.upper() + + env_var_descriptions = sorted(self.env_var_descriptions, key=key) + + ret: List[str] = [] + + for _, group in groupby(env_var_descriptions, key=key): + g = tuple(group) + if len(g) > 1 and unique_keys: + ret.extend(SingleEnvVarDescription.collate(g).wrap(**kwargs)) + else: + ret.extend(chain.from_iterable(i.wrap(**kwargs) for i in g)) + + return ret + + def wrap_grouped(self, **kwargs: Any) -> Iterable[str]: + env_var_descriptions = sorted(self.env_var_descriptions, key=lambda i: (i.path, i.env_var.key)) + ret = list( + chain.from_iterable( + chain.from_iterable(d.wrap(**kwargs) for d in group) + for _, group in groupby(env_var_descriptions, key=lambda i: i.path) + ) + ) + return ret + + @classmethod + def from_envvars(cls, env_vars: Iterable[EnvVar]) -> FlatEnvVarsDescription: + env_var_descriptions = list( + chain.from_iterable(SingleEnvVarDescription.from_envvar((), env_var) for env_var in env_vars) + ) + return cls(env_var_descriptions) diff --git a/envolved/describe/nested.py b/envolved/describe/nested.py new file mode 100644 index 0000000..a2d9064 --- /dev/null +++ b/envolved/describe/nested.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Any, Iterable, Optional, Tuple + +from envolved.describe.util import prefix_description, suffix_description, wrap_description as wrap +from envolved.envvar import Description, EnvVar, SchemaEnvVar, SingleEnvVar + + +class NestedEnvVarsDescription(ABC): + @abstractmethod + def get_path(self) -> Tuple[str, ...]: + ... + + @abstractmethod + def wrap(self, *, indent_increment: str, **kwargs: Any) -> Iterable[str]: + ... + + @classmethod + def from_env_var(cls, path: Tuple[str, ...], env_var: EnvVar) -> NestedEnvVarsDescription: + if isinstance(env_var, SingleEnvVar): + path = (*path, env_var.key.upper()) + return SingleNestedDescription(path, env_var) + else: + assert isinstance(env_var, SchemaEnvVar) + min_child = min( + (e.key.upper() for e in env_var._get_descendants() if isinstance(e, SingleEnvVar)), + default=None, + ) + if min_child is not None: + path = (*path, min_child) + children = [cls.from_env_var(path, child) for child in env_var._get_children()] + return SchemaNestedDescription(path, env_var, children) + + +@dataclass +class SingleNestedDescription(NestedEnvVarsDescription): + path: Tuple[str, ...] + env_var: SingleEnvVar + + @property + def key(self) -> str: + key = self.env_var.key + if not self.env_var.case_sensitive: + key = key.upper() + + return key + + def get_path(self) -> Tuple[str, ...]: + return self.path + + def wrap(self, *, indent_increment: str, **kwargs: Any) -> Iterable[str]: + text: Description + if self.env_var.description is None: + text = self.key + else: + prefix = self.key + ": " + text = prefix_description(prefix, self.env_var.description) + subsequent_indent_increment = len(prefix) + kwargs["subsequent_indent"] = kwargs.get("subsequent_indent", "") + " " * subsequent_indent_increment + return wrap(text, **kwargs) + + +class NestedDescriptionWithChildren(NestedEnvVarsDescription): + children: Iterable[NestedEnvVarsDescription] + + @abstractmethod + def title(self) -> Description | None: + ... + + def wrap(self, *, indent_increment: str, **kwargs: Any) -> Iterable[str]: + title = self.title() + if title is not None: + yield from wrap(title, **kwargs) + kwargs["subsequent_indent"] = kwargs.get("subsequent_indent", "") + indent_increment + kwargs["initial_indent"] = kwargs.get("initial_indent", "") + indent_increment + for child in sorted(self.children, key=lambda i: i.get_path()): + yield from child.wrap(indent_increment=indent_increment, **kwargs) + + +@dataclass +class SchemaNestedDescription(NestedDescriptionWithChildren): + path: Tuple[str, ...] + env_var: SchemaEnvVar + children: Iterable[NestedEnvVarsDescription] + + def get_path(self) -> Tuple[str, ...]: + return self.path + + def title(self) -> Description | None: + if self.env_var.description is None: + return "" + else: + return suffix_description(self.env_var.description, ":") + + +@dataclass +class RootNestedDescription(NestedDescriptionWithChildren): + children: Iterable[NestedEnvVarsDescription] + + def get_path(self) -> Tuple[str, ...]: + return () + + def title(self) -> Description | None: + return None + + @classmethod + def from_envvars(cls, env_vars: Iterable[EnvVar]) -> RootNestedDescription: + return cls([NestedEnvVarsDescription.from_env_var((), env_var) for env_var in env_vars]) + + def wrap(self, *, indent_increment: Optional[str] = None, **kwargs: Any) -> Iterable[str]: + if indent_increment is None: + indent_increment = kwargs.get("subsequent_indent", " ") + assert isinstance(indent_increment, str) + return list(super().wrap(indent_increment=indent_increment, **kwargs)) diff --git a/envolved/describe/util.py b/envolved/describe/util.py new file mode 100644 index 0000000..024446d --- /dev/null +++ b/envolved/describe/util.py @@ -0,0 +1,34 @@ +from textwrap import wrap +from typing import Any, Iterable + +from envolved.envvar import Description + + +def wrap_description(description: Description, **kwargs: Any) -> Iterable[str]: + if isinstance(description, str): + yield from wrap(description, **kwargs) + else: + is_first_paragraph = True + for line in description: + yield from wrap(line, **kwargs) + if is_first_paragraph: + kwargs["initial_indent"] = kwargs.get("subsequent_indent", "") + is_first_paragraph = False + + +def prefix_description(prefix: str, description: Description) -> Description: + if isinstance(description, str): + return prefix + description.lstrip() + elif description: + return [prefix + description[0].lstrip(), *description[1:]] + else: + return prefix + + +def suffix_description(description: Description, suffix: str) -> Description: + if isinstance(description, str): + return description.rstrip() + suffix + elif description: + return [*description[:-1], description[-1].rstrip() + suffix] + else: + return suffix diff --git a/envolved/envvar.py b/envolved/envvar.py index d16099b..0d77073 100644 --- a/envolved/envvar.py +++ b/envolved/envvar.py @@ -1,15 +1,24 @@ from __future__ import annotations +from abc import ABC, abstractmethod +from contextlib import contextmanager +from dataclasses import dataclass +from enum import Enum, auto +from itertools import chain +from types import MappingProxyType from typing import ( Any, Callable, Dict, + Generic, Iterable, + Iterator, List, Mapping, MutableSet, Optional, Sequence, + Type, TypeVar, Union, overload, @@ -17,27 +26,293 @@ from _weakrefset import WeakSet -from envolved.basevar import AsDefault, Discard, EnvVar, Missing, SchemaEnvVar, SingleEnvVar, missing -from envolved.factory_spec import FactorySpec, factory_spec -from envolved.infer_env_var import AutoTypedEnvVar, InferEnvVar, inferred_env_var -from envolved.parsers import ParserInput +from envolved.envparser import CaseInsensitiveAmbiguityError, env_parser +from envolved.exceptions import MissingEnvError, SkipDefault +from envolved.factory_spec import FactoryArgSpec, FactorySpec, factory_spec, missing as factory_spec_missing +from envolved.parsers import Parser, ParserInput, parser T = TypeVar("T") +Self = TypeVar("Self") K = TypeVar("K") V = TypeVar("V") +class Missing(Enum): + missing = auto() + + +missing = Missing.missing + + +class AsDefault(Enum): + as_default = auto() + + +as_default = AsDefault.as_default + + +class NoPatch(Enum): + no_patch = auto() + + +no_patch = NoPatch.no_patch + + +class Discard(Enum): + discard = auto() + + +discard = Discard.discard + +Description = Union[str, Sequence[str]] + + +@dataclass +class _EnvVarResult(Generic[T]): + value: T | Discard + exists: bool + + +def unwrap_validator(func: Callable[[T], T]) -> Callable[[T], T]: + if isinstance(func, staticmethod): + func = func.__func__ + return func + + +class EnvVar(Generic[T], ABC): + def __init__( + self, + default: Union[T, Missing, Discard], + description: Optional[Description], + validators: Iterable[Callable[[T], T]] = (), + ): + self._validators: List[Callable[[T], T]] = [unwrap_validator(v) for v in validators] + self.default = default + 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: + if self.monkeypatch is not no_patch: + if self.monkeypatch is missing: + key = getattr(self, "key", self) + raise MissingEnvError(key) + return self.monkeypatch # type: ignore[return-value] + return self._get_validated(**kwargs).value # type: ignore[return-value] + + def validator(self, validator: Callable[[T], T]) -> EnvVar[T]: + self._validators.append(validator) + return self + + def _get_validated(self, **kwargs: Any) -> _EnvVarResult[T]: + try: + value = self._get(**kwargs) + except SkipDefault as sd: + raise sd.args[0] from None + except MissingEnvError as mee: + if self.default is missing: + raise mee + return _EnvVarResult(self.default, exists=False) + for validator in self._validators: + value = validator(value) + return _EnvVarResult(value, exists=True) + + @abstractmethod + def _get(self, **kwargs: Any) -> T: + pass + + @abstractmethod + def with_prefix(self: Self, prefix: str) -> Self: + pass + + @abstractmethod + def _get_children(self) -> Iterable[EnvVar]: + pass + + def _get_descendants(self) -> Iterable[EnvVar]: + for child in self._get_children(): + yield child + yield from child._get_descendants() + + @contextmanager + def patch(self, value: Union[T, Missing, Discard]) -> Iterator[None]: + previous = self.monkeypatch + self.monkeypatch = value + try: + yield + finally: + self.monkeypatch = previous + + +class SingleEnvVar(EnvVar[T]): + def __init__( + self, + key: str, + default: Union[T, Missing, Discard] = missing, + *, + type: Union[Type[T], Parser[T]], + description: Optional[Description] = None, + case_sensitive: bool = False, + strip_whitespaces: bool = True, + validators: Iterable[Callable[[T], T]] = (), + ): + super().__init__(default, description, validators) + self._key = key + self._type = parser(type) + self.case_sensitive = case_sensitive + self.strip_whitespaces = strip_whitespaces + + @property + def key(self) -> str: + return self._key + + @property + 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: + raise MissingEnvError(self._key) from err + except CaseInsensitiveAmbiguityError as cia: + raise RuntimeError(f"environment error: cannot choose between environment variables {cia.args[0]}") from cia + + if self.strip_whitespaces: + raw_value = raw_value.strip() + return self.type(raw_value) + + def with_prefix(self, prefix: str) -> SingleEnvVar[T]: + return register_env_var( + SingleEnvVar( + prefix + self._key, + self.default, + type=self.type, + description=self.description, + case_sensitive=self.case_sensitive, + strip_whitespaces=self.strip_whitespaces, + validators=self._validators, + ) + ) + + def _get_children(self) -> Iterable[EnvVar[Any]]: + return () + + +class SchemaEnvVar(EnvVar[T]): + def __init__( + self, + keys: Mapping[str, EnvVar[Any]], + default: Union[T, Missing, Discard] = missing, + *, + type: Callable[..., T], + description: Optional[Description] = None, + on_partial: Union[T, Missing, AsDefault, Discard] = missing, + validators: Iterable[Callable[[T], T]] = (), + pos_args: Sequence[EnvVar[Any]] = (), + ): + super().__init__(default, description, validators) + self._args = keys + self._pos_args = pos_args + self._type = type + self.on_partial = on_partial + + @property + def type(self) -> Callable[..., T]: + return self._type + + @property + def args(self) -> Mapping[str, EnvVar[Any]]: + return MappingProxyType(self._args) + + @property + def pos_args(self) -> Sequence[EnvVar[Any]]: + return tuple(self._pos_args) + + @property + def on_partial(self) -> Union[T, Missing, AsDefault, Discard]: + return self._on_partial + + @on_partial.setter + def on_partial(self, value: Union[T, Missing, AsDefault, Discard]): + 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 + any_exist = False + errs: List[MissingEnvError] = [] + for env_var in self._pos_args: + try: + result = env_var._get_validated() # noqa: SLF001 + except MissingEnvError as e: # noqa: PERF203 + errs.append(e) + else: + if result.value is discard: + break + pos_values.append(result.value) + if result.exists: + any_exist = True + for key, env_var in self._args.items(): + if key in kw_values: + # key could be in kwargs because it was passed in as a positional argument, if so, we don't want to + # overwrite it + continue + try: + result = env_var._get_validated() # noqa: SLF001 + except MissingEnvError as e: + errs.append(e) + else: + if result.value is not discard: + kw_values[key] = result.value + if result.exists: + any_exist = True + + if errs: + if self.on_partial is not as_default and any_exist: + if self.on_partial is missing: + raise SkipDefault(errs[0]) + return self.on_partial # type: ignore[return-value] + raise errs[0] + return self._type(*pos_values, **kw_values) + + def with_prefix(self, prefix: str) -> SchemaEnvVar[T]: + return register_env_var( + SchemaEnvVar( + {k: v.with_prefix(prefix) for k, v in self._args.items()}, + self.default, + type=self._type, + description=self.description, + on_partial=self.on_partial, + validators=self._validators, + pos_args=tuple(v.with_prefix(prefix) for v in self._pos_args), + ) + ) + + def _get_children(self) -> Iterable[EnvVar[Any]]: + return chain(self._args.values(), self._pos_args) + + @overload def env_var( key: str, *, default: Union[T, Missing, AsDefault, Discard] = missing, - description: Optional[str] = None, + description: Optional[Description] = None, validators: Iterable[Callable[[T], T]] = (), case_sensitive: bool = False, strip_whitespaces: bool = True, -) -> AutoTypedEnvVar[T]: +) -> InferEnvVar[T]: pass @@ -47,7 +322,7 @@ def env_var( *, type: ParserInput[T], default: Union[T, Missing, Discard] = missing, - description: Optional[str] = None, + description: Optional[Description] = None, validators: Iterable[Callable[[T], T]] = (), case_sensitive: bool = False, strip_whitespaces: bool = True, @@ -63,7 +338,7 @@ def env_var( default: Union[T, Missing, Discard] = missing, pos_args: Sequence[Union[EnvVar[Any], InferEnvVar[Any]]], args: Mapping[str, Union[EnvVar[Any], InferEnvVar[Any]]] = {}, - description: Optional[str] = None, + description: Optional[Description] = None, validators: Iterable[Callable[[T], T]] = (), on_partial: Union[T, Missing, AsDefault, Discard] = missing, ) -> SchemaEnvVar[T]: @@ -78,7 +353,7 @@ def env_var( default: Union[T, Missing, Discard] = missing, pos_args: Sequence[Union[EnvVar[Any], InferEnvVar[Any]]] = (), args: Mapping[str, Union[EnvVar[Any], InferEnvVar[Any]]], - description: Optional[str] = None, + description: Optional[Description] = None, validators: Iterable[Callable[[T], T]] = (), on_partial: Union[T, Missing, AsDefault, Discard] = missing, ) -> SchemaEnvVar[T]: @@ -90,7 +365,7 @@ def env_var( # type: ignore[misc] *, type: Optional[ParserInput[T]] = None, default: Union[T, Missing, AsDefault, Discard] = missing, - description: Optional[str] = None, + description: Optional[Description] = None, validators: Iterable[Callable[[T], T]] = (), **kwargs: Any, ): @@ -123,8 +398,6 @@ def env_var( # type: ignore[misc] if factory_specs is None: factory_specs = factory_spec(type) kw_var_spec = factory_specs.keyword.get(k) - if kw_var_spec is None: - raise TypeError(f"No type hint found for parameter {k!r} in factory {type!r}") arg = v.with_spec(k, kw_var_spec) else: arg = v @@ -171,8 +444,82 @@ def env_var( # type: ignore[misc] def register_env_var(ev: EV) -> EV: - global top_level_env_vars # noqa: PLW0603 - top_level_env_vars.add(ev) - top_level_env_vars -= frozenset(ev._get_children()) # noqa: SLF001 return ev + + +class InferType(Enum): + infer_type = auto() + + +infer_type = InferType.infer_type + + +@dataclass +class InferEnvVar(Generic[T]): + key: Optional[str] + type: Any + default: Union[T, Missing, AsDefault, Discard] + description: Optional[Description] + validators: List[Callable[[T], T]] + case_sensitive: bool + strip_whitespaces: bool + + def with_spec(self, param_id: Union[str, int], spec: FactoryArgSpec | None) -> SingleEnvVar[T]: + key = self.key + if key is None: + if not isinstance(param_id, str): + raise ValueError(f"cannot infer key for positional parameter {param_id}, please specify a key") + key = param_id + + default: Union[T, Missing, Discard] + 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") + + if spec.default is factory_spec_missing: + default = missing + else: + default = spec.default + else: + default = self.default + + if self.type is infer_type: + if spec is None: + raise ValueError(f"cannot infer type for parameter {key}, parameter {param_id} not found in factory") + if spec.type is factory_spec_missing: + raise ValueError( + f"cannot infer type for parameter {key}, parameter {param_id} has no type hint in factory" + ) + ty = spec.type + else: + ty = self.type + + return register_env_var( + SingleEnvVar( + key=key, + default=default, + description=self.description, + validators=self.validators, + case_sensitive=self.case_sensitive, + strip_whitespaces=self.strip_whitespaces, + type=ty, + ) + ) + + def validator(self, func: Callable[[T], T]) -> Callable[[T], T]: + self.validators.append(func) + return func + + +def inferred_env_var( + key: Optional[str] = None, + *, + type: Union[ParserInput[T], InferType] = infer_type, + default: Union[T, Missing, AsDefault, Discard] = as_default, + description: Optional[Description] = None, + validators: Iterable[Callable[[T], T]] = (), + case_sensitive: bool = False, + strip_whitespaces: bool = True, +) -> InferEnvVar[T]: + return InferEnvVar(key, type, default, description, list(validators), case_sensitive, strip_whitespaces) diff --git a/envolved/infer_env_var.py b/envolved/infer_env_var.py index 6719306..063aad8 100644 --- a/envolved/infer_env_var.py +++ b/envolved/infer_env_var.py @@ -1,78 +1,7 @@ -from dataclasses import dataclass -from enum import Enum, auto -from typing import TYPE_CHECKING, Any, Callable, Generic, Iterable, List, NoReturn, Optional, TypeVar, Union +from envolved.envvar import InferEnvVar, inferred_env_var -from envolved.basevar import AsDefault, Discard, Missing, SingleEnvVar, as_default, missing -from envolved.factory_spec import FactoryArgSpec, missing as factory_spec_missing -from envolved.parsers import ParserInput +__all__ = ["InferEnvVar", "inferred_env_var"] -T = TypeVar("T") +# this module is to preserved backwards compatibility - -class InferType(Enum): - infer_type = auto() - - -infer_type = InferType.infer_type - - -@dataclass -class InferEnvVar(Generic[T]): - key: Optional[str] - type: Any - default: Union[T, Missing, AsDefault, Discard] - description: Optional[str] - validators: List[Callable[[T], T]] - case_sensitive: bool - strip_whitespaces: bool - - def with_spec(self, param_id: Union[str, int], spec: FactoryArgSpec) -> SingleEnvVar[T]: - key = self.key - if key is None: - if not isinstance(param_id, str): - raise ValueError(f"cannot infer key for positional parameter {param_id}, please specify a key") - key = param_id - - default = spec.default if self.default is as_default else self.default - if default is factory_spec_missing: - default = missing - - ty = spec.type if self.type is infer_type else self.type - if ty is factory_spec_missing: - raise ValueError(f"cannot infer type for parameter {key}, type not found in factory") - - return SingleEnvVar( - key=key, - default=default, - description=self.description, - validators=self.validators, - case_sensitive=self.case_sensitive, - strip_whitespaces=self.strip_whitespaces, - type=ty, - ) - - def validator(self, func: Callable[[T], T]) -> Callable[[T], T]: - self.validators.append(func) - return func - - -def inferred_env_var( - key: Optional[str] = None, - *, - type: Union[ParserInput[T], InferType] = infer_type, - default: Union[T, Missing, AsDefault, Discard] = as_default, - description: Optional[str] = None, - validators: Iterable[Callable[[T], T]] = (), - case_sensitive: bool = True, - strip_whitespaces: bool = True, -) -> InferEnvVar[T]: - return InferEnvVar(key, type, default, description, list(validators), case_sensitive, strip_whitespaces) - - -class AutoTypedEnvVar(InferEnvVar[T]): - if not TYPE_CHECKING: - - def get(self) -> NoReturn: - raise AttributeError( - "this env-var is auto-typed and cannot be accessed directly (did you forget to " "specify a type?)" - ) +AutoTypedEnvVar = InferEnvVar # alias for backwards compatibility diff --git a/envolved/parsers.py b/envolved/parsers.py index 5d6e8bb..56ceb7c 100644 --- a/envolved/parsers.py +++ b/envolved/parsers.py @@ -186,6 +186,8 @@ def __init__( output_type: Callable[[Iterator[E]], G] = list, # type: ignore[assignment] opener: Needle = empty_pattern, closer: Needle = empty_pattern, + *, + strip: bool = True, ): """ :param delimiter: The delimiter to split by. @@ -199,6 +201,7 @@ def __init__( self.output_type = output_type self.opener_pattern = needle_to_pattern(opener) self.closer_pattern = needle_to_pattern(closer) + self.strip = strip def __call__(self, x: str) -> G: opener_match = self.opener_pattern.match(x) @@ -220,7 +223,10 @@ def __call__(self, x: str) -> G: ) raw_elements[-1] = raw_elements[-1][: closer_match.start()] - elements = (self.inner_parser(r.strip()) for r in raw_elements) + raw_items = iter(raw_elements) + if self.strip: + raw_items = (r.strip() for r in raw_items) + elements = (self.inner_parser(r) for r in raw_items) return self.output_type(elements) @classmethod @@ -233,6 +239,8 @@ def pair_wise_delimited( output_type: Callable[[Iterator[Tuple[K, V]]], G] = _duplicate_avoiding_dict, # type: ignore[assignment] *, key_first: bool = True, + strip_keys: bool = True, + strip_values: bool = True, **kwargs: Any, ) -> Parser[G]: """ @@ -260,13 +268,17 @@ def get_value_parser(key: K) -> Parser[V]: def get_value_parser(key: K) -> Parser[V]: return _value_parser - def combined_parser(s: str) -> Any: + def combined_parser(s: str) -> Tuple[K, V]: split = key_value_delimiter.split(s, maxsplit=2) if len(split) != 2: raise ValueError(f"expecting key-value pair, got {s}") k, v = split if not key_first: k, v = v, k + if strip_keys: + k = k.strip() + if strip_values: + v = v.strip() key = key_parser(k) value = get_value_parser(key)(v) return key, value diff --git a/pyproject.toml b/pyproject.toml index 8a399e9..954de1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "envolved" -version = "1.1.2" +version = "1.2.0" description = "" authors = ["ben avrahami "] license = "MIT" @@ -65,6 +65,9 @@ combine-as-imports=true [tool.ruff.flake8-annotations] suppress-none-returning = true +[tool.ruff.lint.flake8-self] +ignore-names = ["_get_descendants", "_get_children"] + [tool.ruff.flake8-pytest-style] raises-require-match-for = [] diff --git a/tests/unittests/test_describe.py b/tests/unittests/test_describe.py index 5608818..06137a2 100644 --- a/tests/unittests/test_describe.py +++ b/tests/unittests/test_describe.py @@ -1,3 +1,4 @@ +from textwrap import dedent from types import SimpleNamespace from envolved import env_var @@ -25,10 +26,12 @@ def test_describe(): "q_", type=SimpleNamespace, args=point_args, - description=""" - point Q - next line - """, + description=dedent( + """ + point Q + next line + """ + ).strip(), ) b = env_var("b", type=str) # noqa: F841 @@ -45,17 +48,15 @@ def test_describe(): e_f_g = env_var("e", type=int), env_var("f", type=int), env_var("g", type=int) exclude_from_description(e_f_g) - assert describe_env_vars(initial_indent="", subsequent_indent="\t") == [ + assert describe_env_vars() == [ "A: full description of A", "B", - "", - "\tP_X: x coordinate", - "\tP_Y: y coordinate", + " P_X: x coordinate", + " P_Y: y coordinate", "point Q next line:", - "\tQ_X: x coordinate", - "\tQ_Y: y coordinate", - "", - "\tT_N", - "\t\tT_P_X: x coordinate", - "\t\tT_P_Y: y coordinate", + " Q_X: x coordinate", + " Q_Y: y coordinate", + " T_N", + " T_P_X: x coordinate", + " T_P_Y: y coordinate", ] diff --git a/tests/unittests/test_describe_flat_grouped.py b/tests/unittests/test_describe_flat_grouped.py new file mode 100644 index 0000000..19bf268 --- /dev/null +++ b/tests/unittests/test_describe_flat_grouped.py @@ -0,0 +1,146 @@ +from textwrap import dedent +from types import SimpleNamespace + +from pytest import mark + +from envolved import env_var +from envolved.describe import EnvVarsDescription + + +def test_describe_single_flat(): + d = EnvVarsDescription( + [ + env_var("a", type=int, description="Apple"), + env_var("b", type=int, description="Bee"), + ] + ).flat() + + assert d.wrap_grouped() == [ + "A: Apple", + "B: Bee", + ] + + +def test_describe_single_sensitive(): + d = EnvVarsDescription( + [ + env_var("a", type=int, description="Apple"), + env_var("b", type=int, description="Bee", case_sensitive=True), + env_var("c", type=int), + ] + ).flat() + + assert d.wrap_grouped() == ["A: Apple", "b: Bee", "C"] + + +def test_describe_single_flat_multiline(): + d = EnvVarsDescription( + [ + env_var( + "a", + type=int, + description=dedent( + """ + Apple + Banana + """ + ) + .strip() + .splitlines(), + ), + env_var("b", type=int, description="Bee"), + ] + ).flat() + + assert d.wrap_grouped() == [ + "A: Apple", + " Banana", + "B: Bee", + ] + + +def test_describe_single_flat_long(): + d = EnvVarsDescription( + [ + env_var("a", type=int, description="I'm a yankee doodle dandy, a yankee doodle do or die"), + env_var("b", type=int, description="Bee"), + ] + ).flat() + + assert d.wrap_grouped(width=20) == [ + "A: I'm a yankee", + " doodle dandy, a", + " yankee doodle do", + " or die", + "B: Bee", + ] + + +@mark.parametrize( + "schema_desc", + [ + None, + "Cee", + ["Cee", "Fee", "Ree"], + "I'm a yankee doodle dandy, a yankee doodle do or die", + ], +) +def test_describe_multi_flat(schema_desc): + d = EnvVarsDescription( + [ + env_var("a", type=int, description="Apple"), + env_var("d", type=int, description="Bee"), + env_var( + "c_", + type=SimpleNamespace, + args={ + "x": env_var("x", type=int, description="x coordinate"), + "y": env_var("y", type=int, description="y coordinate"), + }, + description=schema_desc, + ), + ] + ).flat() + + assert d.wrap_grouped() == [ + "A: Apple", + "C_X: x coordinate", + "C_Y: y coordinate", + "D: Bee", + ] + + +@mark.parametrize( + "schema_desc", + [ + None, + "Cee", + ["Cee", "Fee", "Ree"], + "I'm a yankee doodle dandy, a yankee doodle do or die", + ], +) +def test_describe_multi_flat_dragup(schema_desc): + d = EnvVarsDescription( + [ + env_var("B", type=int, description="Apple"), + env_var("d", type=int, description="Bee"), + env_var( + "", + type=SimpleNamespace, + args={ + "a": env_var("a", type=int, description="A coordinate"), + "x": env_var("c_x", type=int, description="x coordinate"), + "y": env_var("c_y", type=int, description="y coordinate"), + }, + description=schema_desc, + ), + ] + ).flat() + + assert d.wrap_grouped() == [ + "A: A coordinate", + "C_X: x coordinate", + "C_Y: y coordinate", + "B: Apple", + "D: Bee", + ] diff --git a/tests/unittests/test_describe_flat_sorted.py b/tests/unittests/test_describe_flat_sorted.py new file mode 100644 index 0000000..c372dfc --- /dev/null +++ b/tests/unittests/test_describe_flat_sorted.py @@ -0,0 +1,277 @@ +from textwrap import dedent +from types import SimpleNamespace + +from pytest import mark + +from envolved import env_var +from envolved.describe import EnvVarsDescription + + +def test_describe_single_flat(): + d = EnvVarsDescription( + [ + env_var("a", type=int, description="Apple"), + env_var("b", type=int, description="Bee"), + ] + ).flat() + + assert d.wrap_sorted() == [ + "A: Apple", + "B: Bee", + ] + + +def test_describe_single_sensitive(): + d = EnvVarsDescription( + [ + env_var("a", type=int, description="Apple"), + env_var("b", type=int, description="Bee", case_sensitive=True), + env_var("c", type=int), + ] + ).flat() + + assert d.wrap_sorted() == ["A: Apple", "b: Bee", "C"] + + +def test_describe_single_flat_multiline(): + d = EnvVarsDescription( + [ + env_var( + "a", + type=int, + description=dedent( + """ + Apple + Banana + """ + ) + .strip() + .splitlines(), + ), + env_var("b", type=int, description="Bee"), + ] + ).flat() + + assert d.wrap_sorted() == [ + "A: Apple", + " Banana", + "B: Bee", + ] + + +def test_describe_single_flat_long(): + d = EnvVarsDescription( + [ + env_var("a", type=int, description="I'm a yankee doodle dandy, a yankee doodle do or die"), + env_var("b", type=int, description="Bee"), + ] + ).flat() + + assert d.wrap_sorted(width=20) == [ + "A: I'm a yankee", + " doodle dandy, a", + " yankee doodle do", + " or die", + "B: Bee", + ] + + +@mark.parametrize( + "schema_desc", + [ + None, + "Cee", + ["Cee", "Fee", "Ree"], + "I'm a yankee doodle dandy, a yankee doodle do or die", + ], +) +def test_describe_multi_flat(schema_desc): + d = EnvVarsDescription( + [ + env_var("a", type=int, description="Apple"), + env_var("d", type=int, description="Bee"), + env_var( + "c_", + type=SimpleNamespace, + args={ + "x": env_var("x", type=int, description="x coordinate"), + "y": env_var("y", type=int, description="y coordinate"), + }, + description=schema_desc, + ), + ] + ).flat() + + assert d.wrap_sorted() == [ + "A: Apple", + "C_X: x coordinate", + "C_Y: y coordinate", + "D: Bee", + ] + + +def test_describe_flat_collision(): + d = EnvVarsDescription( + [ + env_var( + "", + type=SimpleNamespace, + args={ + "x": env_var("x", type=int, description="x coordinate"), + "y": env_var("y", type=int, description="y coordinate"), + }, + ), + env_var("x", type=int), + env_var("z", type=int, description="z coordinate"), + ] + ).flat() + + assert d.wrap_sorted() == [ + "X: x coordinate", + "Y: y coordinate", + "Z: z coordinate", + ] + + +def test_describe_flat_cousins(): + d = EnvVarsDescription( + [ + env_var( + "", + type=SimpleNamespace, + args={ + "x": env_var("x", type=int, description="x coordinate"), + "y": env_var("y", type=int, description="y coordinate"), + }, + ), + env_var( + "", + type=SimpleNamespace, + args={ + "x": env_var("x", type=int), + "a": env_var("a", type=int), + }, + ), + env_var("z", type=int, description="z coordinate"), + ] + ).flat() + + assert d.wrap_sorted() == [ + "A", + "X: x coordinate", + "Y: y coordinate", + "Z: z coordinate", + ] + + +def test_describe_flat_collision_nodesc(): + d = EnvVarsDescription( + [ + env_var( + "", + type=SimpleNamespace, + args={ + "x": env_var("x", type=int), + "y": env_var("y", type=int, description="y coordinate"), + }, + ), + env_var("x", type=int), + env_var("z", type=int, description="z coordinate"), + ] + ).flat() + + assert d.wrap_sorted() == [ + "X", + "Y: y coordinate", + "Z: z coordinate", + ] + + +def test_describe_flat_collision_warning(): + d = EnvVarsDescription( + [ + env_var( + "", + type=SimpleNamespace, + args={ + "x": env_var("x", type=int, description="ex"), + }, + ), + env_var("x", type=int, description="x coordinate"), + ] + ).flat() + + (x_desc,) = d.wrap_sorted() + + assert x_desc in [ + "X: ex", + "X: x coordinate", + ] + + +def test_describe_flat_collision_dup(): + d = EnvVarsDescription( + [ + env_var( + "", + type=SimpleNamespace, + args={ + "x": env_var("x", type=int, description="x coordinate"), + "y": env_var("y", type=int, description="y coordinate"), + }, + ), + env_var("x", type=int), + env_var("z", type=int, description="z coordinate"), + ] + ).flat() + + assert sorted(d.wrap_sorted(unique_keys=False)) == [ + "X", + "X: x coordinate", + "Y: y coordinate", + "Z: z coordinate", + ] + + +def test_describe_flat_collision_nodesc_dup(): + d = EnvVarsDescription( + [ + env_var( + "", + type=SimpleNamespace, + args={ + "x": env_var("x", type=int), + "y": env_var("y", type=int, description="y coordinate"), + }, + ), + env_var("x", type=int), + env_var("z", type=int, description="z coordinate"), + ] + ).flat() + + assert d.wrap_sorted(unique_keys=False) == [ + "X", + "X", + "Y: y coordinate", + "Z: z coordinate", + ] + + +def test_describe_flat_collision_warning_dup(): + d = EnvVarsDescription( + [ + env_var( + "", + type=SimpleNamespace, + args={ + "x": env_var("x", type=int, description="ex"), + }, + ), + env_var("x", type=int, description="x coordinate"), + ] + ).flat() + + assert sorted(d.wrap_sorted(unique_keys=False)) == [ + "X: ex", + "X: x coordinate", + ] diff --git a/tests/unittests/test_describe_multi.py b/tests/unittests/test_describe_multi.py new file mode 100644 index 0000000..f19b9e9 --- /dev/null +++ b/tests/unittests/test_describe_multi.py @@ -0,0 +1,180 @@ +from textwrap import dedent +from types import SimpleNamespace + +from envolved import env_var +from envolved.describe import EnvVarsDescription + + +def test_describe_single_nested(): + d = EnvVarsDescription( + [ + env_var("a", type=int, description="Apple"), + env_var("b", type=int, description="Bee"), + ] + ).nested() + + assert d.wrap() == [ + "A: Apple", + "B: Bee", + ] + + +def test_describe_single_sensitive(): + d = EnvVarsDescription( + [ + env_var("a", type=int, description="Apple"), + env_var("b", type=int, description="Bee", case_sensitive=True), + env_var("c", type=int), + ] + ).nested() + + assert d.wrap() == ["A: Apple", "b: Bee", "C"] + + +def test_describe_single_nested_multiline(): + d = EnvVarsDescription( + [ + env_var( + "a", + type=int, + description=dedent( + """ + Apple + Banana + """ + ) + .strip() + .splitlines(), + ), + env_var("b", type=int, description="Bee"), + ] + ).nested() + + assert d.wrap() == [ + "A: Apple", + " Banana", + "B: Bee", + ] + + +def test_describe_single_nested_long(): + d = EnvVarsDescription( + [ + env_var("a", type=int, description="I'm a yankee doodle dandy, a yankee doodle do or die"), + env_var("b", type=int, description="Bee"), + ] + ).nested() + + assert d.wrap(width=20) == [ + "A: I'm a yankee", + " doodle dandy, a", + " yankee doodle do", + " or die", + "B: Bee", + ] + + +def test_describe_multi_nested(): + d = EnvVarsDescription( + [ + env_var("a", type=int, description="Apple"), + env_var("d", type=int, description="Bee"), + env_var( + "c_", + type=SimpleNamespace, + args={ + "x": env_var("x", type=int, description="x coordinate"), + "y": env_var("y", type=int, description="y coordinate"), + }, + description="Cee", + ), + ] + ).nested() + + assert d.wrap() == [ + "A: Apple", + "Cee:", + " C_X: x coordinate", + " C_Y: y coordinate", + "D: Bee", + ] + + +def test_describe_multi_nested_multiline(): + d = EnvVarsDescription( + [ + env_var("a", type=int, description="Apple"), + env_var("d", type=int, description="Bee"), + env_var( + "c_", + type=SimpleNamespace, + args={ + "x": env_var("x", type=int, description="x coordinate"), + "y": env_var("y", type=int, description="y coordinate"), + }, + description=["Cee", "Fee", "Ree"], + ), + ] + ).nested() + + assert d.wrap() == [ + "A: Apple", + "Cee", + "Fee", + "Ree:", + " C_X: x coordinate", + " C_Y: y coordinate", + "D: Bee", + ] + + +def test_describe_multi_nested_long(): + d = EnvVarsDescription( + [ + env_var("a", type=int, description="Apple"), + env_var("d", type=int, description="Bee"), + env_var( + "c_", + type=SimpleNamespace, + args={ + "x": env_var("x", type=int, description="x coordinate"), + "y": env_var("y", type=int, description="y coordinate"), + }, + description="I'm a yankee doodle dandy, a yankee doodle do or die", + ), + ] + ).nested() + + assert d.wrap(width=20) == [ + "A: Apple", + "I'm a yankee doodle", + "dandy, a yankee", + "doodle do or die:", + " C_X: x coordinate", + " C_Y: y coordinate", + "D: Bee", + ] + + +def test_describe_multi_nested_nodescription(): + d = EnvVarsDescription( + [ + env_var("a", type=int, description="Apple"), + env_var("d", type=int, description="Bee"), + env_var( + "c_", + type=SimpleNamespace, + args={ + "x": env_var("x", type=int, description="x coordinate"), + "y": env_var("y", type=int, description="y coordinate"), + }, + ), + ] + ).nested() + + assert d.wrap() == [ + "A: Apple", + " C_X: x coordinate", + " C_Y: y coordinate", + "D: Bee", + ] diff --git a/tests/unittests/test_mock.py b/tests/unittests/test_mock.py index 143abd7..6274dbc 100644 --- a/tests/unittests/test_mock.py +++ b/tests/unittests/test_mock.py @@ -5,8 +5,8 @@ from pytest import MonkeyPatch, fixture, raises from envolved import MissingEnvError, env_var -from envolved.basevar import missing from envolved.describe import exclude_from_description +from envolved.envvar import missing def test_monkeypatch_setenviron(monkeypatch): diff --git a/tests/unittests/test_parsers.py b/tests/unittests/test_parsers.py index 36a062e..e8b7047 100644 --- a/tests/unittests/test_parsers.py +++ b/tests/unittests/test_parsers.py @@ -39,9 +39,29 @@ def test_delimited_str(): assert p("1.3.4.3") == [1, 3, 4, 3] +def test_delimited_strip(): + p = CollectionParser(".", int) + assert p("1.3 .4 .3") == [1, 3, 4, 3] + + +def test_delimited_no_strip(): + p = CollectionParser(".", len, strip=False) + assert p("1.3 .4 .3") == [1, 2, 2, 1] + + def test_mapping(): p = CollectionParser.pair_wise_delimited(";", "=", str, int) - assert p("a=1;b=2;c=3") == {"a": 1, "b": 2, "c": 3} + assert p("a = 1; b=2 ;c=3") == {"a": 1, "b": 2, "c": 3} + + +def test_mapping_nostrip_keys(): + p = CollectionParser.pair_wise_delimited(";", "=", str, int, strip_keys=False) + assert p("a =1; b=2 ;c= 3") == {"a ": 1, "b": 2, "c": 3} + + +def test_mapping_nostrip_values(): + p = CollectionParser.pair_wise_delimited(";", "=", str, len, strip_values=False) + assert p("a =1; b=2 ;c= 3") == {"a": 1, "b": 1, "c": 2} def test_repeating(): diff --git a/tests/unittests/test_schema.py b/tests/unittests/test_schema.py index 51d9dfd..bc1e457 100644 --- a/tests/unittests/test_schema.py +++ b/tests/unittests/test_schema.py @@ -5,9 +5,8 @@ from pytest import mark, raises, skip -from envolved import MissingEnvError, as_default, env_var -from envolved.basevar import discard -from envolved.infer_env_var import inferred_env_var +from envolved import MissingEnvError, as_default, env_var, missing +from envolved.envvar import discard, inferred_env_var class NamedTupleClass(NamedTuple): @@ -455,3 +454,16 @@ def validate(d): monkeypatch.setenv("sb", "foo") assert s.get(c="bla", d=12) == {"a": 12, "b": "foo", "c": "bla", "d": 24} + + +def test_infer_nameonly(monkeypatch): + a = env_var( + "a_", + type=SimpleNamespace, + args={"a": inferred_env_var(type=str, default=missing), "b": inferred_env_var(type=str, default=missing)}, + ) + + monkeypatch.setenv("a_a", "hi") + monkeypatch.setenv("a_b", "36") + + assert a.get() == SimpleNamespace(a="hi", b="36")