In the meantime and following Cornelius Krupp suggestion around the @ operator, I have implemented a decorator which supports that functionality.
It is a first draft, a working one, as it does some naive assumption (such as: declared attributes have a default value), but I will work on that. I simply wanted to see how it would work and I have to say that the syntax is rather appealing, a lot more than using the context manager I would say.
#!/usr/bin/env python
# -*- coding: utf-8; py-indent-offset:4 -*-
###############################################################################
from __future__ import annotations
from collections.abc import Callable
import dataclasses
# Imports meant for re-export - ignore non-used and values cannot be determined
from dataclasses import * # noqa: F403 F401
# Specific imprts for development error-checking
from dataclasses import dataclass as _dataclass, KW_ONLY
import inspect
from typing import overload
__all__ = [
"at_dataclass",
] + dataclasses.__all__
@overload
def ann_dataclass(cls: None = None, **kwargs) -> Callable[[type], type]:
...
@overload
def ann_dataclass(cls: type, **kwargs) -> type:
...
class _NO_FIELD_TYPE:
pass
NO_FIELD = _NO_FIELD_TYPE()
class _NO_INIT_TYPE:
pass
NO_INIT = _NO_INIT_TYPE()
class _NO_INIT_FACTORY_TYPE:
pass
NO_INIT_FACTORY = _NO_INIT_FACTORY_TYPE()
ANN_MARKER = "@"
WSPACE = " "
ANN_OPEN = "["
ANN_CLOSE = "]"
def at_dataclass(cls: type | None = None, **kwargs) -> type | Callable[[type], type]:
# actual decorator for when cls is not None
def _annotifier(cls: type) -> type:
# Fetch the annotations using latest best practices with eval_str=True
# because from __future__ import annotations mey the default in the future
# print(f"{cls = }")
no_fields = {}
for name, annotation in inspect.get_annotations(cls).items():
if not (type(annotation) is str):
continue
try:
_type, f_ann = annotation.split(maxsplit=1)
except ValueError:
continue # splitting was not possible
if not f_ann.startswith(ANN_MARKER):
continue
if not f_ann[1] == WSPACE:
_, s_subannotations = f_ann.split(WSPACE, maxsplit=1)
else:
s_subannotations = f_ann[1:]
if s_subannotations[1] == ANN_OPEN and s_subannotations[-1] == ANN_CLOSE:
subannotations = eval(s_subannotations)
else:
subannotations = eval(f"[{s_subannotations}]")
if NO_FIELD in subannotations: # remove from annotations
cls.__annotations__.pop(name)
no_fields[name] = _type
elif NO_INIT in subannotations:
setattr(cls, name, field(init=False, default=getattr(cls, name)))
cls.__annotations__[name] = _type
elif NO_INIT_FACTORY in subannotations:
setattr(cls, name, field(init=False, default_factory=getattr(cls, name)))
cls.__annotations__[name] = _type
elif KW_ONLY in subannotations:
setattr(cls, name, field(kw_only=True, default=getattr(cls, name)))
cls.__annotations__[name] = _type
dataclassed = _dataclass(cls, **kwargs) # apply std dataclass processing
for name, annotation in no_fields.items():
dataclassed.__annotations__[name] = no_fields[name]
return dataclassed
# decorator functionality when kwargs are used, return real deco (with closure)
if cls is None:
return _annotifier # -> Callable[[type], type]
# A cls is there, process it
return _annotifier(cls) # -> type
# With everything done export ann_dataclass as dataclass
dataclass = at_dataclass
class Dummy:
pass
# Small test
if __name__ == "__main__":
from dataclasses import field, fields
from typing import ClassVar
@dataclass
class A:
cv: ClassVar[str] = "classvar"
a: int = 5
e: int @ KW_ONLY = 25
b: int @ NO_FIELD = 7
c: int @ NO_INIT = 0
d: int @ [NO_INIT, Dummy] = 0
a = A()
print("=" * 80)
print(f"{a.__annotations__ = }")
print("=" * 80)
print(f"{a.cv = }")
print(f"{a.a = }")
print(f"{a.b = }")
for f in fields(A):
print("-- " + "-" * 70)
print(f"{f = }")
print("-" * 70)
try:
b = A(a=1, b=2)
except Exception as e:
print(f"Exception: {e = }")
try:
b = A(a=1, c=2)
except Exception as e:
print(f"Exception: {e = }")
try:
b = A(a=1, d=2)
except Exception as e:
print(f"Exception: {e = }")
try:
b = A(1, 2)
except Exception as e:
print(f"Exception: {e = }")
b = A(1, e=2)
The output of the test cases
================================================================================
a.__annotations__ = {'cv': 'ClassVar[str]', 'a': 'int', 'e': 'int', 'c': 'int', 'd': 'int', 'b': 'int'}
================================================================================
a.cv = 'classvar'
a.a = 5
a.b = 7
-- ----------------------------------------------------------------------
f = Field(name='a',type='int',default=5,default_factory=<dataclasses._MISSING_TYPE object at 0x0000016145B01DD0>,init=True,repr=True,hash=None,compare=True,metadata=mappingproxy({}),kw_only=False,_field_type=_FIELD)
-- ----------------------------------------------------------------------
f = Field(name='e',type='int',default=25,default_factory=<dataclasses._MISSING_TYPE object at 0x0000016145B01DD0>,init=True,repr=True,hash=None,compare=True,metadata=mappingproxy({}),kw_only=True,_field_type=_FIELD)
-- ----------------------------------------------------------------------
f = Field(name='c',type='int',default=0,default_factory=<dataclasses._MISSING_TYPE object at 0x0000016145B01DD0>,init=False,repr=True,hash=None,compare=True,metadata=mappingproxy({}),kw_only=False,_field_type=_FIELD)
-- ----------------------------------------------------------------------
f = Field(name='d',type='int',default=0,default_factory=<dataclasses._MISSING_TYPE object at 0x0000016145B01DD0>,init=False,repr=True,hash=None,compare=True,metadata=mappingproxy({}),kw_only=False,_field_type=_FIELD)
----------------------------------------------------------------------
Exception: e = TypeError("A.__init__() got an unexpected keyword argument 'b'")
Exception: e = TypeError("A.__init__() got an unexpected keyword argument 'c'")
Exception: e = TypeError("A.__init__() got an unexpected keyword argument 'd'")
Exception: e = TypeError('A.__init__() takes from 1 to 2 positional arguments but 3 were given')