bagof-magic
Classes that build themselves from your type hints.
Write your fields as annotations. Magic generates __init__, __repr__,
__eq__, and everything else.
from bagof.magic import Magic
class Point(Magic, frozen=True):
x: float
y: float
>>> Point(1.0, 2.0)
Point(x=1.0, y=2.0)
>>> Point(1.0, 2.0) == Point(1.0, 2.0)
True
>>> Point(1.0, 2.0).x = 3.0
Traceback (most recent call last):
AttributeError: Cannot set frozen field 'x'
If you prefer a decorator:
=== "Base class"
```python
from bagof.magic import Magic
class Point(Magic, frozen=True):
x: float
y: float
```
=== "Decorator"
```python
from bagof.magic import magic
@magic(frozen=True)
class Point:
x: float
y: float
```
Three things that set it apart
Settings are inherited
Set an option on a base class. Every subclass keeps it.
class Record(Magic, frozen=True, kw_only=True):
id: int
class User(Record):
name: str
>>> User(id=1, name="ada")
User(id=1, name='ada')
A subclass can change any setting. override=True makes the change apply
to inherited fields too:
class Draft(Record, frozen=False, override=True):
note: str = ""
>>> draft = Draft(id=1)
>>> draft.id = 2
>>> draft
Draft(id=2, note='')
A field that names its own preference in its annotation (like Frozen[int]
or KwOnly[int]) keeps it regardless.
Per-field behaviour lives in the annotation
Each field says what it does, right where it is declared:
from bagof.magic import Magic, Factory, KwOnly, NoRepr
class Task(Magic):
name: str
tags: Factory[list] # a fresh list per instance
token: NoRepr[str] = "" # present, but hidden from repr
priority: KwOnly[int] = 0 # keyword-only argument
>>> Task("build", ["ci"], priority=2)
Task(name='build', tags=['ci'], priority=2)
The field(...) spelling from dataclasses and attrs also works. Both
produce the same Field:
=== "In the annotation"
```python
from bagof.magic import Magic, Factory, NoRepr
class Task(Magic):
name: str
tags: Factory[list]
token: NoRepr[str] = ""
```
=== "As the default"
```python
from bagof.magic import Magic, field
class Task(Magic):
name: str
tags: list = field(factory=list)
token: str = field(default="", repr=False)
```
The annotation form composes naturally. Several annotations stack on one
field without nesting. The field(...) form covers anything the annotations
cannot say.
Field(...) is the same thing with a capital letter. Prefer the lowercase
field(...) when using it as a default value: mypy reads
tags: list = Field(factory=list) as assigning a Field to a list slot,
while field(...) says it produces whatever the annotation requires.
Conversion and validation come from the type
Turn them on and the type hint does the work:
class Config(Magic, convert=True, validate=True):
host: str
port: int = 8080
>>> Config("localhost", "9000")
Config(host='localhost', port=9000)
"9000" became 9000 because the hint said int. To convert only some
fields, mark them individually:
=== "Whole class"
```python
class Config(Magic, convert=True):
host: str
port: int = 8080
```
=== "One field"
```python
from bagof.magic import ConvertTo
class Config(Magic):
host: str
port: ConvertTo[int] = 8080
```
The rules come from bagof-converters and
bagof-validators. Anything they understand (nested
containers, unions, enums, TypedDict, dates, paths, numpy arrays) works
here too.
A type hint can name something that does not exist yet: a class that refers
to itself, a name defined later in the file, a type imported only under
if TYPE_CHECKING. The name is looked up the first time the field is used.
By then the module has finished loading, so forward references simply work:
class Router(Magic, convert=True):
port: "Port" = 8080
class Port(int):
pass
If the name is still missing at first use, the field carries on unconverted and unvalidated, and says so once:
Router.port: the name `Port` is not defined, so `port` is not being
converted.
A field that needs its type to build a default has nothing to fall back on, so it raises instead.
unresolved_hints controls the report: "warn" (the default), "raise",
or "ignore". Setting "raise" is worth doing in CI, where an unresolved
hint is a mistake rather than something to tolerate:
class Service(Magic, convert=True, unresolved_hints="raise"):
port: int = 8080
Leaving the defaults alone
A default is converted and validated like any other value. Sometimes only the incoming values need the attention:
class Node(Magic, convert=True, convert_defaults=False):
name: str
parent: "Node" = None
>>> Node("root")
Node(name='root', parent=None)
parent: Optional["Node"] is the precise spelling and needs nothing turned
off. But when the defaults in a class are already exactly right,
convert_defaults=False and validate_defaults=False take them as
written. Values a caller passes are still converted and validated, as are
values assigned afterwards.
Also included
Dict-like access, when you want it:
class Row(Magic, mapping=True):
name: str
age: int
>>> dict(Row("ada", 36))
{'name': 'ada', 'age': 36}
A key is present while its field holds a value. A field the constructor does not take, and that has no default, holds nothing until something sets it. It stays out of the view until then:
>>> class Draft(Magic, mapping=True):
... title: str
... slug: NoInit[str]
>>> draft = Draft("Ada")
>>> dict(draft)
{'title': 'Ada'}
>>> draft["slug"] = "ada"
>>> dict(draft)
{'title': 'Ada', 'slug': 'ada'}
On a frozen class, __post_init__ cannot set such a field by assignment.
Use object.__setattr__, the same approach dataclasses and attrs
require:
>>> class Slug(Magic, frozen=True):
... title: str
... slug: NoInit[str]
...
... def __post_init__(self, arguments):
... object.__setattr__(self, "slug", self.title.lower())
>>> Slug("Hello World")
Slug(title='Hello World', slug='hello world')
Functions that work on any Magic class (using Point from above):
>>> from bagof.magic import replace, asdict, astuple
>>> replace(Point(1.0, 2.0), y=20.0)
Point(x=1.0, y=20.0)
>>> asdict(Point(1.0, 2.0))
{'x': 1.0, 'y': 2.0}
>>> astuple(Point(1.0, 2.0))
(1.0, 2.0)
replace builds the copy by calling the class again, so conversion,
validation and init hooks all run on the new values. This is also why it
works on a frozen class. The other side of that: a __post_init__ that
derives one field from another will derive it again from the already-derived
value. asdict recurses into nested Magic instances. Everything else is
returned as-is. A field with no value is left out. astuple raises instead,
because a missing position would shift everything after it.
There is also fields and fields_dict for the fields themselves, and
is_magic to ask whether a class was built by Magic.
A field with no value is left out of repr() too, so a
partially-filled object still prints cleanly. Equality counts it: two
objects are equal when the same fields hold values and those values
match. hash agrees.
>>> class Draft(Magic):
... title: str
... slug: NoInit[str]
>>> Draft("Ada")
Draft(title='Ada')
>>> Draft("Ada") == Draft("Ada")
True
>>> ada = Draft("Ada")
>>> ada.slug = "ada"
>>> ada
Draft(title='Ada', slug='ada')
>>> ada == Draft("Ada")
False
Mutable defaults that are not shared. In a plain class, x: list = []
gives every instance the same list. Here each one gets its own:
>>> class Basket(Magic):
... items: list = []
>>> first, second = Basket(), Basket()
>>> first.items.append("apple")
>>> second.items
[]
Set mutable_default="raise" to reject mutable defaults at class
definition time, the way dataclasses and attrs do. Or "allow" when
one shared object is what you want.
Hooks around construction. Write __pre_init__ or __post_init__
and it runs during construction. Give it a parameter and it receives
everything the constructor was called with. __pre_init__ sees values as
passed. __post_init__ sees them as stored:
from bagof.magic import Magic, InitVar
class Circle(Magic):
radius: float
scale: InitVar[float] = 1.0 # passed in, used, not kept
def __post_init__(self, arguments):
self.radius = self.radius * arguments.scale
>>> Circle(2.0, scale=3.0)
Circle(radius=6.0)
Generic classes. A Magic class can take a type parameter, and
filling it in gives the fields that type — whether you name a subclass or
fill it in at the call site:
from typing import Generic, TypeVar
from bagof.magic import Magic
T = TypeVar("T")
class Box(Magic, Generic[T], convert=True):
item: T
class IntBox(Box[int]):
pass
>>> Box("1")
Box(item='1')
>>> IntBox("1")
IntBox(item=1)
>>> Box[int]("1")
Box[int](item=1)
item is T on Box (no specific type to convert to) and int once the
parameter is filled in — which is why the string becomes a number. Both
spellings do the same thing: Box[int] is a class just as IntBox is,
and Box[int]("1") == Box(1). This works the same way when the parameter
is nested (List[T], Optional[T], Dict[str, T]).
Documentation that writes itself. Describe a field and it shows up in
the class docstring and in the generated __init__:
from bagof.magic import Doc
class Retry(Magic):
"""Retry policy."""
times: Doc[int, "how many times to try again"] = 3
delay: Doc[float, "seconds to wait between tries"] = 0.5
>>> print(Retry.__doc__)
Retry policy.
<BLANKLINE>
Attributes
----------
times : int, default=3
how many times to try again
delay : float, default=0.5
seconds to wait between tries
<BLANKLINE>
<BLANKLINE>
Building the right subclass
A class can hand back one of its subclasses, chosen from the arguments:
class Chord(Magic, polymorphic=True):
root: str
mode: str = "major"
variant: str = "natural"
class MinorChord(Chord, on={"mode": "minor"}):
def thirds(self) -> int:
return 3
>>> Chord(root="A", mode="minor")
MinorChord(root='A', mode='minor', variant='natural')
>>> Chord(root="C")
Chord(root='C', mode='major', variant='natural')
A default counts the same as a value the caller passed, so Chord(root="C")
and Chord(root="C", mode="major") always produce the same class.
MinorChord does not need to write mode out again. Matching on one exact
value gives the field that value as its default, so the subclass can be
built on its own:
>>> MinorChord(root="A")
MinorChord(root='A', mode='minor', variant='natural')
Saying what a subclass stands for
A constraint is a value to equal, a set to belong to, a pattern to match, a type to fit, or a question to answer:
| Written as | Matches when |
|---|---|
"minor" |
the argument equals it |
{"minor", "aeolian"} |
the argument is one of them |
re.compile(r"m(in)?") |
the pattern matches the whole argument |
int, Literal["a", "b"] |
the argument fits the type |
lambda v: v > 3 |
the call answers yes |
... |
the argument was given at all |
A subclass is in the running when every constraint it declares matches.
Which subclass wins
More conditions beats fewer. A narrower condition beats a wider one. In order: how many fields the subclass constrains, then how precise the constraints are (exact value, then set, then pattern, then type), then how far down the hierarchy the subclass sits.
Import order is never considered. Two subclasses that no rule separates
raise AmbiguousPolymorphError. Use priority= to settle it. It is also
how you spell "when nothing else fits", since a subclass that constrains
nothing matches everything:
class Note(Magic, polymorphic=True):
name: str
class Sharp(Note, on={"name": lambda name: name.endswith("#")}):
pass
class Natural(Note, on={}, priority=-1):
pass
>>> Note("C#")
Sharp(name='C#')
>>> Note("C")
Natural(name='C')
Narrowing more than once
A subclass of a subclass registers with its parent, so each step narrows the choice:
class HarmonicMinor(MinorChord, on={"variant": "harmonic"}):
pass
>>> Chord(root="A", mode="minor", variant="harmonic")
HarmonicMinor(root='A', mode='minor', variant='harmonic')
Reaching HarmonicMinor means satisfying MinorChord first. Ask for
variant="harmonic" without a mode and the first step matches nothing:
>>> Chord(root="A", variant="harmonic")
Chord(root='A', mode='major', variant='harmonic')
Registering a class you did not write
>>> class Diminished(Chord):
... pass
...
>>> Chord.register_polymorph(Diminished, mode="dim")
<class '...Diminished'>
>>> Chord(root="B", mode="dim")
Diminished(root='B', mode='dim', variant='natural')
Registering later only changes what is built later. Existing instances are untouched.
The two settings
polymorphic="strict" refuses to build the class itself. It names the
subclasses it considered. This is how a missing import shows up as a missing
import, rather than as a dispatch that quietly did nothing. A class that is
itself registered somewhere is exempt: building it is the whole point of
having registered, so a leaf with no subclasses of its own works normally.
pin_discriminant decides what the matched field becomes on the subclass.
"pin" (the default) gives it that value as a default. It stays in repr,
in ==, and in anything that walks the fields. "classvar" makes it a
class attribute, stored once rather than once per instance. The constructor
still accepts and discards the value, so both
Chord(root="A", mode="sus") and SusChord(root="A", mode="sus") keep
working. "keep" leaves the field exactly as the subclass wrote it.
A pinned value is a default the class author wrote. It is converted,
validated and copied per instance, exactly as mode: str = "minor" would
be. convert_defaults and validate_defaults apply to it the same way.
class SusChord(Chord, on={"mode": "sus"}, pin_discriminant="classvar"):
pass
>>> SusChord.mode
'sus'
>>> SusChord(root="B")
SusChord(root='B', variant='natural')
!!! warning "classvar and round trips"
Under "classvar" the discriminant is no longer one of the instance's
fields, so asdict leaves it out. A dictionary without it cannot be
dispatched back to the same subclass. Use "pin" whenever the values
have to survive a round trip through a config file or a database.
Writing the class attribute yourself, mode: ClassVar[str] = "minor", is
refused. The error explains why: the base passes mode on to whatever it
builds, so a subclass whose constructor does not take it would break.
pin_discriminant="classvar" is that spelling, done so that both calls
keep working.
Pickling and copying rebuild through the class an instance already has. Neither goes back through the dispatch.
Input aliases and forwarding properties
Give a field several input names with Alias. The first name is the
one shown in the signature, repr and dict-like view. The stored attribute
keeps the name written in the class body:
from bagof.magic import Magic, Alias, Property, ReadOnlyProperty, field, replace
class Person(Magic):
name: Alias[str, ("label", "name", "title")]
>>> Person(title="Ada")
Person(label='Ada')
>>> Person(name="Ada").name
'Ada'
>>> replace(Person("Ada"), title="Grace")
Person(label='Grace')
A single string replaces the input name as before. A tuple or list accepts all its names, in order. Include the stored name explicitly if callers should be able to use it too. Supplying two names for the same field is an error, even with equal values. A positional value and an alias keyword also count as two values. Aliases do not enable keyword arguments for a positional-only field.
Property exposes additional attributes backed by that same field:
class Named(Magic, alias=True):
name: Property[str, {"label": "readwrite", "title": "readonly"}]
>>> named = Named(title="Ada")
>>> named.label
'Ada'
>>> named.label = "Grace"
>>> named.name, named.title
('Grace', 'Grace')
>>> named.title = "Ada"
Traceback (most recent call last):
AttributeError: ...
A string or sequence of names creates read/write properties. A mapping
chooses access for each name: True and "readwrite" are equivalent,
"readonly" forbids assignment, and False disables the property.
Deletion through a property is unsupported. Writes use the target field's
conversion, validation and frozen rules. Read-only access does not freeze
an object returned by the property.
ReadOnlyProperty is Property with every name read-only, so you can
skip the mapping when none of them should be writable:
class Named(Magic, alias=True):
name: ReadOnlyProperty[str, ("label", "title")]
Stacked Property (or ReadOnlyProperty) hints accumulate: the outer
adds its names to the inner rather than replacing them, and a name given
on both takes the outer access mode. Alias hints stack the same way --
they concatenate, keeping the first spelling of a repeated name -- and so
does field metadata. Stacked ConvertTo or Validate callables chain,
the inner running first. Everything else -- a default, a factory, a
whole-field toggle like property="all" -- is last-wins, the outer
replacing the inner.
The equivalent field declaration is:
class Named(Magic):
name: str = field(
alias=True,
property={"label": True, "title": "readonly"},
)
alias=True keeps the default public name first and adds enabled property
names in declaration order. With no properties it keeps the default name.
alias=False preserves leading underscores. Field.aliases always returns
a tuple, and Field.public_name is its first element.
property=True exposes the preferred public name as a read/write property;
property="readonly" exposes it read-only. If that name is already the
stored name, no extra property is needed. property="all" exposes every
input name the field accepts instead: the stored name is left out (it is
already the attribute), and any name already used for a field, method or
other attribute is skipped rather than clashing. To expose an attribute
literally called readonly, readwrite or all, use a sequence or
mapping. Explicit properties (a name, sequence or mapping) cannot target
themselves, other fields, or existing methods. Properties are for stored
instance fields, not ClassVars or InitVars.
Both options can be class settings. Inherited fields keep their settings
unless override=True (or override="alias" / override="property") asks
them to resolve again. Explicit field settings still win. Redeclaring a
field can replace its names and remove its old properties without changing
the base class. Different fields cannot share an alias or property name,
including across bases; remove the old declaration before reusing a name.
Properties add no fields or storage. Mapping access and serialization use
only the preferred public key. Constructor hooks receive preferred names;
polymorphic selection and replace accept alternate input names too.
Hand-written constructors remain responsible for their own arguments;
calling self.__magic_init__ uses the generated alias handling.
Signatures show the preferred name, and constructor documentation lists alternatives. Static type checkers do not infer all accepted synonyms or generated properties from these annotations.
The annotations
Each of these can be used bare (x: Frozen[int]) or with a value
(x: Default[int, 5]). Every one has an opposite.
| Annotation | What it does | Opposite |
|---|---|---|
Alias[T, names] |
accept input names, preferred first | -- |
Property[T, names] |
expose forwarding attributes | -- |
ReadOnlyProperty[T, names] |
expose read-only forwarding attributes | -- |
Default[T, v] |
give the field a default | -- |
Factory[T] |
build the default by calling something | -- |
ConvertTo[T] |
convert whatever comes in | -- |
Validate[T] |
reject anything that does not fit | -- |
Init[T] |
say it is an argument, which it is anyway | NoInit |
Kw[T] |
may be passed by name | NotKw |
Positional[T] |
may be passed by position | NotPositional |
KwOnly[T] |
by name only | NotKwOnly |
PositionalOnly[T] |
by position only | NotPositionalOnly |
Frozen[T] |
cannot be changed afterwards | NotFrozen |
Repr[T] |
show in repr() |
NoRepr |
Eq[T] |
count towards == |
NoEq |
Order[T] |
count towards < |
NoOrder |
Compare[T] |
both of the above | NoCompare |
Hash[T] |
count towards hash() |
NoHash |
Key[T] |
appear in the dict-like view | NotKey |
ClassVar[T] |
shared by every instance | -- |
InitVar[T] |
passed in, used, not kept | -- |
Doc[T, "..."] |
describe the field | -- |
Each annotation sets exactly what its name says, and that wins over the
class setting: on a kw_only=True class, x: Positional[int] can still
be passed by position, while x: NotKw[int] forbids the only way left and
the field takes its default instead (the same as NoInit[int]).
Several annotations stack on one field by nesting. When two disagree, the outer one wins.
>>> class ByName(Magic):
... x: Kw[NotKw[int]] = 0
...
>>> ByName(x=1)
ByName(x=1)
>>> class ByPosition(Magic):
... x: NotKw[Kw[int]] = 0
...
>>> ByPosition(1)
ByPosition(x=1)
Init and NoInit say whether a field is an argument at all. Kw,
Positional and the ...Only pairs say how it may be passed. A field
is an argument unless something says otherwise, so Init[T] changes
nothing. NoInit does the work by forbidding both ways at once.
NotKwOnly means "by position as well" and NotPositionalOnly means "by
name as well": each negates its own name and leaves the other half alone.
This makes them aliases for Positional and Kw.
One thing to know: on a kw_only=True class, a field that can be passed by
position moves to the front of the signature, ahead of the keyword-only
fields, regardless of its declaration order.
class Point(Magic, kw_only=True):
a: int
x: Positional[int]
>>> Point(1, a=2)
Point(a=2, x=1)
x is declared second but becomes the first positional argument.
Anything you cannot say with one of these, say with Field(...) inside an
Annotated: x: Annotated[int, Field(alias="ex", metadata={"unit": "m"})].
(On Python 3.8, import Annotated from typing_extensions rather than
typing.)
The class settings
class Thing(Magic, frozen=True, kw_only=True, slots=True):
...
| Setting | Default | What it does |
|---|---|---|
init |
True |
generate __init__ |
repr |
True |
generate __repr__ |
eq |
True |
generate __eq__ |
order |
False |
generate the comparisons |
hash |
None |
generate __hash__; decides for itself by default |
unsafe_hash |
False |
generate one even when the class is mutable |
frozen |
False |
refuse assignment after construction |
match_args |
False |
support structural pattern matching |
kw_only |
False |
every field must be passed by name |
positional_only |
False |
every field must be passed by position |
slots |
False |
use __slots__, and drop __dict__ |
weakref_slot |
False |
allow weak references under slots |
convert |
False |
convert every field from its type |
validate |
False |
check every field against its type |
convert_defaults |
True |
convert a value that came from a default, too |
validate_defaults |
True |
check a value that came from a default, too |
unresolved_hints |
"warn" |
what to do when a type hint still names something undefined the first time a field needs it; or "raise", or "ignore" |
factory |
False |
build every missing default from its type |
mutable_default |
"factory" |
give each instance its own copy of x: list = []; or "raise", or "allow" |
alias |
default public name | input names; True includes enabled property names, False keeps underscores |
property |
False |
forwarding attributes; True or "readonly" exposes the public name, "all" every input alias |
mapping |
False |
behave like a dictionary; a subclass inherits the methods and cannot turn them off |
override |
False |
apply this class's settings to inherited fields too |
polymorphic |
False |
build one of this class's subclasses, chosen from the arguments; or "strict", which refuses to build this class when none of them matches |
pin_discriminant |
"pin" |
what a subclass does with the field it matches on; or "classvar", or "keep" |
reverse |
False |
list a subclass's own fields before inherited ones |
doc |
True |
add the field table to the class docstring |
Most of them also accept a string instead of True, which binds the
generated method under that name. This is useful when you want to call the
generated method from your own.
How it compares
Close to attrs in spirit, with pydantic's habit of doing real work from your type hints, and inheritance where the others use decorators.
| dataclasses | attrs | pydantic | magic | |
|---|---|---|---|---|
| settings inherited by subclasses | no | no | yes | yes |
| per-field behaviour in the annotation | no | no | partly | yes |
| conversion from the type hint | no | partly | yes | yes |
| validation from the type hint | no | partly | yes | yes |
| dict-like instances | no | no | partly | yes |
| no methods added unless asked | yes | yes | no | yes |
There is a fuller side-by-side in the comparison page.
Install
pip install git+https://github.com/bagofseeds/bagof-magic.git
Python 3.8 and later.
Status
Early. The API is settling, and things may still move. Issues and ideas are welcome at bagofseeds/bagof-magic.
Metadata
Release files for bagof-magic 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| bagof_magic-0.2.0.tar.gz | 174.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| bagof_magic-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 277.8 kB
Release files / bagof_magic-0.2.0.tar.gz
| Download URL | bagof_magic-0.2.0.tar.gz |
|---|---|
| Size | 174.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
4cceaa17cf93c4e8717e27324b9dce7ea1f5ef8363c6c87d1b5926fde1f04603
|
|
BLAKE2b-256 checksum How to use checksums |
dc645e083941685b52c4a9a3f58cb5f078c4a53419d61f236de41261590b76d5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / bagof_magic-0.2.0-py3-none-any.whl
| Download URL | bagof_magic-0.2.0-py3-none-any.whl |
|---|---|
| Size | 103.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
20c2a2ca11c74230fca31661385c3aee00c7ca7f07d59fcf48e9d503fc25e377
|
|
BLAKE2b-256 checksum How to use checksums |
d7facb7d816d288c6bad93d3920c86be46a2fd73b7bf330199092237d96a07ef
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|