From 84a73d751bcb3e6f5ba7149a7e775f9d7715a86a Mon Sep 17 00:00:00 2001 From: Nathaniel Starkman Date: Fri, 6 Dec 2024 16:26:04 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(flags):=20add=20flag=20FilterR?= =?UTF-8?q?epr=20(#46)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nstarman --- README.md | 26 ++- src/dataclassish/_src/flag_compat.py | 336 ++++++++++++++++++++++++++- src/dataclassish/_src/flags.py | 11 + src/dataclassish/flags.py | 4 +- 4 files changed, 361 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index f5e78c2..9027fa1 100644 --- a/README.md +++ b/README.md @@ -404,13 +404,14 @@ consideration by the functions in `dataclassish`. ```pycon >>> from dataclassish import flags >>> flags.__all__ -['FlagConstructionError', 'AbstractFlag', 'NoFlag'] +['FlagConstructionError', 'AbstractFlag', 'NoFlag', 'FilterRepr'] ``` -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. +Where `AbstractFlag` is the base class for flags, `NoFlag` is a flag that does +nothing, and `FilterRepr` will filter out any fields with `repr=True`. +`FlagConstructionError` is an error that is raised when a flag is constructed +incorrectly. As a quick example, we'll show how to use `NoFlag`. @@ -421,6 +422,21 @@ As a quick example, we'll show how to use `NoFlag`. ``` +As another example, we'll show how to use `FilterRepr`. + +```pycon +>>> from dataclasses import field +>>> @dataclass +... class Point: +... x: float +... y: float = field(repr=False) +>>> obj = Point(1.0, 2.0) + +>>> field_keys(flags.FilterRepr, obj) +('x',) + +``` + ## Citation [![DOI][zenodo-badge]][zenodo-link] @@ -448,8 +464,6 @@ We welcome contributions! [pypi-link]: https://pypi.org/project/dataclassish/ [pypi-platforms]: https://img.shields.io/pypi/pyversions/dataclassish [pypi-version]: https://img.shields.io/pypi/v/dataclassish -[rtd-badge]: https://readthedocs.org/projects/dataclassish/badge/?version=latest -[rtd-link]: https://dataclassish.readthedocs.io/en/latest/?badge=latest [ruff-badge]: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v2.json [ruff-link]: https://docs.astral.sh/ruff/ [spec0-badge]: https://img.shields.io/badge/SPEC-0-green?labelColor=%23004811&color=%235CA038 diff --git a/src/dataclassish/_src/flag_compat.py b/src/dataclassish/_src/flag_compat.py index ed9f48e..170b27f 100644 --- a/src/dataclassish/_src/flag_compat.py +++ b/src/dataclassish/_src/flag_compat.py @@ -9,13 +9,23 @@ from plum import dispatch from typing_extensions import Never -from .flags import AbstractFlag, NoFlag +from .api import ( + asdict, + astuple, + field_items, + field_keys, + field_values, + fields, + get_field, + replace, +) +from .flags import AbstractFlag, FilterRepr, NoFlag # =================================================================== # AbstractFlag -@dispatch # type: ignore[misc] +@dispatch # type: ignore[misc, no-redef] def replace(flag: type[AbstractFlag], _: Any, /, **__: Any) -> Never: # noqa: ARG001 """Raise an error if given an AbstractFlag when replacing. @@ -39,7 +49,7 @@ def replace(flag: type[AbstractFlag], _: Any, /, **__: Any) -> Never: # noqa: A raise ValueError(msg) -@dispatch # type: ignore[misc] +@dispatch # type: ignore[misc, no-redef] def fields(flag: type[AbstractFlag], _: Any, /) -> Never: # noqa: ARG001 """Raise an error if an AbstractFlag is used to get fields. @@ -63,7 +73,7 @@ def fields(flag: type[AbstractFlag], _: Any, /) -> Never: # noqa: ARG001 raise ValueError(msg) -@dispatch # type: ignore[misc] +@dispatch # type: ignore[misc, no-redef] def asdict( flag: type[AbstractFlag], # noqa: ARG001 _: Any, @@ -93,7 +103,7 @@ def asdict( raise ValueError(msg) -@dispatch # type: ignore[misc] +@dispatch # type: ignore[misc, no-redef] def astuple( flag: type[AbstractFlag], # noqa: ARG001 _: Any, @@ -123,7 +133,7 @@ def astuple( raise ValueError(msg) -@dispatch # type: ignore[misc] +@dispatch # type: ignore[misc, no-redef] def field_keys(flag: type[AbstractFlag], _: Any, /) -> Never: # noqa: ARG001 """Raise an error if an AbstractFlag is used with ``field_keys``. @@ -147,7 +157,7 @@ def field_keys(flag: type[AbstractFlag], _: Any, /) -> Never: # noqa: ARG001 raise ValueError(msg) -@dispatch # type: ignore[misc] +@dispatch # type: ignore[misc, no-redef] def field_values(flag: type[AbstractFlag], _: Any, /) -> Never: # noqa: ARG001 """Raise an error if an AbstractFlag is used with ``field_values``. @@ -171,7 +181,7 @@ def field_values(flag: type[AbstractFlag], _: Any, /) -> Never: # noqa: ARG001 raise ValueError(msg) -@dispatch # type: ignore[misc] +@dispatch # type: ignore[misc, no-redef] def field_items(flag: type[AbstractFlag], _: Any, /) -> Never: # noqa: ARG001 """Raise an error if an AbstractFlag is used with ``field_items``. @@ -334,3 +344,313 @@ def field_items(flag: type[NoFlag], obj: Any, /) -> Iterable[tuple[Any, Any]]: """ return cast(Iterable[tuple[Any, Any]], field_items(obj)) + + +# =================================================================== +# FilterRepr + + +@dispatch # type: ignore[misc, no-redef] +def replace(_: type[FilterRepr], obj: Any, /, **kwargs: Any) -> Any: + """Replace the fields of an object, filtering based on repr. + + Raises + ------ + ValueError + If a field is in kwargs but has repr=False. + + Examples + -------- + >>> from dataclassish import replace + >>> from dataclassish.flags import FilterRepr + + 1) dataclass: + + >>> from dataclasses import dataclass, field + >>> @dataclass + ... class Point: + ... x: float + ... y: float = field(repr=False) + >>> obj = Point(1.0, 2.0) + + >>> replace(FilterRepr, obj, x=3.0) + Point(x=3.0) + + >>> try: replace(FilterRepr, obj, y=3.0) + ... except ValueError as e: print(e) + Fields ['y'] are in kwargs but are repr=False. + + 2) dict: + + >>> obj = {"x": 1.0, "y": 2.0} + >>> replace(FilterRepr, obj, x=3.0) + {'x': 3.0, 'y': 2.0} + + """ + # Determine if any changes are + fs_in_kw_but_repr = [f.name for f in fields(obj) if f.name in kwargs if not f.repr] + if fs_in_kw_but_repr: + msg = f"Fields {fs_in_kw_but_repr} are in kwargs but are repr=False." + raise ValueError(msg) + + return replace(obj, **kwargs) + + +@dispatch # type: ignore[misc, no-redef] +def fields(_: type[FilterRepr], obj: Any) -> tuple[Field, ...]: # type: ignore[type-arg] + """Return fields of the object, filtering based on repr. + + Examples + -------- + >>> from dataclassish import fields + >>> from dataclassish.flags import FilterRepr + + 1) dataclass: + + >>> from dataclasses import dataclass, field + >>> @dataclass + ... class Point: + ... x: float + ... y: float = field(repr=False) + >>> obj = Point(1.0, 2.0) + + >>> fields(FilterRepr, obj) + (Field(name='x',...),) + + 2) dict: + + >>> obj = {"x": 1.0, "y": 2.0} + >>> fields(FilterRepr, obj) + (Field(name='x',...), Field(name='y',...)) + + """ + return tuple(f for f in fields(obj) if f.repr) + + +@dispatch # type: ignore[misc, no-redef] +def asdict( + flag: type[FilterRepr], + obj: Any, + /, + *, + dict_factory: Callable[[list[tuple[str, Any]]], dict[str, Any]] = dict, +) -> dict[str, Any]: + """Return the fields as a mapping, filtering based on repr. + + Examples + -------- + >>> from dataclassish import asdict + >>> from dataclassish.flags import FilterRepr + + 1) dataclass: + + >>> from dataclasses import dataclass, field + >>> @dataclass + ... class Point: + ... x: float + ... y: float = field(repr=False) + >>> obj = Point(1.0, 2.0) + + >>> asdict(FilterRepr, obj) + {'x': 1.0} + + 2) dict: + + >>> obj = {"x": 1.0, "y": 2.0} + >>> asdict(FilterRepr, obj) + {'x': 1.0, 'y': 2.0} + + """ + all_dict = asdict(obj) + keep_keys = field_keys(flag, obj) + return dict_factory([(k, all_dict[k]) for k in keep_keys]) + + +@dispatch # type: ignore[misc, no-redef] +def astuple( + _: type[FilterRepr], + obj: Any, + /, + *, + tuple_factory: Callable[[Any], tuple[Any, ...]] = tuple, +) -> tuple[Any, ...]: + """Return the fields of an object as a tuple, filtering based on repr. + + Examples + -------- + >>> from dataclassish import astuple + >>> from dataclassish.flags import FilterRepr + + 1) dataclass: + + >>> from dataclasses import dataclass, field + >>> @dataclass + ... class Point: + ... x: float + ... y: float = field(repr=False) + >>> obj = Point(1.0, 2.0) + + >>> astuple(FilterRepr, obj) + (1.0,) + + 2) dict: + + >>> obj = {"x": 1.0, "y": 2.0} + >>> astuple(FilterRepr, obj) + (1.0, 2.0) + + """ + tup = astuple(obj) + keep = [f.repr for f in fields(obj)] + return tuple_factory([x for x, cond in zip(tup, keep, strict=True) if cond]) + + +@dispatch # type: ignore[misc, no-redef] +def get_field(_: type[FilterRepr], obj: Any, field_name: str) -> Any: + """Get the value of a field from an object, filtering based on repr. + + Raises + ------ + ValueError + If the field is repr=False. + + Examples + -------- + >>> from dataclassish import get_field + >>> from dataclassish.flags import FilterRepr + + 1) dataclass: + + >>> from dataclasses import dataclass, field + >>> @dataclass + ... class Point: + ... x: float + ... y: float = field(repr=False) + >>> obj = Point(1.0, 2.0) + + >>> get_field(FilterRepr, obj, "x") + 1.0 + + >>> try: get_field(FilterRepr, obj, "z") + ... except ValueError as e: print(e) + Field z not found. + + >>> try: get_field(FilterRepr, obj, "y") + ... except ValueError as e: print(e) + Field y is repr=False. + + 2) dict: + + >>> obj = {"x": 1.0, "y": 2.0} + >>> get_field(FilterRepr, obj, "x") + 1.0 + + """ + f = [f.repr for f in fields(obj) if f.name == field_name] + if not f: + msg = f"Field {field_name} not found." + raise ValueError(msg) + if not f[0]: + msg = f"Field {field_name} is repr=False." + raise ValueError(msg) + + return get_field(obj, field_name) + + +@dispatch # type: ignore[misc, no-redef] +def field_keys(_: type[FilterRepr], obj: Any) -> tuple[Any, ...]: + """Return the keys of an object, filtering based on repr. + + Examples + -------- + >>> from dataclassish import field_keys + >>> from dataclassish.flags import FilterRepr + + 1) dataclass: + + >>> from dataclasses import dataclass, field + >>> @dataclass + ... class Point: + ... x: float + ... y: float = field(repr=False) + >>> obj = Point(1.0, 2.0) + + >>> field_keys(FilterRepr, obj) + ('x',) + + 2) dict: + + >>> obj = {"x": 1.0, "y": 2.0} + >>> field_keys(FilterRepr, obj) + ('x', 'y') + + """ + return tuple(k for k, f in zip(field_keys(obj), fields(obj), strict=True) if f.repr) + + +@dispatch # type: ignore[misc, no-redef] +def field_values(_: type[FilterRepr], obj: Any) -> tuple[Any, ...]: + """Return the values of an object, filtering based on repr. + + Examples + -------- + >>> from dataclassish import field_values + >>> from dataclassish.flags import FilterRepr + + 1) dataclass: + + >>> from dataclasses import dataclass, field + >>> @dataclass + ... class Point: + ... x: float + ... y: float = field(repr=False) + >>> obj = Point(1.0, 2.0) + + >>> field_values(FilterRepr, obj) + (1.0,) + + 2) dict: + + >>> obj = {"x": 1.0, "y": 2.0} + >>> field_values(FilterRepr, obj) + (1.0, 2.0) + + """ + return tuple( + v for v, f in zip(field_values(obj), fields(obj), strict=True) if f.repr + ) + + +@dispatch # type: ignore[misc, no-redef] +def field_items(_: type[FilterRepr], obj: Any) -> tuple[tuple[Any, Any], ...]: + """Return the items of an object, filtering based on repr. + + Examples + -------- + >>> from dataclassish import field_items + >>> from dataclassish.flags import FilterRepr + + 1) dataclass: + + >>> from dataclasses import dataclass, field + >>> @dataclass + ... class Point: + ... x: float + ... y: float = field(repr=False) + >>> obj = Point(1.0, 2.0) + + >>> field_items(FilterRepr, obj) + (('x', 1.0),) + + 2) dict: + + >>> obj = {"x": 1.0, "y": 2.0} + >>> field_items(FilterRepr, obj) + (('x', 1.0), ('y', 2.0)) + + """ + return tuple( + (k, v) + for (k, v), f in zip(field_items(obj), fields(obj), strict=True) + if f.repr + ) diff --git a/src/dataclassish/_src/flags.py b/src/dataclassish/_src/flags.py index acc3fa2..8cae2ca 100644 --- a/src/dataclassish/_src/flags.py +++ b/src/dataclassish/_src/flags.py @@ -39,3 +39,14 @@ def __new__(cls, *_: Any, **__: Any) -> None: # type: ignore[misc] @final class NoFlag(AbstractFlag): """No flag.""" + + +@final +class FilterRepr(AbstractFlag): + """Filter items based on whether they are in the repr. + + For dataclasses this is determined by ``repr=True`` in the field. + For dicts all items are included. + Behavior depends on the ``dataclassish.fields`` function. + + """ diff --git a/src/dataclassish/flags.py b/src/dataclassish/flags.py index f86ab5f..b927781 100644 --- a/src/dataclassish/flags.py +++ b/src/dataclassish/flags.py @@ -1,6 +1,6 @@ """flags for ``dataclassish``.""" -__all__ = ["FlagConstructionError", "AbstractFlag", "NoFlag"] +__all__ = ["FlagConstructionError", "AbstractFlag", "NoFlag", "FilterRepr"] from ._src.flag_compat import * # noqa: F403 -from ._src.flags import AbstractFlag, FlagConstructionError, NoFlag +from ._src.flags import AbstractFlag, FilterRepr, FlagConstructionError, NoFlag