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

feat: iteration on the lib interface. #1

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
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
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,53 @@ Model your domain at the edge.
> active development, and its API is subject to change. We encourage developers to experiment with DTOS and provide
> feedback, but we recommend against using it in production environments until a stable release is available.`

## Additional Goals

In addition to the primary goals of the library, we are aiming to address the following issues with the Litestar DTO
implementation:

- Tagged union support: If the type annotation encounters a union field where the inner types are supported by the DTO,
we should have a framework-specific way to elect a discrimination tag for each type. If there is no tag, the annotation
would not be supported. E.g., for SQLAlchemy we'd be able to use the `polymorphic_on` value to specify the tag.
- Transfer model naming: the names of the transfer models manifest in any json schema or openapi documentation. We want
first class support for controlling these names and logical defaults.
- Applying the DTO to the whole annotation: the current Litestar implementation looks for a DTO supported type within
the annotation and binds itself to that. The goal of the DTO should be to produce an encodable object from an instance
of the annotated type, and be able to construct an instance of the annotated type from input data. Types that are
supported by the DTO should be able to be arbitrarily nested within the annotation, and the DTO should be able to
traverse the annotation to find them, and deal with them in place.
- Support multiple modelling libraries: In Litestar, a DTO is bound to a single modelling library via inheritance. We
should be able to support multiple modelling libraries in the same DTO object by using a plugin system instead of
inheritance.
- Global configuration: Support binding config objects to model types so that the same model can share a config object
across multiple DTOs. This would be especially useful for types that nest models within them. E.g., something like:

```python
from dataclasses import dataclass

from dtos import DTOConfig, register_config

@dataclass
class Base:
secret_thing: str

@dataclass
class A(Base): ...

# this config would apply to all models that inherit from Base, unless a more specific config is provided
register_config(Base, DTOConfig(rename="camel", exclude={"secret_thing"}))
```
- First class support for generic types: The following doesn't work in Litestar:

```python
@dataclass
class Foo(Generic[T]):
foo: T


FooDTO = DataclassDTO[Foo[int]]
```

## About

The `dtos` library bridges the gap between complex domain models and their practical usage across network boundaries.
Expand All @@ -40,6 +87,10 @@ match your application's requirements.
- **Seamless Integration**: Designed to work effortlessly with popular Python data modeling and ORM tools, ``dtos``
integrates into your existing workflow with minimal overhead.

## How it Works

Given a type annotation, `dtos` recursively traverse the type to find fields that it can decompose into a set of
component types.
peterschutt marked this conversation as resolved.
Show resolved Hide resolved
## Contributing

All [Jolt][jolt-org] projects will always be a community-centered, available for contributions of any size.
Expand Down
54 changes: 53 additions & 1 deletion docs/PYPI_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,60 @@ Model your domain at the edge.

</div>

> **Warning**: Pre-Release Alpha Stage
> [!WARNING]
> **Pre-Release Alpha Stage**
>
> Please note that DTOS is currently in a pre-release alpha stage of development. This means the library is still under
> active development, and its API is subject to change. We encourage developers to experiment with DTOS and provide
> feedback, but we recommend against using it in production environments until a stable release is available.`

## Additional Goals

In addition to the primary goals of the library, we are aiming to address the following issues with the Litestar DTO
implementation:

- Tagged union support: If the type annotation encounters a union field where the inner types are supported by the DTO,
we should have a framework-specific way to elect a discrimination tag for each type. If there is no tag, the annotation
would not be supported. E.g., for SQLAlchemy we'd be able to use the `polymorphic_on` value to specify the tag.
- Transfer model naming: the names of the transfer models manifest in any json schema or openapi documentation. We want
first class support for controlling these names and logical defaults.
- Applying the DTO to the whole annotation: the current Litestar implementation looks for a DTO supported type within
the annotation and binds itself to that. The goal of the DTO should be to produce an encodable object from an instance
of the annotated type, and be able to construct an instance of the annotated type from input data. Types that are
supported by the DTO should be able to be arbitrarily nested within the annotation, and the DTO should be able to
traverse the annotation to find them, and deal with them in place.
- Support multiple modelling libraries: In Litestar, a DTO is bound to a single modelling library via inheritance. We
should be able to support multiple modelling libraries in the same DTO object by using a plugin system instead of
inheritance.
- Global configuration: Support binding config objects to model types so that the same model can share a config object
across multiple DTOs. This would be especially useful for types that nest models within them. E.g., something like:

