| # 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* receives {class}`attrs.Attribute` instances with default aliases already resolved, so the leading-underscore stripping has already been applied. | |
| You can use the `attrs.Attribute.alias_is_default` flag to detect whether an alias was explicitly provided by the user or auto-generated. | |
| ```{doctest} | |
| >>> def dataclass_names(cls, fields): | |
| ... return [ | |
| ... field.evolve(alias=field.name) | |
| ... if field.alias_is_default | |
| ... 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"}' | |
| ``` |