| # Extending |
| |
| Each *attrs*-decorated class has a `__attrs_attrs__` class attribute. |
| It's a tuple of {class}`attrs.Attribute` carrying metadata about each attribute. |
| |
| So it is fairly simple to build your own decorators on top of *attrs*: |
| |
| ```{doctest} |
| >>> from attrs import define |
| >>> def print_attrs(cls): |
| ... print(cls.__attrs_attrs__) |
| ... return cls |
| >>> @print_attrs |
| ... @define |
| ... class C: |
| ... a: int |
| (Attribute(name='a', default=NOTHING, validator=None, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=<class 'int'>, converter=None, kw_only=False, inherited=False, on_setattr=None, alias='a'),) |
| ``` |
| |
| :::{warning} |
| The {func}`attrs.define` / {func}`attr.s` decorator **must** be applied first because it puts `__attrs_attrs__` in place! |
| That means that is has to come *after* your decorator because: |
| |
| ```python |
| @a |
| @b |
| def f(): |
| pass |
| ``` |
| |
| is just [syntactic sugar](https://en.wikipedia.org/wiki/Syntactic_sugar) for: |
| |
| ```python |
| def original_f(): |
| pass |
| |
| f = a(b(original_f)) |
| ``` |
| ::: |
| |
| |
| ## Wrapping the Decorator |
| |
| A more elegant way can be to wrap *attrs* altogether and build a class [DSL](https://en.wikipedia.org/wiki/Domain-specific_language) on top of it. |
| |
| An example for that is the package [*environ-config*](https://github.com/hynek/environ-config) that uses *attrs* under the hood to define environment-based configurations declaratively without exposing *attrs* APIs at all. |
| |
| Another common use case is to overwrite *attrs*'s defaults. |
| |
| |
| ### Mypy |
| |
| Unfortunately, decorator wrapping currently [confuses](https://github.com/python/mypy/issues/5406) Mypy's *attrs* plugin. |
| At the moment, the best workaround is to hold your nose, write a fake Mypy plugin, and mutate a bunch of global variables: |
| |
| ```python |
| from mypy.plugin import Plugin |
| from mypy.plugins.attrs import ( |
| attr_attrib_makers, |
| attr_class_makers, |
| attr_dataclass_makers, |
| ) |
| |
| # These work just like `attr.dataclass`. |
| attr_dataclass_makers.add("my_module.method_looks_like_attr_dataclass") |
| |
| # This works just like `attr.s`. |
| attr_class_makers.add("my_module.method_looks_like_attr_s") |
| |
| # These are our `attr.ib` makers. |
| attr_attrib_makers.add("my_module.method_looks_like_attrib") |
| |
| class MyPlugin(Plugin): |
| # Our plugin does nothing but it has to exist so this file gets loaded. |
| pass |
| |
| |
| def plugin(version): |
| return MyPlugin |
| ``` |
| |
| Then tell Mypy about your plugin using your project's `mypy.ini`: |
| |
| ```ini |
| [mypy] |
| plugins=<path to file> |
| ``` |
| |
| :::{warning} |
| Please note that it is currently *impossible* to let Mypy know that you've changed defaults like *eq* or *order*. |
| You can only use this trick to tell Mypy that a class is actually an *attrs* class. |
| ::: |
| |
| |
| ### Pyright |
| |
| Generic decorator wrapping is supported in [Pyright](https://github.com/microsoft/pyright) via `typing.dataclass_transform` / {pep}`681`. |
| |
| For a custom wrapping of the form: |
| |
| ``` |
| @typing.dataclass_transform(field_specifiers=(attr.attrib, attrs.field)) |
| def custom_define(f): |
| return attrs.define(f) |
| ``` |
| |
| ## Types |
| |
| *attrs* offers two ways of attaching type information to attributes: |
| |
| - {pep}`526` annotations, |
| - and the *type* argument to {func}`attr.ib` / {func}`attrs.field`. |
| |
| This information is available to you: |
| |
| ```{doctest} |
| >>> from attrs import define, field, fields |
| >>> @define |
| ... class C: |
| ... x: int = field() |
| ... y = field(type=str) |
| >>> fields(C).x.type |
| <class 'int'> |
| >>> fields(C).y.type |
| <class 'str'> |
| ``` |
| |
| Currently, *attrs* doesn't do anything with this information but it's very useful if you'd like to write your own validators or serializers! |
| |
| Originally, we didn't add the *type* argument to the new {func}`attrs.field` API, because type annotations are the preferred way. |
| But we reintroduced it later, so `field` can be used with the {func}`attrs.make_class` function. |
| We strongly discourage the use of the *type* parameter outside of {func}`attrs.make_class`. |
| |
| (extending-metadata)= |
| |
| ## Metadata |
| |
| If you're the author of a third-party library with *attrs* integration, you may want to take advantage of attribute metadata. |
| |
| Here are some tips for effective use of metadata: |
| |
| - Try making your metadata keys and values immutable. |
| This keeps the entire {class}`~attrs.Attribute` instances immutable too. |
| |
| - To avoid metadata key collisions, consider exposing your metadata keys from your modules.: |
| |
| ```python |
| from mylib import MY_METADATA_KEY |
| |
| @define |
| class C: |
| x = field(metadata={MY_METADATA_KEY: 1}) |
| ``` |
| |
| Metadata should be composable, so consider supporting this approach even if you decide implementing your metadata in one of the following ways. |
| |
| - Expose `field` wrappers for your specific metadata. |
| This is a more graceful approach if your users don't require metadata from other libraries. |
| |
| ```{doctest} |
| >>> from attrs import fields, NOTHING |
| >>> MY_TYPE_METADATA = '__my_type_metadata' |
| >>> |
| >>> def typed( |
| ... cls, default=NOTHING, validator=None, repr=True, |
| ... eq=True, order=None, hash=None, init=True, metadata=None, |
| ... converter=None |
| ... ): |
| ... metadata = metadata or {} |
| ... metadata[MY_TYPE_METADATA] = cls |
| ... return field( |
| ... default=default, validator=validator, repr=repr, |
| ... eq=eq, order=order, hash=hash, init=init, |
| ... metadata=metadata, converter=converter |
| ... ) |
| >>> |
| >>> @define |
| ... class C: |
| ... x: int = typed(int, default=1, init=False) |
| >>> fields(C).x.metadata[MY_TYPE_METADATA] |
| <class 'int'> |
| ``` |
| |
| (transform-fields)= |
| |
| ## Automatic Field Transformation and Modification |
| |
| *attrs* allows you to automatically modify or transform the class' fields while the class is being created. |
| You do this by passing a *field_transformer* hook to {func}`~attrs.define` (and friends). |
| Its main purpose is to automatically add converters to attributes based on their type to aid the development of API clients and other typed data loaders. |
| |
| This hook must have the following signature: |
| |
| ```{eval-rst} |
| .. function:: your_hook(cls: type, fields: list[attrs.Attribute]) -> list[attrs.Attribute] |
| :noindex: |
| ``` |
| |
| - *cls* is your class right *before* it is being converted into an attrs class. |
| This means it does not yet have the `__attrs_attrs__` attribute. |
| - *fields* is a list of all `attrs.Attribute` instances that will later be set to `__attrs_attrs__`. |
| You can modify these attributes any way you want: |
| You can add converters, change types, and even remove attributes completely or create new ones! |
| |
| For example, let's assume that you really don't like floats: |
| |
| ```{doctest} |
| >>> def drop_floats(cls, fields): |
| ... return [f for f in fields if f.type not in {float, 'float'}] |
| ... |
| >>> @frozen(field_transformer=drop_floats) |
| ... class Data: |
| ... a: int |
| ... b: float |
| ... c: str |
| ... |
| >>> Data(42, "spam") |
| Data(a=42, c='spam') |
| ``` |
| |
| A more realistic example would be to automatically convert data that you, for example, load from JSON: |
| |
| ```{doctest} |
| >>> from datetime import datetime |
| >>> |
| >>> def auto_convert(cls, fields): |
| ... results = [] |
| ... for field in fields: |
| ... if field.converter is not None: |
| ... results.append(field) |
| ... continue |
| ... if field.type in {datetime, 'datetime'}: |
| ... converter = (lambda d: datetime.fromisoformat(d) if isinstance(d, str) else d) |
| ... else: |
| ... converter = None |
| ... results.append(field.evolve(converter=converter)) |
| ... return results |
| ... |
| >>> @frozen(field_transformer=auto_convert) |
| ... class Data: |
| ... a: int |
| ... b: str |
| ... c: datetime |
| ... |
| >>> from_json = {"a": 3, "b": "spam", "c": "2020-05-04T13:37:00"} |
| >>> Data(**from_json) # **** |
| Data(a=3, b='spam', c=datetime.datetime(2020, 5, 4, 13, 37)) |
| ``` |
| |
| Or, perhaps you would prefer to generate dataclass-compatible `__init__` signatures via a default field *alias*. |
| Note, *field_transformer* operates on {class}`attrs.Attribute` instances before the default private-attribute handling is applied so explicit user-provided aliases can be detected. |
| |
| ```{doctest} |
| >>> def dataclass_names(cls, fields): |
| ... return [ |
| ... field.evolve(alias=field.name) |
| ... if not field.alias |
| ... else field |
| ... for field in fields |
| ... ] |
| ... |
| >>> @frozen(field_transformer=dataclass_names) |
| ... class Data: |
| ... public: int |
| ... _private: str |
| ... explicit: str = field(alias="aliased_name") |
| ... |
| >>> Data(public=42, _private="spam", aliased_name="yes") |
| Data(public=42, _private='spam', explicit='yes') |
| ``` |
| |
| ## Customize Value Serialization in `asdict()` |
| |
| *attrs* allows you to serialize instances of *attrs* classes to dicts using the {func}`attrs.asdict` function. |
| However, the result can not always be serialized since most data types will remain as they are: |
| |
| ```{doctest} |
| >>> import json |
| >>> import datetime |
| >>> from attrs import asdict |
| >>> |
| >>> @frozen |
| ... class Data: |
| ... dt: datetime.datetime |
| ... |
| >>> data = asdict(Data(datetime.datetime(2020, 5, 4, 13, 37))) |
| >>> data |
| {'dt': datetime.datetime(2020, 5, 4, 13, 37)} |
| >>> json.dumps(data) |
| Traceback (most recent call last): |
| ... |
| TypeError: Object of type datetime is not JSON serializable |
| ``` |
| |
| To help you with this, {func}`~attrs.asdict` allows you to pass a *value_serializer* hook. |
| It has the signature |
| |
| ```{eval-rst} |
| .. function:: your_hook(inst: type, field: attrs.Attribute, value: typing.Any) -> typing.Any |
| :noindex: |
| ``` |
| |
| ```{doctest} |
| >>> from attr import asdict |
| >>> def serialize(inst, field, value): |
| ... if isinstance(value, datetime.datetime): |
| ... return value.isoformat() |
| ... return value |
| ... |
| >>> data = asdict( |
| ... Data(datetime.datetime(2020, 5, 4, 13, 37)), |
| ... value_serializer=serialize, |
| ... ) |
| >>> data |
| {'dt': '2020-05-04T13:37:00'} |
| >>> json.dumps(data) |
| '{"dt": "2020-05-04T13:37:00"}' |
| ``` |