Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ready for 1.7.0 #18

Merged
merged 7 commits into from
Jul 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading