Skip to content
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
33 changes: 33 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: CI

on:
push:
branches: ['**']
pull_request:
branches: [main]

jobs:
check:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.10', '3.11', '3.12', '3.13']

steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install dependencies
run: uv sync --all-groups

- name: Run checks
run: make check-all
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ tox.ini
.pytest_cache/
.python-version
.vscode/
dist/
20 changes: 0 additions & 20 deletions .travis.yml

This file was deleted.

27 changes: 27 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
PYTHON_FILES=$(find . -name "*.py" -not -path "./.venv/*" -not -path "./build/*" -not -path "./dist/*" -not -path "./tests/*")

dist/: ${PYTHON_FILES} pyproject.toml
uv build

.PHONY: publish
publish: dist/
uv publish dist/*

.PHONY: test
test:
uv run pytest --cov=ecological --cov-report=term-missing tests/

.PHONY: typecheck
typecheck:
uv run ty check ecological/

.PHONY: lint
lint:
uv run ruff check ecological/

.PHONY: format
format:
uv run ruff format ecological/

.PHONY: check-all
check-all: test typecheck lint
2 changes: 2 additions & 0 deletions ecological/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
from .config import AutoConfig, Autoload, Config, Variable

__all__ = ["AutoConfig", "Autoload", "Config", "Variable"]
57 changes: 44 additions & 13 deletions ecological/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,38 @@
The heart of the library.
See ``README.rst`` and ``Configuration`` class for more details.
"""

import dataclasses
import enum
import os
import warnings
from typing import Any, Callable, Dict, NewType, Optional, Type, Union, get_type_hints
from typing import (
Any,
Dict,
Optional,
Protocol,
Type,
get_type_hints,
cast as typing_cast,
)

# Aliased in order to avoid a conflict with the _Options.transform attribute.
from . import transform as transform_module

_NO_DEFAULT = object()
VariableName = NewType("VariableName", Union[str, bytes])
VariableValue = NewType("VariableValue", Union[str, bytes])
Source = NewType("Source", Dict[VariableName, VariableValue])
TransformCallable = NewType("TransformCallable", Callable[[VariableValue, Type], Any])
VariableName = str
VariableValue = str | bytes
Source = Dict[VariableName, VariableValue]


class TransformCallable(Protocol):
def __call__(self, representation: str, wanted_type: Type) -> Any: ...


class VariableNameFactory(Protocol):
def __call__(
self, attr_name: str, prefix: Optional[str] = None
) -> VariableName: ...


class Autoload(enum.Enum):
Expand Down Expand Up @@ -60,10 +78,10 @@ class _Options:

prefix: Optional[str] = None
autoload: Autoload = Autoload.CLASS
source: Source = os.environ
source: Source = dataclasses.field(default_factory=lambda: os.environ)
transform: TransformCallable = transform_module.cast
wanted_type: Type = str
variable_name: Callable[[str, Optional[str]], VariableName] = _generate_environ_name
variable_name: VariableNameFactory = _generate_environ_name

@classmethod
def from_dict(cls, options_dict: Dict) -> "_Options":
Expand Down Expand Up @@ -94,10 +112,10 @@ class Variable:
and user preferences how to process it.
"""

variable_name: Optional[VariableName] = None
variable_name: VariableName | None = None
default: Any = _NO_DEFAULT
transform: Optional[TransformCallable] = None
source: Optional[Source] = None
transform: TransformCallable | None = None
source: Source | None = None
wanted_type: Type = dataclasses.field(init=False)

def set_defaults(
Expand All @@ -123,6 +141,12 @@ def get(self) -> VariableValue:
``self.transform`` operation on it. Falls back to ``self.default``
if the value is not found.
"""
if self.source is None:
raise ValueError("Source for variable is not set.")

if self.variable_name is None:
raise ValueError("Variable name is not set.")

try:
raw_value = self.source[self.variable_name]
except KeyError:
Expand All @@ -133,8 +157,13 @@ def get(self) -> VariableValue:
else:
return self.default

if self.transform is None:
raise ValueError(
f"Transform function for {self.variable_name!r} is not set."
)

try:
return self.transform(raw_value, self.wanted_type)
return self.transform(typing_cast(str, raw_value), self.wanted_type)
except (ValueError, SyntaxError) as e:
raise ValueError(
f"Invalid configuration for {self.variable_name!r}."
Expand Down Expand Up @@ -185,7 +214,7 @@ def __init__(self, *args, **kwargs):
cls.load(self)

@classmethod
def load(cls: "Config", target_obj: Optional[object] = None):
def load(cls: Type["Config"], target_obj: Optional[object] = None):
"""
Fetches and converts values of variables declared as attributes on ``cls`` according
to their specification and finally assigns them to the corresponding attributes
Expand All @@ -202,9 +231,11 @@ def load(cls: "Config", target_obj: Optional[object] = None):
for attr_name in attr_names:
# Omit private and nested configuration attributes
# (Attribute value can be the instance of Config itself).
if attr_name.startswith("_") or isinstance(attr_name, cls):
if attr_name.startswith("_"):
continue
attr_value = cls_dict.get(attr_name, _NO_DEFAULT)
if type(attr_value) is cls:
continue
attr_type = attr_types.get(attr_name, cls._options.wanted_type)

if isinstance(attr_value, Variable):
Expand Down
66 changes: 20 additions & 46 deletions ecological/transform.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,22 @@
Provides ``cast`` function (default transform function for variables of `config.Config`)
mitigating a number of `typing` module quirks that happen across Python versions.
"""

import ast
import collections
from typing import AnyStr, ByteString, Dict, FrozenSet, List, Set, Tuple

try:
from typing import GenericMeta

PEP560 = False
except ImportError:
GenericMeta = None
PEP560 = True

from typing import (
AnyStr,
ByteString,
Dict,
FrozenSet,
List,
Set,
Tuple,
Counter,
Deque,
cast as typing_cast,
)

_NOT_IMPORTED = object()

try: # Types added in Python 3.6.1
from typing import Counter, Deque
except ImportError:
Deque = Counter = _NOT_IMPORTED

TYPES_THAT_NEED_TO_BE_PARSED = [bool, list, set, tuple, dict]
TYPING_TO_REGULAR_TYPE = {
Expand All @@ -36,26 +33,6 @@
}


def _cast_typing_old(wanted_type: type) -> type:
"""
Casts typing types in Python < 3.7
"""
wanted_type = TYPING_TO_REGULAR_TYPE.get(wanted_type, wanted_type)
if isinstance(wanted_type, GenericMeta):
# Fallback to try to map complex typing types to real types
for base in wanted_type.__bases__:
# if not isinstance(base, Generic):
# # If it's not a Generic class then it can be a real type
# wanted_type = base
# break
if base in TYPING_TO_REGULAR_TYPE:
# The mapped type in bases is most likely the base type for complex types
# (for example List[int])
wanted_type = TYPING_TO_REGULAR_TYPE[base]
break
return wanted_type


def _cast_typing_pep560(wanted_type: type) -> type:
"""
Casts typing types in Python >= 3.7
Expand All @@ -65,10 +42,11 @@ def _cast_typing_pep560(wanted_type: type) -> type:
if wanted_type in TYPING_TO_REGULAR_TYPE:
return TYPING_TO_REGULAR_TYPE.get(wanted_type, wanted_type)

try:
return wanted_type.__origin__
except AttributeError: # This means it's (probably) not a typing type
return wanted_type
if hasattr(wanted_type, "__origin__"):
return typing_cast(type, wanted_type.__origin__)

# This means it's (probably) not a typing type
return wanted_type


def cast(representation: str, wanted_type: type):
Expand All @@ -82,13 +60,9 @@ def cast(representation: str, wanted_type: type):
# is its __supertype__ field, which it is the only "typing" member to have.
# Since newtypes can be nested, we process __supertype__ as long as available.
while hasattr(wanted_type, "__supertype__"):
wanted_type = wanted_type.__supertype__
wanted_type = typing_cast(type, wanted_type.__supertype__)

# If it's another typing type replace it with the real type
if PEP560: # python >= 3.7
wanted_type = _cast_typing_pep560(wanted_type)
else:
wanted_type = _cast_typing_old(wanted_type)
wanted_type = _cast_typing_pep560(wanted_type)

if wanted_type in TYPES_THAT_NEED_TO_BE_PARSED:
value = (
Expand Down
Loading