diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 558b9a6..20bb558 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,6 +19,7 @@ repos: hooks: - id: blacken-docs additional_dependencies: [black==24.*] + exclude: README.md - repo: https://github.com/pre-commit/pre-commit-hooks rev: "v5.0.0" diff --git a/README.md b/README.md index 1c89bf1..ba91135 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,10 @@

PyPI: dataclassish PyPI versions: dataclassish - ReadTheDocs dataclassish license

CI status - ReadTheDocs codecov ruff ruff @@ -46,144 +44,208 @@ pip install dataclassish ## Documentation -[![Documentation Status][rtd-badge]][rtd-link] - -WIP. But if you've worked with a -[`dataclass`](https://docs.python.org/3/library/dataclasses.html) then you -basically already know everything you need to know. +- [Getting Started](#getting-started) + - [Working with a `@dataclass`](#working-with-a-dataclass) + - [Working with a `dict`](#working-with-a-dict) + - [The `__replace__` Method](#the-__replace__-method) + - [Registering in a Custom Type](#registering-in-a-custom-type) +- [Adding a Second Argument](#adding-a-second-argument) +- [dataclass tools](#dataclass-tools) +- [More tools](#more-tools) +- [Converters](#converters) +- [Flags](#flags) ### Getting Started +#### Replacing a `@dataclass` + In this example we'll show how `dataclassish` works exactly the same as [`dataclasses`][dataclasses-link] when working with a `@dataclass` object. -```python -from dataclassish import replace -from dataclasses import dataclass +```pycon +>>> from dataclassish import replace +>>> from dataclasses import dataclass +>>> @dataclass(frozen=True) +... class Point: +... x: float | int +... y: float | int -@dataclass -class Point: - x: float - y: float +>>> p = Point(1.0, 2.0) +>>> p +Point(x=1.0, y=2.0) -p = Point(1.0, 2.0) -print(p) -# Point(x=1.0, y=2.0) +>>> p2 = replace(p, x=3.0) +>>> p2 +Point(x=3.0, y=2.0) -p2 = replace(p, x=3.0) -print(p2) -# Point(x=3.0, y=2.0) ``` -Now we'll work with a [`dict`][dict-link] object. Note that you cannot use tools -from [`dataclasses`][dataclasses-link] with [`dict`][dict-link] objects. +#### Replacing a `dict` -```python -from dataclassish import replace +Now we'll work with a [`dict`][dict-link] object. Note that +[`dataclasses`][dataclasses-link] does _not_ work with [`dict`][dict-link] +objects, but with `dataclassish` it's easy! + +```pycon +>>> from dataclassish import replace -p = {"x": 1, "y": 2.0} -print(p) -# {'x': 1, 'y': 2.0} +>>> p = {"x": 1, "y": 2.0} +>>> p +{'x': 1, 'y': 2.0} -p2 = replace(p, x=3.0) -print(p2) -# {'x': 3.0, 'y': 2.0} +>>> p2 = replace(p, x=3.0) +>>> p2 +{'x': 3.0, 'y': 2.0} # If we try to `replace` a value that isn't in the dict, we'll get an error -try: - replace(p, z=None) -except ValueError as e: - print(e) -# invalid keys {'z'}. +>>> try: +... replace(p, z=None) +... except ValueError as e: +... print(e) +invalid keys {'z'}. + ``` -Registering in a custom type is very easy! Let's make a custom object and define -how `replace` will operate on it. +#### Replacing via the `__replace__` Method -```python -from typing import Any -from plum import dispatch +In Python 3.13+ objects can implement the `__replace__` method to define how +`copy.replace` should operate on them. This was directly inspired by +`dataclass.replace`, and is a nice generalization to more general Python +objects. `dataclassish` too supports this method. +```pycon +>>> class HasReplace: +... def __init__(self, a, b): +... self.a = a +... self.b = b +... def __repr__(self) -> str: +... return f"HasReplace(a={self.a},b={self.b})" +... def __replace__(self, **changes): +... return type(self)(**(self.__dict__ | changes)) -class MyClass: - def __init__(self, a, b, c): - self.a = a - self.b = b - self.c = c +>>> obj = HasReplace(1, 2) +>>> obj +HasReplace(a=1,b=2) - def __repr__(self) -> str: - return f"MyClass(a={self.a},b={self.b},c={self.c})" +>>> obj2 = replace(obj, b=3) +>>> obj2 +HasReplace(a=1,b=3) + +``` +#### Replacing a Custom Type -@dispatch -def replace(obj: MyClass, **changes: Any) -> MyClass: - current_args = {k: getattr(obj, k) for k in "abc"} - updated_args = current_args | changes - return MyClass(**updated_args) +Let's say there's a custom object that we want to use `replace` on, but which +doesn't have a `__replace__` method (or which we want more control over using a +second argument, discussed later). Registering in a custom type is very easy! +Let's make a custom object and define how `replace` will operate on it. +```pycon +>>> from typing import Any +>>> from plum import dispatch -obj = MyClass(1, 2, 3) -print(obj) -# MyClass(a=1,b=2,c=3) +>>> class MyClass: +... def __init__(self, a, b, c): +... self.a = a +... self.b = b +... self.c = c +... def __repr__(self) -> str: +... return f"MyClass(a={self.a},b={self.b},c={self.c})" + + +>>> @dispatch +... def replace(obj: MyClass, **changes: Any) -> MyClass: +... current_args = {k: getattr(obj, k) for k in "abc"} +... updated_args = current_args | changes +... return MyClass(**updated_args) + + +>>> obj = MyClass(1, 2, 3) +>>> obj +MyClass(a=1,b=2,c=3) + +>>> obj2 = replace(obj, c=4.0) +>>> obj2 +MyClass(a=1,b=2,c=4.0) -obj2 = replace(obj, c=4.0) -print(obj2) -# MyClass(a=1,b=2,c=4.0) ``` -### Adding a Second Argument +### Nested Replacement `replace` can also accept a second positional argument which is a dictionary -specifying a nested replacement. For example consider the following dict: +specifying a nested replacement. For example consider the following dict of +Point objects: + +```pycon +>>> p = {"a": Point(1, 2), "b": Point(3, 4), "c": Point(5, 6)} -```python -p = {"a": {"a1": 1, "a2": 2}, "b": {"b1": 3, "b2": 4}, "c": {"c1": 5, "c2": 6}} ``` -With `replace` the sub-dicts can be updated via: +With `replace` the nested structure can be updated via: + +```pycon +>>> replace(p, {"a": {"x": 1.5}, "b": {"y": 4.5}, "c": {"x": 5.5}}) +{'a': Point(x=1.5, y=2), 'b': Point(x=3, y=4.5), 'c': Point(x=5.5, y=6)} -```python -replace(p, {"a": {"a1": 1.5}, "b": {"b2": 4.5}, "c": {"c1": 5.5}}) -# {'a': {'a1': 1.5, 'a2': 2}, 'b': {'b1': 3, 'b2': 4.5}, 'c': {'c1': 5.5, 'c2': 6}} ``` -In contrast in pure Python this would be: +In contrast in pure Python this would be very challenging. Expand the example +below to see how this might be done. -```python -from copy import deepcopy +