```python
from dataclasses import dataclass

from dtos import DTOConfig, register_config

@dataclass
class Base:
secret_thing: str

@dataclass
class A(Base): ...

# this config would apply to all models that inherit from Base, unless a more specific config is provided
register_config(Base, DTOConfig(rename="camel", exclude={"secret_thing"}))
```
- First class support for generic types: The following doesn't work in Litestar:

```python
@dataclass
class Foo(Generic[T]):
foo: T


FooDTO = DataclassDTO[Foo[int]]
```

## About

The `dtos` library bridges the gap between complex domain models and their practical usage across network boundaries.
Expand All @@ -39,6 +87,10 @@ match your application's requirements.
- **Seamless Integration**: Designed to work effortlessly with popular Python data modeling and ORM tools, ``dtos``
integrates into your existing workflow with minimal overhead.

## How it Works

Given a type annotation, `dtos` recursively traverse the type to find fields that it can decompose into a set of
component types.
## Contributing

All [Jolt][jolt-org] projects will always be a community-centered, available for contributions of any size.
Expand Down
67 changes: 64 additions & 3 deletions dtos/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,68 @@
from __future__ import annotations

__all__ = ("return_three",)
from typing import TYPE_CHECKING, TypeVar

from dtos.config import DTOConfig
from dtos.dto import DTO
from dtos.internals.config_registry import global_config_registry
from dtos.internals.plugin_registry import global_plugin_registry
from dtos.plugins import DataclassPlugin, MsgspecPlugin

def return_three() -> int:
return 3
if TYPE_CHECKING:
from collections.abc import Sequence

from dtos.plugins import Plugin

__all__ = (
"DTOConfig",
"create_dto",
"register_config",
"register_plugins",
)

T = TypeVar("T")


def register_config(type_: type, config: DTOConfig) -> None:
"""Register a global DTO configuration object.

Args:
type_: The type of the DTO object.
config: The DTO configuration object.
"""
global_config_registry.register(type_, config)


def register_plugins(plugins: Sequence[Plugin]) -> None:
"""Register a global DTO plugin.

Args:
plugins: Instances of :class:`Plugin` for the :class:`DTO` instance. Additional to any
plugins already registered. The order of the plugins is important, the first plugin
that can handle a type is used, and plugins registered later are checked first.
"""
global_plugin_registry.register(plugins)


def create_dto(
type_: type[T],
plugins: Sequence[Plugin] = (),
type_configs: Sequence[tuple[type, DTOConfig]] = (),
) -> DTO[T]:
"""Create a new DTOFactory with the given configurations added.

Args:
type_: The type of the DTO object.
plugins: Instances of :class:`Plugin` for the :class:`DTO` instance. Additional to, and take
precedence over plugins registered globally.
type_configs: A sequence of tuples where the first element is a :class:`type` and the
second element is a :class:`DTOConfig` instance. Additional to the configurations
registered globally. Types are matched according MRO, longest match is used.

Returns:
A new :class:`DTO` instance.
"""
return DTO(type_, plugins=plugins, type_configs=type_configs)


register_plugins([DataclassPlugin(), MsgspecPlugin()])
63 changes: 63 additions & 0 deletions dtos/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
from __future__ import annotations

from typing import TYPE_CHECKING

import msgspec

from dtos.exc import ConfigError

if TYPE_CHECKING:
from collections.abc import Set

from dtos.types import RenameStrategy

__all__ = ("DTOConfig",)


class DTOConfig(msgspec.Struct, frozen=True):
"""Control the generated DTO."""

