Skip to content

Commit 6b8a534

Browse files
committed
feat: iteration on the lib interface.
Adds some top-level functions for registering config objects and plugins. A skeleton of the plugin interface and some stub impls for msgspec and dataclasses. Some registry implementations for plugins and config objects.
1 parent 982c27c commit 6b8a534

18 files changed

+857
-23
lines changed

README.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,53 @@ Model your domain at the edge.
2020
> active development, and its API is subject to change. We encourage developers to experiment with DTOS and provide
2121
> feedback, but we recommend against using it in production environments until a stable release is available.`
2222
23+
## Additional Goals
24+
25+
In addition to the primary goals of the library, we are aiming to address the following issues with the Litestar DTO
26+
implementation:
27+
28+
- Tagged union support: If the type annotation encounters a union field where the inner types are supported by the DTO,
29+
we should have a framework-specific way to elect a discrimination tag for each type. If there is no tag, the annotation
30+
would not be supported. E.g., for SQLAlchemy we'd be able to use the `polymorphic_on` value to specify the tag.
31+
- Transfer model naming: the names of the transfer models manifest in any json schema or openapi documentation. We want
32+
first class support for controlling these names and logical defaults.
33+
- Applying the DTO to the whole annotation: the current Litestar implementation looks for a DTO supported type within
34+
the annotation and binds itself to that. The goal of the DTO should be to produce an encodable object from an instance
35+
of the annotated type, and be able to construct an instance of the annotated type from input data. Types that are
36+
supported by the DTO should be able to be arbitrarily nested within the annotation, and the DTO should be able to
37+
traverse the annotation to find them, and deal with them in place.
38+
- Support multiple modelling libraries: In Litestar, a DTO is bound to a single modelling library via inheritance. We
39+
should be able to support multiple modelling libraries in the same DTO object by using a plugin system instead of
40+
inheritance.
41+
- Global configuration: Support binding config objects to model types so that the same model can share a config object
42+
across multiple DTOs. This would be especially useful for types that nest models within them. E.g., something like:
43+
44+
```python
45+
from dataclasses import dataclass
46+
47+
from dtos import DTOConfig, register_config
48+
49+
@dataclass
50+
class Base:
51+
secret_thing: str
52+
53+
@dataclass
54+
class A(Base): ...
55+
56+
# this config would apply to all models that inherit from Base, unless a more specific config is provided
57+
register_config(Base, DTOConfig(rename="camel", exclude={"secret_thing"}))
58+
```
59+
- First class support for generic types: The following doesn't work in Litestar:
60+
61+
```python
62+
@dataclass
63+
class Foo(Generic[T]):
64+
foo: T
65+
66+
67+
FooDTO = DataclassDTO[Foo[int]]
68+
```
69+
2370
## About
2471

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

90+
## How it Works
91+
92+
Given a type annotation, `dtos` recursively traverse the type to find fields that it can decompose into a set of
93+
component types.
4394
## Contributing
4495

4596
All [Jolt][jolt-org] projects will always be a community-centered, available for contributions of any size.

docs/PYPI_README.md

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,60 @@ Model your domain at the edge.
1313

1414
</div>
1515

16-
> **Warning**: Pre-Release Alpha Stage
16+
> [!WARNING]
17+
> **Pre-Release Alpha Stage**
1718
>
1819
> Please note that DTOS is currently in a pre-release alpha stage of development. This means the library is still under
1920
> active development, and its API is subject to change. We encourage developers to experiment with DTOS and provide
2021
> feedback, but we recommend against using it in production environments until a stable release is available.`
2122
23+
## Additional Goals
24+
25+
In addition to the primary goals of the library, we are aiming to address the following issues with the Litestar DTO
26+
implementation:
27+
28+
- Tagged union support: If the type annotation encounters a union field where the inner types are supported by the DTO,
29+
we should have a framework-specific way to elect a discrimination tag for each type. If there is no tag, the annotation
30+
would not be supported. E.g., for SQLAlchemy we'd be able to use the `polymorphic_on` value to specify the tag.
31+
- Transfer model naming: the names of the transfer models manifest in any json schema or openapi documentation. We want
32+
first class support for controlling these names and logical defaults.
33+
- Applying the DTO to the whole annotation: the current Litestar implementation looks for a DTO supported type within
34+
the annotation and binds itself to that. The goal of the DTO should be to produce an encodable object from an instance
35+
of the annotated type, and be able to construct an instance of the annotated type from input data. Types that are
36+
supported by the DTO should be able to be arbitrarily nested within the annotation, and the DTO should be able to
37+
traverse the annotation to find them, and deal with them in place.
38+
- Support multiple modelling libraries: In Litestar, a DTO is bound to a single modelling library via inheritance. We
39+
should be able to support multiple modelling libraries in the same DTO object by using a plugin system instead of
40+
inheritance.
41+
- Global configuration: Support binding config objects to model types so that the same model can share a config object
42+
across multiple DTOs. This would be especially useful for types that nest models within them. E.g., something like:
43+
44+
```python
45+
from dataclasses import dataclass
46+
47+
from dtos import DTOConfig, register_config
48+
49+
@dataclass
50+
class Base:
51+
secret_thing: str
52+
53+
@dataclass
54+
class A(Base): ...
55+
56+
# this config would apply to all models that inherit from Base, unless a more specific config is provided
57+
register_config(Base, DTOConfig(rename="camel", exclude={"secret_thing"}))
58+
```
59+
- First class support for generic types: The following doesn't work in Litestar:
60+
61+
```python
62+
@dataclass
63+
class Foo(Generic[T]):
64+
foo: T
65+
66+
67+
FooDTO = DataclassDTO[Foo[int]]
68+
```
69+
2270
## About
2371

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

