diff --git a/CHANGELOG.md b/CHANGELOG.md index 87b940b..a7f9a89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,10 @@ # envolved Changelog +## 1.7.0 +### Added +* `inferred_env_var` can now infer additional parameter data from the `Env` annotation metadata. +* `SchemaEnvVars` can now be initialized with `args=...` to use all keyword arguments with `Env` annotations as arguments. +### Fixed +* Fixed type annotations for `LookupParser.case_insensitive` ## 1.6.0 ### Added * added `AbsoluteName` to create env vars with names that never have a prefix diff --git a/docs/conf.py b/docs/conf.py index ed1567a..4c0b6bc 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -40,16 +40,6 @@ add_module_names = False autosectionlabel_prefix_document = True -extensions = ["sphinx.ext.intersphinx", "sphinx.ext.autosectionlabel"] - -intersphinx_mapping = { - "python": ("https://docs.python.org/3/", None), -} - -python_use_unqualified_type_names = True -add_module_names = False -autosectionlabel_prefix_document = True - extensions.append("sphinx.ext.linkcode") import os import subprocess diff --git a/docs/envvar.rst b/docs/envvar.rst index 1eb693e..0da80b2 100644 --- a/docs/envvar.rst +++ b/docs/envvar.rst @@ -43,7 +43,8 @@ EnvVars :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. :param args: A dictionary of EnvVars to to retrieve and use as arguments to ``type``. Arguments can be - :ref:`inferred ` in some cases. + :ref:`inferred ` in some cases. Can also be :data:`ellipsis` to indicate that the arguments + should be inferred from the type annotation of the ``type`` callable (see :ref:`infer:Automatic Argument Inferrence`). :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`. diff --git a/docs/infer.rst b/docs/infer.rst index 540e130..7a459b2 100644 --- a/docs/infer.rst +++ b/docs/infer.rst @@ -61,4 +61,85 @@ There is also a legacy method to create inferred env vars, which is deprecated a .. function:: env_var(key: str, **kwargs) -> InferEnvVar[T] :noindex: - Create an inferred env var that infers only the type. \ No newline at end of file + Create an inferred env var that infers only the type. + +Overriding Inferred Attributes in Annotation +---------------------------------------------------- + +Attributes inferred by :func:`inferred_env_var` can be overridden by specifying the attribute in the type annotation metadata with :data:`typing.Annotated`, and :class:`Env`. + +.. code-block:: python + + from typing import Annotated + from envolved import Env, inferred_env_var, env_var + + @dataclass + class GridSize: + width: int + height: Annotated[int, Env(default=5)] = 10 # GRID_HEIGHT will have default 5 + diagonal: Annotated[bool, Env(key='DIAG')] = False # GRID_DIAG will be parsed as bool + + grid_size_ev = env_var('GRID_', type=GridSize, args=dict( + width=inferred_env_var(), # GRID_WIDTH will be parsed as int + height=inferred_env_var(), # GRID_HEIGHT will be parsed as int, and will have + # default 5 + diagonal=inferred_env_var(), # GRID_DIAG will be parsed as bool, and will have + # default False + )) + +.. currentmodule:: factory_spec + +.. class:: Env(*, key = ..., default = ..., type = ...) + + Metadata class to override inferred attributes in a type annotation. + + :param key: The environment variable key to use. + :param default: The default value to use if the environment variable is not set. + :param type: The type to use for parsing the environment variable. + +Automatic Argument Inferrence +------------------------------------ + +When using :func:`~envvar.env_var` to create schema environment variables, it might be useful to automatically infer the arguments from the type's argument annotation altogether. This can be done by supplying ``args=...`` to the :func:`~envvar.env_var` function. + +.. code-block:: python + + @dataclass + class GridSize: + width: Annotated[int, Env(key='WIDTH')] + height: Annotated[int, Env(key='HEIGHT', default=5)] + diagonal: Annotated[bool, Env(key='DIAG', default=False)] + + grid_size_ev = env_var('GRID_', type=GridSize, args=...) + # this will be equivalent to + grid_size_ev = env_var('GRID_', type=GridSize, args=dict( + width=inferred_env_var('WIDTH'), + height=inferred_env_var('HEIGHT', default=5), + diagonal=inferred_env_var('DIAG', default=False), + )) + +Note that only parameters annotated with :data:`typing.Annotated` and :class:`Env` will be inferred, all others will be ignored. + +.. code-block:: python + + @dataclass + class GridSize: + width: Annotated[int, Env(key='WIDTH')] + height: Annotated[int, Env(key='HEIGHT', default=5)] = 10 + diagonal: bool = False + + grid_size_ev = env_var('GRID_', type=GridSize, args=...) + # only width and height will be used as arguments in the env var + +Arguments can be annotated with an empty :class:`Env` to allow them to be inferred as well. + +.. code-block:: python + + @dataclass + class GridSize: + width: Annotated[int, Env(key='WIDTH')] + height: Annotated[int, Env(key='HEIGHT', default=5)] + diagonal: Annotated[bool, Env(key='DIAG')] = False + + grid_size_ev = env_var('GRID_', type=GridSize, args=...) + # now all three arguments will be used as arguments in the env var \ No newline at end of file diff --git a/envolved/__init__.py b/envolved/__init__.py index 3e6abf1..d7a505a 100644 --- a/envolved/__init__.py +++ b/envolved/__init__.py @@ -3,6 +3,7 @@ from envolved.describe import describe_env_vars from envolved.envvar import EnvVar, Factory, as_default, discard, env_var, inferred_env_var, missing, no_patch from envolved.exceptions import MissingEnvError +from envolved.factory_spec import Env __all__ = [ "__version__", @@ -17,4 +18,5 @@ "no_patch", "Factory", "AbsoluteName", + "Env", ] diff --git a/envolved/_version.py b/envolved/_version.py index e4adfb8..14d9d2f 100644 --- a/envolved/_version.py +++ b/envolved/_version.py @@ -1 +1 @@ -__version__ = "1.6.0" +__version__ = "1.7.0" diff --git a/envolved/envvar.py b/envolved/envvar.py index 03a5290..0733797 100644 --- a/envolved/envvar.py +++ b/envolved/envvar.py @@ -1,5 +1,6 @@ from __future__ import annotations +import sys from _weakrefset import WeakSet from abc import ABC, abstractmethod from contextlib import contextmanager @@ -17,6 +18,7 @@ List, Mapping, MutableSet, + NoReturn, Optional, Sequence, Type, @@ -31,6 +33,11 @@ from envolved.factory_spec import FactoryArgSpec, FactorySpec, factory_spec, missing as factory_spec_missing from envolved.parsers import Parser, ParserInput, parser +if sys.version_info >= (3, 10): + from types import EllipsisType +else: + EllipsisType = NoReturn # there's no right way to do this in 3.9 + T = TypeVar("T") Self = TypeVar("Self") @@ -393,7 +400,7 @@ def env_var( type: Callable[..., T], default: Union[T, Missing, Discard, Factory[T]] = missing, pos_args: Sequence[Union[EnvVar[Any], InferEnvVar[Any]]], - args: Mapping[str, Union[EnvVar[Any], InferEnvVar[Any]]] = {}, + args: Union[Mapping[str, Union[EnvVar[Any], InferEnvVar[Any]]], EllipsisType] = MappingProxyType({}), description: Optional[Description] = None, validators: Iterable[Callable[[T], T]] = (), on_partial: Union[T, Missing, AsDefault, Discard, Factory[T]] = missing, @@ -408,7 +415,7 @@ def env_var( type: Callable[..., T], default: Union[T, Missing, Discard, Factory[T]] = missing, pos_args: Sequence[Union[EnvVar[Any], InferEnvVar[Any]]] = (), - args: Mapping[str, Union[EnvVar[Any], InferEnvVar[Any]]], + args: Union[Mapping[str, Union[EnvVar[Any], InferEnvVar[Any]]], EllipsisType], description: Optional[Description] = None, validators: Iterable[Callable[[T], T]] = (), on_partial: Union[T, Missing, AsDefault, Discard, Factory[T]] = missing, @@ -434,9 +441,15 @@ def env_var( # type: ignore[misc] on_partial = kwargs.pop("on_partial", missing) if kwargs: raise TypeError(f"Unexpected keyword arguments: {kwargs}") + + factory_specs: Optional[FactorySpec] = None + + if args is ...: + factory_specs = factory_spec(type) + args = {k: inferred_env_var() for k, v in factory_specs.keyword.items() if v.is_explicit_env} + pos: List[EnvVar] = [] keys: Dict[str, EnvVar] = {} - factory_specs: Optional[FactorySpec] = None for p in pos_args: if isinstance(p, InferEnvVar): if factory_specs is None: @@ -524,9 +537,12 @@ class InferEnvVar(Generic[T]): 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): + if spec and spec.key_override: + key = spec.key_override + elif not isinstance(param_id, str): raise ValueError(f"cannot infer key for positional parameter {param_id}, please specify a key") - key = param_id + else: + key = param_id default: Union[T, Missing, Discard, Factory[T]] if self.default is as_default: diff --git a/envolved/factory_spec.py b/envolved/factory_spec.py index 12fbf4e..4383c9c 100644 --- a/envolved/factory_spec.py +++ b/envolved/factory_spec.py @@ -1,5 +1,6 @@ from __future__ import annotations +import sys from dataclasses import dataclass from inspect import Parameter, signature from itertools import zip_longest @@ -12,6 +13,28 @@ class FactoryArgSpec: default: Any type: Any + key_override: Optional[str] = None + is_explicit_env: bool = False + + @classmethod + def from_type_annotation(cls, default: Any, ty: Any) -> FactoryArgSpec: + key_override = None + is_explicit_env = False + md = getattr(ty, "__metadata__", None) + if md: + # ty is annotated + ty = ty.__origin__ + for m in md: + if isinstance(m, Env): + is_explicit_env = True + if m.key is not None: + key_override = m.key + if m.default is not missing: + default = m.default + if m.type is not missing: + ty = m.type + + return cls(default, ty, key_override, is_explicit_env) @classmethod def merge(cls, a: Optional[FactoryArgSpec], b: Optional[FactoryArgSpec]) -> FactoryArgSpec: @@ -22,9 +45,18 @@ def merge(cls, a: Optional[FactoryArgSpec], b: Optional[FactoryArgSpec]) -> Fact return FactoryArgSpec( default=a.default if a.default is not missing else b.default, type=a.type if a.type is not missing else b.type, + key_override=a.key_override if a.key_override is not None else b.key_override, + is_explicit_env=a.is_explicit_env or b.is_explicit_env, ) +class Env: + def __init__(self, *, key: str | None = None, default: Any = missing, type: Any = missing): + self.key = key + self.default = default + self.type = type + + @dataclass class FactorySpec: positional: Sequence[FactoryArgSpec] @@ -42,10 +74,17 @@ def merge(self, other: FactorySpec) -> FactorySpec: ) +def compat_get_type_hints(obj: Any) -> Dict[str, Any]: + if sys.version_info >= (3, 9): + return get_type_hints(obj, include_extras=True) + return get_type_hints(obj) + + def factory_spec(factory: Union[Callable[..., Any], Type], skip_pos: int = 0) -> FactorySpec: if isinstance(factory, type): initial_mapping = { - k: FactoryArgSpec(getattr(factory, k, missing), v) for k, v in get_type_hints(factory).items() + k: FactoryArgSpec.from_type_annotation(getattr(factory, k, missing), v) + for k, v in compat_get_type_hints(factory).items() } cls_spec = FactorySpec(positional=(), keyword=initial_mapping) init_spec = factory_spec(factory.__init__, skip_pos=1) # type: ignore[misc] @@ -53,7 +92,7 @@ def factory_spec(factory: Union[Callable[..., Any], Type], skip_pos: int = 0) -> # we arbitrarily decide that __init__ wins over __new__ return init_spec.merge(new_spec).merge(cls_spec) - type_hints = get_type_hints(factory) + type_hints = compat_get_type_hints(factory) sign = signature(factory) pos = [] kwargs = {} @@ -66,7 +105,7 @@ def factory_spec(factory: Union[Callable[..., Any], Type], skip_pos: int = 0) -> default = missing ty = type_hints.get(param.name, missing) - arg_spec = FactoryArgSpec(default, ty) + arg_spec = FactoryArgSpec.from_type_annotation(default, ty) if param.kind in (Parameter.POSITIONAL_OR_KEYWORD, Parameter.POSITIONAL_ONLY): pos.append(arg_spec) diff --git a/envolved/parsers.py b/envolved/parsers.py index 8f4daf8..6b9f485 100644 --- a/envolved/parsers.py +++ b/envolved/parsers.py @@ -372,7 +372,7 @@ def __init__( self.case_sensitive = _case_sensitive @classmethod - def case_insensitive(cls, lookup: Mapping[str, T], fallback: Union[T, NoFallback] = no_fallback) -> LookupParser[T]: + def case_insensitive(cls, lookup: LookupCases, fallback: Union[T, NoFallback] = no_fallback) -> LookupParser[T]: return cls(lookup, fallback, _case_sensitive=False) def __call__(self, x: str) -> T: diff --git a/pyproject.toml b/pyproject.toml index aa1e6ac..da58b45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "envolved" -version = "1.6.0" +version = "1.7.0" description = "" authors = ["ben avrahami "] license = "MIT" diff --git a/tests/unittests/test_schema.py b/tests/unittests/test_schema.py index adefc7f..8a14640 100644 --- a/tests/unittests/test_schema.py +++ b/tests/unittests/test_schema.py @@ -4,9 +4,11 @@ from typing import Any, NamedTuple, Optional from pytest import mark, raises, skip +from typing_extensions import Annotated from envolved import Factory, MissingEnvError, as_default, env_var, missing from envolved.envvar import discard, inferred_env_var +from envolved.factory_spec import Env class NamedTupleClass(NamedTuple): @@ -522,3 +524,60 @@ def test_infer_nameonly(monkeypatch): monkeypatch.setenv("a_b", "36") assert a.get() == SimpleNamespace(a="hi", b="36") + + +def test_annotate_rename(monkeypatch): + @dataclass + class A: + x: Annotated[str, Env(key="T")] + y: Annotated[int, Env(key="U")] + + a = env_var("a_", type=A, args={"x": inferred_env_var(), "y": inferred_env_var()}) + + monkeypatch.setenv("a_T", "hi") + monkeypatch.setenv("a_U", "36") + + assert a.get() == A("hi", 36) + + +def test_annotate_override_type(monkeypatch): + @dataclass + class A: + x: Annotated[str, Env(key="T")] + y: Annotated[int, Env(type=float)] + + a = env_var("a_", type=A, args={"x": inferred_env_var(), "y": inferred_env_var()}) + + monkeypatch.setenv("a_T", "hi") + monkeypatch.setenv("a_Y", "36.5") + + assert a.get() == A("hi", 36.5) + + +def test_annotate_override_default(monkeypatch): + @dataclass + class A: + x: Annotated[str, Env(key="T", type=str.lower)] + y: Annotated[int, Env(default=36.5)] = 10 + + a = env_var("a_", type=A, args={"x": inferred_env_var(), "y": inferred_env_var()}) + + monkeypatch.setenv("a_T", "HI") + + assert a.get() == A("hi", 36.5) + + +def test_ellipsis_args(monkeypatch): + @dataclass + class A: + x: Annotated[str, Env(key="T", type=str.lower)] + y: Annotated[int, Env(default=36.5)] = 10 + z: Annotated[str, Env()] = "foo" + m: str = "" + + a = env_var("a_", type=A, args=...) + + monkeypatch.setenv("a_T", "HI") + monkeypatch.setenv("a_Z", "bar") + + assert a.get() == A("hi", 36.5, "bar", "")