Skip to content

Commit

Permalink
ready for 1.7.0 (#18)
Browse files Browse the repository at this point in the history
* ready for 1.7.0

* fix type hint

* fix version

* fix documentation

* support 3.13

* nevermind

* fixes

---------

Co-authored-by: Ben Avrahami <[email protected]>
  • Loading branch information
bentheiii and Ben Avrahami authored Jul 7, 2024
1 parent 0807477 commit 7032560
Show file tree
Hide file tree
Showing 11 changed files with 217 additions and 23 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
10 changes: 0 additions & 10 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion docs/envvar.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <infer:Inferred Env Vars>` in some cases.
:param args: A dictionary of EnvVars to to retrieve and use as arguments to ``type``. Arguments can be
:ref:`inferred <infer:Inferred Env Vars>` in some cases.
:ref:`inferred <infer:Inferred Env Vars>` 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`.
Expand Down
83 changes: 82 additions & 1 deletion docs/infer.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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
2 changes: 2 additions & 0 deletions envolved/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__",
Expand All @@ -17,4 +18,5 @@
"no_patch",
"Factory",
"AbsoluteName",
"Env",
]
2 changes: 1 addition & 1 deletion envolved/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "1.6.0"
__version__ = "1.7.0"
26 changes: 21 additions & 5 deletions envolved/envvar.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import sys
from _weakrefset import WeakSet
from abc import ABC, abstractmethod
from contextlib import contextmanager
Expand All @@ -17,6 +18,7 @@
List,
Mapping,
MutableSet,
NoReturn,
Optional,
Sequence,
Type,
Expand All @@ -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")

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
45 changes: 42 additions & 3 deletions envolved/factory_spec.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import sys
from dataclasses import dataclass
from inspect import Parameter, signature
from itertools import zip_longest
Expand All @@ -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:
Expand All @@ -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]
Expand All @@ -42,18 +74,25 @@ 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]
new_spec = factory_spec(factory.__new__, skip_pos=1)
# 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 = {}
Expand All @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion envolved/parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "envolved"
version = "1.6.0"
version = "1.7.0"
description = ""
authors = ["ben avrahami <[email protected]>"]
license = "MIT"
Expand Down
Loading

0 comments on commit 7032560

Please sign in to comment.