90+
## How it Works
91+
92+
Given a type annotation, `dtos` recursively traverse the type to find fields that it can decompose into a set of
93+
component types.
4294
## Contributing
4395

4496
All [Jolt][jolt-org] projects will always be a community-centered, available for contributions of any size.

dtos/__init__.py

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,68 @@
11
from __future__ import annotations
22

3-
__all__ = ("return_three",)
3+
from typing import TYPE_CHECKING, TypeVar
44

5+
from dtos.config import DTOConfig
6+
from dtos.dto import DTO
7+
from dtos.internals.config_registry import global_config_registry
8+
from dtos.internals.plugin_registry import global_plugin_registry
9+
from dtos.plugins import DataclassPlugin, MsgspecPlugin
510

6-
def return_three() -> int:
7-
return 3
11+
if TYPE_CHECKING:
12+
from collections.abc import Sequence
13+
14+
from dtos.plugins import Plugin
15+
16+
__all__ = (
17+
"DTOConfig",
18+
"create_dto",
19+
"register_config",
20+
"register_plugins",
21+
)
22+
23+
T = TypeVar("T")
24+
25+
26+
def register_config(type_: type, config: DTOConfig) -> None:
27+
"""Register a global DTO configuration object.
28+
29+
Args:
30+
type_: The type of the DTO object.
31+
config: The DTO configuration object.
32+
"""
33+
global_config_registry.register(type_, config)
34+
35+
36+
def register_plugins(plugins: Sequence[Plugin]) -> None:
37+
"""Register a global DTO plugin.
38+
39+
Args:
40+
plugins: Instances of :class:`Plugin` for the :class:`DTO` instance. Additional to any
41+
plugins already registered. The order of the plugins is important, the first plugin
42+
that can handle a type is used, and plugins registered later are checked first.
43+
"""
44+
global_plugin_registry.register(plugins)
45+
46+
47+
def create_dto(
48+
type_: type[T],
49+
plugins: Sequence[Plugin] = (),
50+
type_configs: Sequence[tuple[type, DTOConfig]] = (),
51+
) -> DTO[T]:
52+
"""Create a new DTOFactory with the given configurations added.
53+
54+
Args:
55+
type_: The type of the DTO object.
56+
plugins: Instances of :class:`Plugin` for the :class:`DTO` instance. Additional to, and take
57+
precedence over plugins registered globally.
58+
type_configs: A sequence of tuples where the first element is a :class:`type` and the
59+
second element is a :class:`DTOConfig` instance. Additional to the configurations
60+
registered globally. Types are matched according MRO, longest match is used.
61+
62+
Returns:
63+
A new :class:`DTO` instance.
64+
"""
65+
return DTO(type_, plugins=plugins, type_configs=type_configs)
66+
67+
68+
register_plugins([DataclassPlugin(), MsgspecPlugin()])

dtos/config.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
import msgspec
6+
7+
from dtos.exc import ConfigError
8+
9+
if TYPE_CHECKING:
10+
from collections.abc import Set
11+
12+
from dtos.types import RenameStrategy
13+
14+
__all__ = ("DTOConfig",)
15+
16+
17+
class DTOConfig(msgspec.Struct, frozen=True):
18+
"""Control the generated DTO."""
19+
20+
exclude: Set[str] = msgspec.field(default_factory=set)
21+
"""Explicitly exclude fields from the generated DTO.
22+
23+
If exclude is specified, all fields not specified in exclude will be included by default.
24+
25+
Notes:
26+
- The field names are dot-separated paths to nested fields, e.g. ``"address.street"`` will
27+
exclude the ``"street"`` field from a nested ``"address"`` model.
28+
- 'exclude' mutually exclusive with 'include' - specifying both values will raise an
29+
``ImproperlyConfiguredException``.
30+
"""
31+
include: Set[str] = msgspec.field(default_factory=set)
32+
"""Explicitly include fields in the generated DTO.
33+
34+
If include is specified, all fields not specified in include will be excluded by default.
35+
36+
Notes:
37+
- The field names are dot-separated paths to nested fields, e.g. ``"address.street"`` will
38+
include the ``"street"`` field from a nested ``"address"`` model.
39+
- 'include' mutually exclusive with 'exclude' - specifying both values will raise an
40+
``ImproperlyConfiguredException``.
41+
"""
42+
rename_fields: dict[str, str] = msgspec.field(default_factory=dict)
43+
"""Mapping of field names, to new name."""
44+
rename_strategy: RenameStrategy | None = None
45+
"""Rename all fields using a pre-defined strategy or a custom strategy.
46+
47+
The pre-defined strategies are: `upper`, `lower`, `camel`, `pascal`.
48+
49+
A custom strategy is any callable that accepts a string as an argument and
50+
return a string.
51+
52+
Fields defined in ``rename_fields`` are ignored."""
53+
max_nested_depth: int = 1
54+
"""The maximum depth of nested items allowed for data transfer."""
55+
partial: bool = False
56+
"""Allow transfer of partial data."""
57+
underscore_fields_private: bool = True
58+
"""Fields starting with an underscore are considered private and excluded from data transfer."""
59+
60+
def __post_init__(self) -> None:
61+
if self.include and self.exclude:
62+
msg = "Cannot specify both 'include' and 'exclude' in DTOConfig"
63+
raise ConfigError(msg)

