From 03fd61e8ad1452e36f31dafda3089858db97c05c Mon Sep 17 00:00:00 2001 From: nstarman Date: Wed, 17 Jul 2024 13:59:08 -0400 Subject: [PATCH] feat: field_keys and full docstrings Signed-off-by: nstarman --- pyproject.toml | 9 +- src/dataclasstools/__init__.py | 3 +- src/dataclasstools/_core.py | 214 ++++++++++++++++++++++++++++++--- src/dataclasstools/_ext.py | 139 +++++++++++++++++++-- 4 files changed, 337 insertions(+), 28 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8b87807..f648322 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -114,14 +114,16 @@ src = ["src"] extend-select = ["ALL"] ignore = [ "ANN401", # Dynamically typed expressions are disallowed in `**kwargs` + "COM812", # For ruff.format "D203", # 1 blank line required before class docstring "D213", # Multi-line docstring summary should start at the first line + "F811", # Redefinition of unused '...' (for plum-dispatch) + "FIX002", # Line contains TODO + "ISC001", # Conflicts with formatter "PLR09", # Too many <...> "PLR2004", # Magic value used in comparison - "ISC001", # Conflicts with formatter "TD002", # Missing author in TODO "TD003", # Missing issue link on the line following this TODO - "FIX002", # Line contains TODO ] [tool.ruff.lint.per-file-ignores] @@ -129,6 +131,9 @@ ignore = [ "noxfile.py" = ["T20"] "docs/conf.py" = ["INP001"] +[tool.ruff.lint.isort] +combine-as-imports = true + [tool.pylint] py-version = "3.10" diff --git a/src/dataclasstools/__init__.py b/src/dataclasstools/__init__.py index 20f1e0d..bc68b0f 100644 --- a/src/dataclasstools/__init__.py +++ b/src/dataclasstools/__init__.py @@ -4,7 +4,7 @@ """ from ._core import DataclassInstance, asdict, astuple, fields, replace -from ._ext import field_items, field_values +from ._ext import field_items, field_keys, field_values from ._version import version as __version__ __all__ = [ @@ -16,6 +16,7 @@ "asdict", "astuple", # ext + "field_keys", "field_values", "field_items", ] diff --git a/src/dataclasstools/_core.py b/src/dataclasstools/_core.py index e6edaf4..3e0742e 100644 --- a/src/dataclasstools/_core.py +++ b/src/dataclasstools/_core.py @@ -2,12 +2,15 @@ __all__ = ["DataclassInstance", "replace", "fields", "asdict", "astuple"] -from collections.abc import Callable -from dataclasses import Field as _dataclass_Field -from dataclasses import asdict as _dataclass_asdict -from dataclasses import astuple as _dataclass_astuple -from dataclasses import fields as _dataclass_fields -from dataclasses import replace as _dataclass_replace +from collections.abc import Callable, Hashable, Mapping +from dataclasses import ( + Field, + asdict as _dataclass_asdict, + astuple as _dataclass_astuple, + field, + fields as _dataclass_fields, + replace as _dataclass_replace, +) from typing import Any, ClassVar, Protocol, runtime_checkable from plum import dispatch @@ -27,34 +30,215 @@ def __subclasshook__(cls: type, c: type) -> bool: return hasattr(c, "__dataclass_fields__") -@dispatch # type: ignore[misc] +# =================================================================== +# Replace + + +@dispatch def replace(obj: DataclassInstance, /, **kwargs: Any) -> DataclassInstance: - """Replace the fields of a dataclass instance.""" + """Replace the fields of a dataclass instance. + + Examples + -------- + >>> from dataclasses import dataclass + >>> from dataclasstools import replace + + >>> @dataclass + ... class Point: + ... x: float + ... y: float + + >>> p = Point(1.0, 2.0) + >>> p + Point(x=1.0, y=2.0) + + >>> replace(p, x=3.0) + Point(x=3.0, y=2.0) + + """ return _dataclass_replace(obj, **kwargs) -@dispatch # type: ignore[misc] -def fields(obj: DataclassInstance) -> tuple[_dataclass_Field, ...]: # type: ignore[type-arg] # TODO: raise issue in beartype - """Return the fields of a dataclass instance.""" +@dispatch # type: ignore[no-redef] +def replace(obj: Mapping[Hashable, Any], /, **kwargs: Any) -> Mapping[Hashable, Any]: + """Replace the fields of a mapping. + + This operates similarly to `dict.update`, except that + the kwargs are checked against the keys of the mapping. + + Examples + -------- + >>> from dataclasses import dataclass + >>> from dataclasstools import replace + + >>> p = {"a": 1, "b": 2, "c": 3} + >>> p + {'a': 1, 'b': 2, 'c': 3} + + >>> replace(p, c=4.0) + {'a': 1, 'b': 2, 'c': 4.0} + + """ + extra_keys = set(kwargs) - set(obj) + if extra_keys: + msg = f"invalid keys {extra_keys}." + raise ValueError(msg) + + return type(obj)(**{**obj, **kwargs}) + + +# =================================================================== +# Fields + + +@dispatch +def fields(obj: DataclassInstance, /) -> tuple[Field, ...]: # type: ignore[type-arg] # TODO: raise issue in beartype + """Return the fields of a dataclass instance. + + Examples + -------- + >>> from dataclasses import dataclass + >>> from dataclasstools import fields + + >>> @dataclass + ... class Point: + ... x: float + ... y: float + + >>> p = Point(1.0, 2.0) + >>> fields(p) + (Field(name='x',type=,...), + Field(name='y',type=,...)) + + """ return _dataclass_fields(obj) -@dispatch # type: ignore[misc] +@dispatch # type: ignore[no-redef] +def fields(obj: Mapping[str, Any], /) -> tuple[Field, ...]: # type: ignore[type-arg] # TODO: raise issue in beartype + """Return the mapping as a tuple of `dataclass.Field` objects. + + Examples + -------- + >>> from dataclasstools import fields + + >>> p = {"a": 1, "b": 2.0, "c": "3"} + >>> fields(p) + (Field(name='a',type=,...), + Field(name='b',type=,...), + Field(name='c',type=,...)) + + """ + fs = tuple(field(kw_only=True) for _ in obj) # pylint: ignore=invalid-field-call + for f, (k, v) in zip(fs, obj.items(), strict=True): + f.name = k + f.type = type(v) + return fs + + +# =================================================================== +# Asdict + + +@dispatch def asdict( obj: DataclassInstance, /, *, dict_factory: Callable[[list[tuple[str, Any]]], dict[str, Any]] = dict, ) -> dict[str, Any]: - """Return the fields of a dataclass instance as a dictionary.""" + """Return the fields of a dataclass instance as a dictionary. + + Examples + -------- + >>> from dataclasses import dataclass + >>> from dataclasstools import asdict + + >>> @dataclass + ... class Point: + ... x: float + ... y: float + + >>> p = Point(1.0, 2.0) + >>> asdict(p) + {'x': 1.0, 'y': 2.0} + + """ return _dataclass_asdict(obj, dict_factory=dict_factory) -@dispatch # type: ignore[misc] +@dispatch # type: ignore[no-redef] +def asdict( + obj: Mapping[str, Any], + /, + *, + dict_factory: Callable[[list[tuple[str, Any]]], dict[str, Any]] = dict, +) -> dict[str, Any]: + """Return the fields of a mapping as a dictionary. + + Following the `asdict` API, the dictionary may be copied if ``dict_factory`` + performs a copy when constructed from a :class:`~collections.abc.Mapping`. + + Examples + -------- + >>> from dataclasstools import asdict + + >>> p = {"a": 1, "b": 2.0, "c": "3"} + >>> asdict(p) + {'a': 1, 'b': 2.0, 'c': '3'} + + >>> asdict(p) is p + False + + """ + return dict_factory(obj) + + +# =================================================================== +# Astuple + + +@dispatch def astuple( obj: DataclassInstance, /, tuple_factory: Callable[[Any], tuple[Any, ...]] = tuple, ) -> tuple[Any, ...]: - """Return the fields of a dataclass instance as a tuple.""" + """Return the fields of a dataclass instance as a tuple. + + Examples + -------- + >>> from dataclasses import dataclass + >>> from dataclasstools import astuple + + >>> @dataclass + ... class Point: + ... x: float + ... y: float + + >>> p = Point(1.0, 2.0) + >>> astuple(p) + (1.0, 2.0) + + """ return _dataclass_astuple(obj, tuple_factory=tuple_factory) + + +@dispatch # type: ignore[no-redef] +def astuple( + obj: Mapping[str, Any], + /, + tuple_factory: Callable[[Any], tuple[Any, ...]] = tuple, +) -> tuple[Any, ...]: + """Return the fields of a mapping as a tuple. + + Examples + -------- + >>> from dataclasstools import astuple + + >>> p = {"a": 1, "b": 2.0, "c": "3"} + >>> astuple(p) + (1, 2.0, '3') + + """ + return tuple_factory(obj.values()) diff --git a/src/dataclasstools/_ext.py b/src/dataclasstools/_ext.py index 3d8cbca..344cdcf 100644 --- a/src/dataclasstools/_ext.py +++ b/src/dataclasstools/_ext.py @@ -1,22 +1,141 @@ """Extension functions for ``dataclasstools``.""" -__all__ = ["field_values", "field_items"] +__all__ = ["field_keys", "field_values", "field_items"] -from collections.abc import Iterator -from typing import Any +from collections.abc import Hashable, Iterator, Mapping +from typing import Any, TypeVar from plum import dispatch -from ._core import DataclassInstance, fields +from ._core import fields +K = TypeVar("K") +V = TypeVar("V") -@dispatch # type: ignore[misc] -def field_values(obj: DataclassInstance) -> Iterator[Any]: - """Return the values of a dataclass instance.""" +# =================================================================== +# Field keys + + +@dispatch +def field_keys(obj: Any, /) -> Iterator[str]: + """Yield the field names from the `dataclasstools.fields`. + + Examples + -------- + >>> from dataclasses import dataclass + >>> from dataclasstools import field_keys + + >>> @dataclass + ... class Point: + ... x: float + ... y: float + + >>> p = Point(1.0, 2.0) + >>> list(field_keys(p)) + ['x', 'y'] + + """ + yield from (f.name for f in fields(obj)) + + +@dispatch # type: ignore[no-redef] +# TODO: def field_keys(obj: Mapping[K, V]) -> Iterator[K]: +def field_keys(obj: Mapping[Hashable, Any]) -> Iterator[Hashable]: + """Return the keys of a mapping. + + Examples + -------- + >>> from dataclasstools import field_keys + + >>> p = {"a": 1, "b": 2.0, "c": "3"} + >>> list(field_keys(p)) + ['a', 'b', 'c'] + + """ + yield from obj.keys() + + +# =================================================================== +# Field values + + +@dispatch +def field_values(obj: Any, /) -> Iterator[Any]: + """Yield the field values from the `dataclasstools.fields`. + + Examples + -------- + >>> from dataclasses import dataclass + >>> from dataclasstools import field_values + + >>> @dataclass + ... class Point: + ... x: float + ... y: float + + >>> p = Point(1.0, 2.0) + >>> list(field_values(p)) + [1.0, 2.0] + + """ yield from (getattr(obj, f.name) for f in fields(obj)) -@dispatch # type: ignore[misc] -def field_items(obj: DataclassInstance) -> Iterator[tuple[str, Any]]: - """Return the field names and values of a dataclass instance.""" +@dispatch # type: ignore[no-redef] +# TODO: def field_values(obj: Mapping[Any, V]) -> Iterator[V]: +def field_values(obj: Mapping[Any, Any]) -> Iterator[Any]: + """Return the values of a mapping. + + Examples + -------- + >>> from dataclasstools import field_values + + >>> p = {"a": 1, "b": 2.0, "c": "3"} + >>> list(field_values(p)) + [1, 2.0, '3'] + + """ + yield from obj.values() + + +# =================================================================== +# Field items + + +@dispatch +def field_items(obj: Any) -> Iterator[tuple[str, Any]]: + """Yield the field names and values from the `dataclasstools.fields`. + + Examples + -------- + >>> from dataclasses import dataclass + >>> from dataclasstools import field_items + + >>> @dataclass + ... class Point: + ... x: float + ... y: float + + >>> p = Point(1.0, 2.0) + >>> list(field_items(p)) + [('x', 1.0), ('y', 2.0)] + + """ yield from ((f.name, getattr(obj, f.name)) for f in fields(obj)) + + +@dispatch # type: ignore[no-redef] +# TODO: def field_items(obj: Mapping[K, V]) -> Iterator[tuple[K, V]]: +def field_items(obj: Mapping[Any, Any]) -> Iterator[tuple[Any, Any]]: + """Return the items of a mapping. + + Examples + -------- + >>> from dataclasstools import field_items + + >>> p = {"a": 1, "b": 2.0, "c": "3"} + >>> list(field_items(p)) + [('a', 1), ('b', 2.0), ('c', '3')] + + """ + yield from obj.items()