Skip to content

Commit

Permalink
feat!: improve typing
Browse files Browse the repository at this point in the history
BREAKING CHANGES:
- add `handles` method on `Schema`
- `register_schema` now only accepts the schema instance as args
  • Loading branch information
Gabriel Pajot authored and gpajot committed Apr 4, 2023
1 parent bfbda38 commit 0355c6a
Show file tree
Hide file tree
Showing 10 changed files with 64 additions and 36 deletions.
14 changes: 6 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,7 @@ Currently, those formats are supported:
The format is automatically inferred from the config file extension.
When loading from multiple files, files can be of multiple formats.

Other formats can be added by subclassing `Format`.

To register more formats: `Config.register_format(MyFormat(...), ".ext1", ".ext2")`.
Other formats can be added by subclassing `Format`: `Config.register_format(MyFormat(...), ".ext1", ".ext2")`.

> 💡 You can re-register a format to change dumping options.
Expand All @@ -71,13 +69,11 @@ Currently, those schemas are supported:
- plain dict
- dataclasses
- pydantic models - requires the `pydantic` extra
- attrs - requires the attrs extra
- attrs - requires the `attrs` extra

The schema is automatically inferred from the config class.

Other schemas can be added by subclassing `Schema`.

To register more schemas: `Config.register_schema(MySchema(...), lambda cls: ...)`.
Other schemas can be added by subclassing `Schema`: `Config.register_schema(MySchema(...))`.