dtos/dto.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING, Generic, TypeVar
4+
5+
from type_lens.type_view import TypeView
6+
7+
from dtos.internals.config_registry import global_config_registry
8+
from dtos.internals.plugin_registry import global_plugin_registry
9+
10+
if TYPE_CHECKING:
11+
from collections.abc import Sequence
12+
13+
from dtos.config import DTOConfig
14+
from dtos.plugins import Plugin
15+
16+
__all__ = ("DTO",)
17+
18+
19+
T = TypeVar("T")
20+
21+
22+
class DTO(Generic[T]):
23+
__slots__ = {
24+
"type_view": "The :class:`TypeView` of the annotation.",
25+
"_plugin_registry": "Registry for plugins.",
26+
"_config_registry": "Registry for config objects.",
27+
}
28+
29+
def __init__(
30+
self,
31+
annotation: type[T],
32+
*,
33+
plugins: Sequence[Plugin] = (),
34+
type_configs: Sequence[tuple[type, DTOConfig]] = (),
35+
) -> None:
36+
self.type_view = TypeView(annotation)
37+
38+
self._plugin_registry = global_plugin_registry.copy()
39+
self._plugin_registry.register(plugins)
40+
self._config_registry = global_config_registry.copy()
41+
for type_, config in type_configs:
42+
self._config_registry.register(type_, config)

dtos/exc.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from __future__ import annotations
2+
3+
__all__ = (
4+
"ConfigError",
5+
"DtosError",
6+
)
7+
8+
9+
class DtosError(Exception):
10+
"""Base class for exceptions in the ``dtos`` library."""
11+
12+
13+
class ConfigError(DtosError):
14+
"""Raised when there is an error with the configuration of a DTO."""

dtos/internals/__init__.py

Whitespace-only changes.

dtos/internals/config_registry.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
if TYPE_CHECKING:
6+
from dtos.config import DTOConfig
7+
8+
__all__ = ("global_config_registry",)
9+
10+
11+
class ConfigRegistry:
12+
"""A global registry of DTO configuration objects."""
13+
14+
__slots__ = (
15+
"configs",
16+
"_cache",
17+
)
18+
19+
def __init__(self) -> None:
20+
self.configs: dict[tuple[type, ...], DTOConfig] = {}
21+
self._cache: dict[type, DTOConfig] = {}
22+
23+
def register(self, type_: type, config: DTOConfig) -> None:
24+
"""Register a DTO configuration object.
25+
26+
Args:
27+
type_ (type): The type of the DTO object.
28+
config (DTOConfig): The DTO configuration object.
29+
"""
30+
self.configs[type_.__mro__] = config
31+
32+
def get(self, type_: type) -> DTOConfig | None:
33+
"""Get the DTO configuration object for the given type.
34+
35+
Args:
36+
type_ (type): The type of the DTO object.
37+
38+
Returns:
39+
DTOConfig | None: The DTO configuration object for the given type, or None if not found.
40+
"""
41+
if config := self._cache.get(type_):
42+
return config
43+
44+
for i in range(1, len(type_.__mro__)):
45+
if config := self.configs.get(type_.__mro__[i:]):
46+
self._cache[type_] = config
47+
return config
48+
return None
49+
50+
def copy(self) -> ConfigRegistry:
51+
"""Create a new ConfigRegistry with the given configurations added.
52+
53+
Args:
54+
configs (dict[type, DTOConfig] | Sequence[tuple[type, DTOConfig]]): The configurations to add.
55+
56+
Returns:
57+
ConfigRegistry: A new ConfigRegistry with the given configurations added.
58+
"""
59+
new_registry = ConfigRegistry()
60+
new_registry.configs = self.configs.copy()
61+
return new_registry
62+
63+
64+
global_config_registry = ConfigRegistry()

0 commit comments

Comments
 (0)