exclude: Set[str] = msgspec.field(default_factory=set)
"""Explicitly exclude fields from the generated DTO.

If exclude is specified, all fields not specified in exclude will be included by default.

Notes:
- The field names are dot-separated paths to nested fields, e.g. ``"address.street"`` will
exclude the ``"street"`` field from a nested ``"address"`` model.
- 'exclude' mutually exclusive with 'include' - specifying both values will raise an
``ImproperlyConfiguredException``.
"""
include: Set[str] = msgspec.field(default_factory=set)
"""Explicitly include fields in the generated DTO.

If include is specified, all fields not specified in include will be excluded by default.

Notes:
- The field names are dot-separated paths to nested fields, e.g. ``"address.street"`` will
include the ``"street"`` field from a nested ``"address"`` model.
- 'include' mutually exclusive with 'exclude' - specifying both values will raise an
``ImproperlyConfiguredException``.
"""
rename_fields: dict[str, str] = msgspec.field(default_factory=dict)
"""Mapping of field names, to new name."""
rename_strategy: RenameStrategy | None = None
"""Rename all fields using a pre-defined strategy or a custom strategy.

The pre-defined strategies are: `upper`, `lower`, `camel`, `pascal`.

A custom strategy is any callable that accepts a string as an argument and
return a string.

Fields defined in ``rename_fields`` are ignored."""
max_nested_depth: int = 1
"""The maximum depth of nested items allowed for data transfer."""
partial: bool = False
"""Allow transfer of partial data."""
underscore_fields_private: bool = True
"""Fields starting with an underscore are considered private and excluded from data transfer."""

def __post_init__(self) -> None:
if self.include and self.exclude:
msg = "Cannot specify both 'include' and 'exclude' in DTOConfig"
raise ConfigError(msg)
42 changes: 42 additions & 0 deletions dtos/dto.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from __future__ import annotations

from typing import TYPE_CHECKING, Generic, TypeVar

from type_lens.type_view import TypeView

from dtos.internals.config_registry import global_config_registry
from dtos.internals.plugin_registry import global_plugin_registry

if TYPE_CHECKING:
from collections.abc import Sequence

from dtos.config import DTOConfig
from dtos.plugins import Plugin

__all__ = ("DTO",)


T = TypeVar("T")


class DTO(Generic[T]):
__slots__ = {
"type_view": "The :class:`TypeView` of the annotation.",
"_plugin_registry": "Registry for plugins.",
"_config_registry": "Registry for config objects.",
}

def __init__(
self,
annotation: type[T],
*,
plugins: Sequence[Plugin] = (),
type_configs: Sequence[tuple[type, DTOConfig]] = (),
) -> None:
self.type_view = TypeView(annotation)

self._plugin_registry = global_plugin_registry.copy()
self._plugin_registry.register(plugins)
self._config_registry = global_config_registry.copy()
for type_, config in type_configs:
self._config_registry.register(type_, config)
14 changes: 14 additions & 0 deletions dtos/exc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from __future__ import annotations

__all__ = (
"ConfigError",
"DtosError",
)


class DtosError(Exception):
"""Base class for exceptions in the ``dtos`` library."""


class ConfigError(DtosError):
"""Raised when there is an error with the configuration of a DTO."""
Empty file added dtos/internals/__init__.py
Empty file.
64 changes: 64 additions & 0 deletions dtos/internals/config_registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
from dtos.config import DTOConfig

__all__ = ("global_config_registry",)


class ConfigRegistry:
"""A global registry of DTO configuration objects."""

__slots__ = (
"configs",
"_cache",
)

def __init__(self) -> None:
self.configs: dict[tuple[type, ...], DTOConfig] = {}
self._cache: dict[type, DTOConfig] = {}

def register(self, type_: type, config: DTOConfig) -> None:
"""Register a DTO configuration object.

Args:
type_ (type): The type of the DTO object.
config (DTOConfig): The DTO configuration object.
"""
self.configs[type_.__mro__] = config

def get(self, type_: type) -> DTOConfig | None:
"""Get the DTO configuration object for the given type.

Args:
type_ (type): The type of the DTO object.

Returns:
DTOConfig | None: The DTO configuration object for the given type, or None if not found.
"""
if config := self._cache.get(type_):
return config

for i in range(1, len(type_.__mro__)):
if config := self.configs.get(type_.__mro__[i:]):
self._cache[type_] = config
return config
return None

def copy(self) -> ConfigRegistry:
"""Create a new ConfigRegistry with the given configurations added.

Args:
configs (dict[type, DTOConfig] | Sequence[tuple[type, DTOConfig]]): The configurations to add.

Returns:
ConfigRegistry: A new ConfigRegistry with the given configurations added.
"""
new_registry = ConfigRegistry()
new_registry.configs = self.configs.copy()
return new_registry


global_config_registry = ConfigRegistry()
Loading
Loading