You can also force the schema by directly overriding the `SCHEMA` class attribute on your config.
This can be used to disable auto selection, or pass arguments to the schema instance.
Expand All @@ -91,7 +87,9 @@ For all schemas and formats, common built in types are handled [when dumping](ht

> ⚠️ Keep in mind that only `attrs` and `pydantic` support casting when loading the config.
You can add custom encoders with `Config.ENCODERS`. For `pydantic`, stick with [the standard way of doing it](https://pydantic-docs.helpmanual.io/usage/exporting_models/#json_encoders).
You can add custom encoders with `Config.ENCODERS`.
For `pydantic`, stick with [the standard way of doing it](https://pydantic-docs.helpmanual.io/usage/exporting_models/#json_encoders).


## Contributing
See [contributing guide](https://github.com/gpajot/zen-config/blob/main/CONTRIBUTING.md).
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 = "zenconfig"
version = "1.7.0"
version = "2.0.0"
description = "Simple configuration loader for python."
authors = ["Gabriel Pajot <gab@les-cactus.co>"]
license = "MIT"
Expand Down
19 changes: 9 additions & 10 deletions zenconfig/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from pathlib import Path
from typing import (
Any,
Callable,
ClassVar,
Dict,
Generic,
Expand Down Expand Up @@ -44,6 +43,10 @@ def dump(
class Schema(ABC, Generic[C]):
"""Abstract class for handling different config class types."""

@abstractmethod
def handles(self, cls: type) -> bool:
"""Return if a type is handled by this schema."""

@abstractmethod
def from_dict(self, cls: Type[C], cfg: Dict[str, Any]) -> C:
"""Load the schema based on a dict configuration."""
Expand Down Expand Up @@ -71,7 +74,7 @@ class BaseConfig(ABC):
# All formats supported, by extension.
__FORMATS: ClassVar[Dict[str, Format]] = {}
# All schema classes supported.
__SCHEMAS: ClassVar[List[Tuple[Schema, Callable[[type], bool]]]] = []
__SCHEMAS: ClassVar[List[Schema]] = []

@classmethod
def register_format(cls, fmt: Format, *extensions: str) -> None:
Expand All @@ -80,13 +83,9 @@ def register_format(cls, fmt: Format, *extensions: str) -> None:
cls.__FORMATS[ext] = fmt

@classmethod
def register_schema(
cls,
schema: Schema,
handles: Callable[[type], bool],
) -> None:
def register_schema(cls, schema: Schema[C]) -> None:
"""Add a schema class to the list of supported ones."""
cls.__SCHEMAS.append((schema, handles))
cls.__SCHEMAS.append(schema)

@classmethod
def _paths(cls) -> Tuple[Path, ...]:
Expand Down Expand Up @@ -130,8 +129,8 @@ def _schema(cls) -> Schema:
"""Get the schema instance for this config class."""
if cls.SCHEMA:
return cls.SCHEMA
for schema, handles in cls.__SCHEMAS:
if not handles(cls):
for schema in cls.__SCHEMAS:
if not schema.handles(cls):
continue
cls.SCHEMA = schema
return cls.SCHEMA
Expand Down
6 changes: 5 additions & 1 deletion zenconfig/formats/yaml.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ def dump(
config: Dict[str, Any],
) -> None:
path.write_text(
yaml.safe_dump(config, indent=self.indent, sort_keys=self.sort_keys)
yaml.safe_dump(
config,
indent=self.indent,
sort_keys=self.sort_keys,
),
)


Expand Down
3 changes: 2 additions & 1 deletion zenconfig/read.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class MergeStrategy(IntEnum):

SHALLOW = 1
DEEP = 2
REPLACE = 3


C = TypeVar("C", bound="ReadOnlyConfig")
Expand All @@ -39,7 +40,7 @@ def load(cls: Type[C]) -> C:
path,
)
config = fmt.load(path)
if not dict_config:
if not dict_config or cls.MERGE_STRATEGY is MergeStrategy.REPLACE:
dict_config = config
elif cls.MERGE_STRATEGY is MergeStrategy.SHALLOW:
dict_config.update(config)
Expand Down
6 changes: 5 additions & 1 deletion zenconfig/schemas/attrs.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from typing import Any, Dict, Type, TypeVar

import attrs
from typing_extensions import TypeGuard

from zenconfig.base import BaseConfig, Schema
from zenconfig.encoder import Encoder
Expand All @@ -9,14 +10,17 @@


class AttrsSchema(Schema[C]):
def handles(self, cls: type) -> TypeGuard[Type[attrs.AttrsInstance]]:
return attrs.has(cls)

def from_dict(self, cls: Type[C], cfg: Dict[str, Any]) -> C:
return _load_nested(cls, cfg)

def to_dict(self, config: C, encoder: Encoder) -> Dict[str, Any]:
return encoder(attrs.asdict(config))


BaseConfig.register_schema(AttrsSchema(), attrs.has)
BaseConfig.register_schema(AttrsSchema())


def _load_nested(cls: Type[C], cfg: Dict[str, Any]) -> C:
Expand Down
29 changes: 23 additions & 6 deletions zenconfig/schemas/dataclass.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,44 @@
from dataclasses import asdict, fields, is_dataclass
from typing import Any, Dict, Type, TypeVar
from dataclasses import Field, asdict, fields, is_dataclass
from typing import (
Any,
ClassVar,
Dict,
Protocol,
Type,
TypeVar,
)

from typing_extensions import TypeGuard

from zenconfig.base import BaseConfig, Schema
from zenconfig.encoder import Encoder

C = TypeVar("C")

class DataclassInstance(Protocol):
__dataclass_fields__: ClassVar[Dict[str, Field]]


C = TypeVar("C", bound=DataclassInstance)


class DataclassSchema(Schema[C]):
def handles(self, cls: type) -> TypeGuard[Type[DataclassInstance]]:
return is_dataclass(cls)

def from_dict(self, cls: Type[C], cfg: Dict[str, Any]) -> C:
return _load_nested(cls, cfg)

def to_dict(self, config: C, encoder: Encoder) -> Dict[str, Any]:
return encoder(asdict(config)) # type: ignore [call-overload]
return encoder(asdict(config))


BaseConfig.register_schema(DataclassSchema(), is_dataclass)
BaseConfig.register_schema(DataclassSchema())


def _load_nested(cls: Type[C], cfg: Dict[str, Any]) -> C:
"""Load nested dataclasses."""
kwargs: Dict[str, Any] = {}
for field in fields(cls): # type: ignore [arg-type]
for field in fields(cls):
if field.name not in cfg:
continue
value = cfg[field.name]
Expand Down
7 changes: 6 additions & 1 deletion zenconfig/schemas/dict.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
from typing import Any, Dict, Type, TypeVar

from typing_extensions import TypeGuard

from zenconfig.base import BaseConfig, Schema
from zenconfig.encoder import Encoder

C = TypeVar("C", bound=dict)


class DictSchema(Schema[C]):
def handles(self, cls: type) -> TypeGuard[Type[dict]]:
return issubclass(cls, dict)

def from_dict(self, cls: Type[C], cfg: Dict[str, Any]) -> C:
return cls(cfg)

def to_dict(self, config: C, encoder: Encoder) -> Dict[str, Any]:
return encoder(config)


BaseConfig.register_schema(DictSchema(), lambda cls: issubclass(cls, dict))
BaseConfig.register_schema(DictSchema())
6 changes: 5 additions & 1 deletion zenconfig/schemas/pydantic.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from typing import Any, Dict, Type, TypeVar

from pydantic import BaseModel
from typing_extensions import TypeGuard

from zenconfig.base import BaseConfig, Schema
from zenconfig.encoder import Encoder, encode
Expand All @@ -16,6 +17,9 @@ class PydanticSchema(Schema[C]):
exclude_unset: bool = False
exclude_defaults: bool = True

def handles(self, cls: type) -> TypeGuard[Type[BaseModel]]:
return issubclass(cls, BaseModel)

def from_dict(self, cls: Type[C], cfg: Dict[str, Any]) -> C:
return cls.parse_obj(cfg)

Expand All @@ -29,7 +33,7 @@ def to_dict(self, config: C, encoder: Encoder) -> Dict[str, Any]:
)


BaseConfig.register_schema(PydanticSchema(), lambda cls: issubclass(cls, BaseModel))
BaseConfig.register_schema(PydanticSchema())


def _encoder(config: BaseModel) -> Encoder:
Expand Down
8 changes: 2 additions & 6 deletions zenconfig/write.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import logging
import sys
from abc import ABC
from functools import partial
from typing import ClassVar, Dict, Optional
from typing import ClassVar, Optional

from zenconfig.base import ZenConfigError
from zenconfig.encoder import Encoder, Encoders, combine_encoders, encode
Expand Down Expand Up @@ -45,12 +44,9 @@ def save(self) -> None:

def clear(self) -> None:
"""Delete the config file(s)."""
kwargs: Dict[str, bool] = {}
if sys.version_info[:2] != (3, 7):
kwargs["missing_ok"] = True
for path in self._paths():
logger.debug("deleting file at path %s", path)
path.unlink(**kwargs)
path.unlink(missing_ok=True)

@classmethod
def _encoder(cls) -> Encoder:
Expand Down

0 comments on commit 0355c6a

Please sign in to comment.