dataclasses were originally intended as boilerplate writers but due to their structure a number of tools special case them and in doing so currently rely on either internal attributes or external observations.
I think we should acknowledge this and provide correct public functions to access the relevant parts of dataclasses. The idea is to expose the relevant details without requiring the use of internal names or really specific internal details (see the first example).
Proposal:
- Add
is_dataclass_typeandis_dataclass_instanceto detect if a type or instance is a dataclass or instance of a dataclass.- These should have a
strict/exclude_subclasses[1] parameter which would restrict detection to actual dataclass types or instances and not things that inherit from dataclasses.- Currently you have to check
type(obj).__dict__.get("__dataclass_fields__")directly.
- Currently you have to check
- This
strictparameter should also be added tois_dataclass - These should check for the presence of both
_FIELDSand_PARAMS - Arguably we can just let users work out if they have a type or instance and just add
strict, I think adding the specific methods is nicer though.dataclassesitself already has_is_dataclass_instancefor this purpose.
- These should have a
- Add
params(cls_or_instance)to retrieve construction parameters from__dataclass_params__in the same wayfields(...)retrieves relevant fields- This will also require making
_DataclassParamspublic - Unlike
fields(cls)this function should probably fail for non-dataclass subclasses in order to preventparams(cls).frozenfrom being misleading. Non-dataclass subclasses of frozen dataclasses are not themselves frozen outside of the dataclass fields they inherited.
- This will also require making
- Add
is_dataclass_methodto detect methods generated by dataclasses- This would work by adding a dunder attribute to the methods when they are generated, unwrapping them to check if necessary.
- This is already done for internal reasons to identify dataclasses’
__init__.__annotate__function.
My examples here are largely from modules attempting to work out if __repr__ is the dataclasses generated __repr__ or if the class was instructed to create a __repr__ whether or not it actually did and doing so in different ways all of which use different internal details.
Two of these are from the stdlib but my view is that if unrelated stdlib modules need to poke around in dataclass internals then unrelated non-stdlib modules may also need to do the same but aren’t in the same privileged position. Also if we use these methods then people (or potentially their LLMs) may copy how we do it.
(Also I broke the pprint example when working on lazy dataclass methods because nobody should be relying on those details.)
Example 1:
pprint tries to detect if a __repr__ function was generated by dataclasses using this logic:
elif (is_dataclass(object) and
not isinstance(object, type) and
object.__dataclass_params__.repr and
# Check dataclass has generated repr method.
hasattr(object.__repr__, "__wrapped__") and
"__create_fn__" in object.__repr__.__wrapped__.__qualname__):
This relies on dataclasses __repr__ being generated by a function called __create_fn__ which only exists inside the generation code. It then also relies on the fact that the generated __repr__ isn’t renamed until after it has been decorated with @recursive_repr, so the original __qualname__ still exists. This has already resulted in dataclasses not changing the name of its __create_fn__ function when calls were batched.
This would be replaced with:
elif (
is_dataclass_instance(object) and
params(object).repr and
is_dataclass_method(object.__repr__)
): ...
Example 2:
enum checks if a class is a dataclass and if repr=True was used when a class was created
if (
'__dataclass_fields__' in base.__dict__
and '__dataclass_params__' in base.__dict__
and base.__dict__['__dataclass_params__'].repr
):
return _dataclass_repr
This directly checks if __dataclass_fields__ is in the class dict rather than using is_dataclass, possibly due to is_dataclass detecting subclasses of dataclasses as dataclasses even if they are not dataclasses themselves. It also uses __dataclass_params__ to find out the parameters the class was defined with.
The equivalent to this would then be:
if is_dataclass_type(base, strict=True) and params(base).repr: ...
If it actually wants to check if the __repr__ hasn’t been overridden while repr=True it can also use is_dataclass_method(base.__repr__).
Example 3:
For a non-stdlib example rich also tries to detect if a __repr__ function was generated by dataclasses, but with different - flawed - logic
def _is_dataclass_repr(obj: object) -> bool:
# Digging in to a lot of internals here
# Catching all exceptions in case something is missing on a non CPython implementation
try:
return obj.__repr__.__code__.co_filename in (
dataclasses.__file__,
reprlib.__file__,
)
except Exception: # pragma: no coverage
return False
The actual logic rich’s pprint uses does also use is_dataclass but due to the check for reprlib.__file__ will incorrectly identify any __repr__ function on a dataclass that is wrapped in @recursive_repr as being a default dataclasses repr that it can replace.
This method would (eventually) be eliminated and replaced with is_dataclass_method(obj.__repr__).
I also see a number of uses of object.__dataclass_params__.frozen on Github[2] to check for frozen dataclasses. Note that as-is this can also detect non-dataclass subclasses of frozen dataclasses as being frozen, which as mentioned before isn’t really accurate.
There are also cases of users trying to type hint __dataclass_params__ and needing to ignore errors or use Any because _DataclassParams isn’t public and as such isn’t in typeshed so type checkers don’t believe it exists.