+Expand for detailed example + +This is a bad approach, updating the frozen dataclasses in place: + +```pycon +>>> from copy import deepcopy + +>>> newp = deepcopy(p) +>>> object.__setattr__(newp["a"], "x", 1.5) +>>> object.__setattr__(newp["b"], "y", 4.5) +>>> object.__setattr__(newp["c"], "x", 5.5) -newp = deepcopy(p) -newp["a"]["a1"] = 1.5 -newp["b"]["b2"] = 4.5 -newp["c"]["c1"] = 5.5 ``` -And this is the simplest case, where the mutability of a [`dict`][dict-link] -allows us to copy the full object and update it after. Note that we had to use -[`deepcopy`](https://docs.python.org/3/library/copy.html#copy.deepcopy) to avoid -mutating the sub-dicts. So what if the objects are immutable? +A better way might be to create an entirely new object! -```python -@dataclass(frozen=True) -class Object: - x: float | dict - y: float +```pycon +>>> newp = {"a": Point(1.5, p["a"].y), +... "b": Point(p["b"].x, 4.5), +... "c": Point(5.5, p["c"].y)} + +``` +This isn't so good either. -@dataclass(frozen=True) -class Collection: - a: Object - b: Object +
+`dataclassish.replace` is a one-liner that can work on any object (if it has a +registered means to do so), regardless of mutability or nesting. Consider this +fully immutable structure: -p = Collection(Object(1.0, 2.0), Object(3.0, 4.0)) -print(p) +```pycon +>>> @dataclass(frozen=True) +... class Object: +... x: float | dict +... y: float + + +>>> @dataclass(frozen=True) +... class Collection: +... a: Object +... b: Object + + +>>> p = Collection(Object(1.0, 2.0), Object(3.0, 4.0)) +>>> p Collection(a=Object(x=1.0, y=2.0), b=Object(x=3.0, y=4.0)) -replace(p, {"a": {"x": 5.0}, "b": {"y": 6.0}}) -# Collection(a=Object(x=5.0, y=2.0), b=Object(x=3.0, y=6.0)) +>>> replace(p, {"a": {"x": 5.0}, "b": {"y": 6.0}}) +Collection(a=Object(x=5.0, y=2.0), b=Object(x=3.0, y=6.0)) + ``` With `replace` this remains a one-liner. Replace pieces of any structure, @@ -191,12 +253,13 @@ regardless of nesting. To disambiguate dictionary fields from nested structures, use the `F` marker. -```python -from dataclassish import F +```pycon +>>> from dataclassish import F + +>>> replace(p, {"a": {"x": F({"thing": 5.0})}}) +Collection(a=Object(x={'thing': 5.0}, y=2.0), + b=Object(x=3.0, y=4.0)) -replace(p, {"a": {"x": F({"thing": 5.0})}}) -# Collection(a=Object(x={'thing': 5.0}, y=2.0), -# b=Object(x=3.0, y=4.0)) ``` ### dataclass tools @@ -205,78 +268,157 @@ replace(p, {"a": {"x": F({"thing": 5.0})}}) `replace`: `fields`, `asdict`, and `astuple`. `dataclassish` supports of all these functions. -```python -from dataclassish import fields, asdict, astuple +```pycon +>>> from dataclassish import fields, asdict, astuple -p = Point(1.0, 2.0) +>>> p = Point(1.0, 2.0) -print(fields(p)) -# (Field(name='x',...), Field(name='y',...)) +>>> fields(p) +(Field(name='x',...), Field(name='y',...)) -print(asdict(p)) -# {'x': 1.0, 'y': 2.0} +>>> asdict(p) +{'x': 1.0, 'y': 2.0} + +>>> astuple(p) +(1.0, 2.0) -print(astuple(p)) -# (1.0, 2.0) ``` `dataclassish` extends these functions to [`dict`][dict-link]'s: -```python -p = {"x": 1, "y": 2.0} +```pycon +>>> p = {"x": 1, "y": 2.0} + +>>> fields(p) +(Field(name='x',...), Field(name='y',...)) -print(fields(p)) -# (Field(name='x',...), Field(name='y',...)) +>>> asdict(p) +{'x': 1, 'y': 2.0} -print(asdict(p)) -# {'x': 1.0, 'y': 2.0} +>>> astuple(p) +(1, 2.0) -print(astuple(p)) -# (1.0, 2.0) ``` Support for custom objects can be implemented similarly to `replace`. -### converters +### More tools + +In addition to the `dataclasses` tools, `dataclassish` provides a few more +utilities. + +- `get_field` returns the field of an object by name. +- `field_keys` returns the names of an object's fields. +- `field_values` returns the values of an object's fields. +- `field_items` returns the names and values of an object's fields. + +```pycon +>>> from dataclassish import get_field, field_keys, field_values, field_items + +>>> p = Point(1.0, 2.0) + +>>> get_field(p, "x") +1.0 + +>>> field_keys(p) +('x', 'y') + +>>> field_values(p) +(1.0, 2.0) + +>>> field_items(p) +(('x', 1.0), ('y', 2.0)) + +``` + +These functions work on any object that has been registered in, not just +`@dataclass` objects. + +```pycon +>>> p = {"x": 1, "y": 2.0} + +>>> get_field(p, "x") +1 + +>>> field_keys(p) +dict_keys(['x', 'y']) + +>>> field_values(p) +dict_values([1, 2.0]) + +>>> field_items(p) +dict_items([('x', 1), ('y', 2.0)]) + +``` + +### Converters While `dataclasses.field` itself does not allow for converters (See PEP 712) many dataclasses-like libraries do. A very short, very non-exhaustive list includes: `attrs` and `equinox`. The module `dataclassish.converters` provides a few useful converter functions. If you need more, check out `attrs`! -```python -from attrs import define, field -from dataclassish.converters import Optional, Unless +```pycon +>>> from attrs import define, field +>>> from dataclassish.converters import Optional, Unless + + +>>> @define +... class Class1: +... attr: int | None = field(default=None, converter=Optional(int)) +... """attr is converted to an int or kept as None.""" -@define -class Class1: - attr: int | None = field(default=None, converter=Optional(int)) - """attr is converted to an int or kept as None.""" +>>> obj = Class1() +>>> print(obj.attr) +None +>>> obj = Class1(attr=1.0) +>>> obj.attr +1 -obj = Class1() -print(obj.attr) -# None +>>> @define +... class Class2: +... attr: float | int = field(converter=Unless(int, converter=float)) +... """attr is converted to a float, unless it's an int.""" -obj = Class1(a=1.0) -print(obj.attr) -# 1 +>>> obj = Class2(1) +>>> obj.attr +1 +>>> obj = Class2("1") +>>> obj.attr +1.0 + +``` + +### Flags + +`dataclassish` provides flags for customizing the behavior of functions. For +example, the [`coordinax`](https://pypi.org/project/coordinax/) package, which +depends on `dataclassish`, uses a flag `AttrFilter` to filter out fields from +consideration by the functions in `dataclassish`. + +`dataclassish` provides a few built-in flags and flag-related utilities. + +```pycon +>>> from dataclassish import flags +>>> flags.__all__ +['FlagConstructionError', 'AbstractFlag', 'NoFlag'] + +``` -@define -class Class2: - attr: float | int = field(converter=Unless(int, converter=float)) - """attr is converted to a float, unless it's an int.""" +Where `AbstractFlag` is the base class for flags, and `NoFlag` is a flag that +does nothing. `FlagConstructionError` is an error that is raised when a flag is +constructed incorrectly. +As a quick example, we'll show how to use `NoFlag`. -obj = Class2(1) -print(obj.attr) -# 1 +```pycon +>>> from dataclassish import field_keys +>>> tuple(field_keys(flags.NoFlag, p)) +('x', 'y') -obj = Class2("1") -print(obj.attr) -# 1.0 ``` ## Citation @@ -289,7 +431,6 @@ then click the link above. ## Development [![Actions Status][actions-badge]][actions-link] -[![Documentation Status][rtd-badge]][rtd-link] [![codecov][codecov-badge]][codecov-link] [![SPEC 0 — Minimum Supported Dependencies][spec0-badge]][spec0-link] [![pre-commit][pre-commit-badge]][pre-commit-link] diff --git a/conftest.py b/conftest.py index 8af1483..75a7fb4 100644 --- a/conftest.py +++ b/conftest.py @@ -5,23 +5,30 @@ from sybil import Document, Region, Sybil from sybil.parsers.myst import ( - DocTestDirectiveParser as MarkdownDocTestDirectiveParser, - PythonCodeBlockParser as MarkdownPythonCodeBlockParser, - SkipParser as MarkdownSkipParser, + DocTestDirectiveParser as MystDocTestDirectiveParser, + PythonCodeBlockParser as MystPythonCodeBlockParser, + SkipParser as MystSkipParser, ) from sybil.parsers.rest import DocTestParser as ReSTDocTestParser +from sybil.sybil import SybilCollection optionflags = ELLIPSIS | NORMALIZE_WHITESPACE parsers: Sequence[Callable[[Document], Iterable[Region]]] = [ - MarkdownDocTestDirectiveParser(optionflags=optionflags), - MarkdownPythonCodeBlockParser(doctest_optionflags=optionflags), - MarkdownSkipParser(), + MystDocTestDirectiveParser(optionflags=optionflags), + MystPythonCodeBlockParser(doctest_optionflags=optionflags), + MystSkipParser(), ] +# TODO: figure out native parser for `pycon` that doesn't require a new line at +# the end. +readme = Sybil( + parsers=[ReSTDocTestParser(optionflags=optionflags)], + patterns=["README.md"], +) docs = Sybil(parsers=parsers, patterns=["*.md"]) python = Sybil( parsers=[ReSTDocTestParser(optionflags=optionflags), *parsers], patterns=["*.py"] ) -pytest_collect_file = (docs + python).pytest() +pytest_collect_file = SybilCollection([docs, readme, python]).pytest() diff --git a/pyproject.toml b/pyproject.toml index b6fd29a..a4e9649 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,7 +72,7 @@ filterwarnings = [ ] log_cli_level = "INFO" testpaths = [ - "src", "tests", + "README.md", "src", "tests", ]