Attributes
A framework usually needs to know things about a user’s code that the code itself doesn’t say: which classes are
plugins, which function or methods handle a callback. An attribute is a decorator that carries that data and
remembers where it was applied - the same idea as
.NET attributes,
which is where pyTooling.Attributes takes it from.
This tutorial builds one from scratch: a plug-in system where a plug-in is a class, its metadata travels with it, and the framework finds every plug-in without the plug-in ever calling the framework.
@Plugin(name="MyPlugin", version="1.0.0")
class MyPlugin:
...
for plugin in Plugin.GetClasses():
... # every class annotated with @Plugin, without a registry anywhere
Three ways to know what a program declares
Attributes are the third of them, and the differences are worth seeing side by side before the mechanics.
Approach |
How the framework learns |
Costs |
|---|---|---|
imperative |
the plug-in module calls |
the call is separate from the thing it registers, so the two drift; and every plug-in has to remember to make it |
meta-class / registry |
a base-class’ |
nothing to remember, but it only reaches classes, it forces the plug-in to derive from your base-class, and the metadata has nowhere to live except more class variables |
attribute |
the decorator records itself on the entity, and the attribute class keeps a registry of where it was applied |
reaches classes, methods and functions, carries typed data of its own, and leaves the plug-in’s inheritance alone |
The registry approach is not a poor relation - Attribute uses
__init_subclass__() internally for exactly that reason, to give every derived attribute class its own
registry. It is the right tool when being a subclass is already the thing you want to enumerate. It stops being
the right tool the moment the annotation carries data, or has to sit on a function.
See also
- Overview
→ The reference for
pyTooling.Attributes, including the predefined attributes.- Overview
→ The decorator mechanics an attribute is built on.
When an attribute is the right tool
Reach for one when the data belongs to the entity, and the framework has to find the entity by the data. Both halves matter:
Use instead |
When |
|---|---|
a plain decorator |
the decorator changes what the function does. An attribute annotates, it doesn’t wrap. |
a class field or a constant |
nothing ever searches for it. A dictionary is simpler than an attribute class. |
a naming convention |
the framework can find entities by name and the name is not also the description - |
an attribute |
the annotation is data - a name, a version, a priority - and something has to enumerate every entity carrying it. |
A plug-in system needs both halves at once, which is why it is the example this tutorial uses: the name and version belong to the plug-in class, and the host application has to enumerate every plug-in it can find.
Example 1: a class-based plug-in mechanism
A plug-in is a class, its metadata travels with it, and the host finds every plug-in without the plug-in ever calling the host. Four steps: define the attribute, apply it, load the modules, find what was annotated.
Step 1: define an attribute class
Derive from Attribute and give the initializer the parameters the annotation should
carry. Expose them as read-only properties, because whoever finds the annotation later has to read them.
The parameter is accepted as a string and stored as a SemanticVersion, so two
plug-in versions can be compared instead of only printed.
That is the whole definition. Two things come from the base-class: __call__, which is what lets the class be
used as a decorator, and the registry that the Get*** methods read.
Hint
For a quick and simple annotation that carries positional or keyword data and nothing else,
SimpleAttribute skips the class definition entirely:
from pyTooling.Attributes import SimpleAttribute
@SimpleAttribute(kind="reader", order=3)
class MarkdownReader:
...
attribute = SimpleAttribute.GetAttributes(MarkdownReader)[0]
attribute.Args # -> ()
attribute.KwArgs # -> {'kind': 'reader', 'order': 3}
from pyTooling.Attributes import Attribute
from pyTooling.Decorators import export, readonly
from pyTooling.Versioning import SemanticVersion
@export
class Plugin(Attribute):
"""Marks a class as a plug-in of this application."""
_name: str #: Name the plug-in is selected by.
_version: SemanticVersion #: Version of the plug-in, as its author declares it.
def __init__(self, name: str, version: str = "0.0.0") -> None:
self._name = name
self._version = SemanticVersion.Parse(version)
@readonly
def Name(self) -> str:
return self._name
@readonly
def Version(self) -> SemanticVersion:
return self._version
Step 2: apply it
An attribute goes on a class, a method or a function. Several attributes stack, and an entity may carry the same attribute class more than once.
The plug-in declares what it is through the attribute, and what it does through an ordinary base-class the
application defines. The two are independent, and that separation is the point: Reader is the interface, and
@Plugin is the metadata.
An attribute carrying no data at all is written without parentheses - @Plugin rather than
@Plugin() - but a plug-in almost always has a name, so the parenthesised form is the usual one here.
Important
The interface is built with ExtendedType, and that is not decoration. It
makes abstractmethod() reject a plug-in that forgot to implement Read,
and - as the second example shows - it is what makes annotated methods
findable at all. A plug-in class alone does not need it; a plug-in class whose methods carry attributes does.
from pathlib import Path
from pyTooling.MetaClasses import ExtendedType, abstractmethod
# the application's own interface
class Reader(metaclass=ExtendedType):
@abstractmethod
def Read(self, path: Path) -> str:
"""Read the document at 'path' and return its text."""
# plugins/markdown.py
@Plugin(name="markdown", version="1.2.0")
class MarkdownReader(Reader):
def Read(self, path: Path) -> str:
return path.read_text(encoding="utf-8")
Step 3: load the plug-in modules
Attention
An entity is registered when its module is imported, because that is when the decorator runs. A plug-in in a
module nothing imports is invisible to GetClasses().
This is the single thing that catches people out, and for a plug-in system it is not an edge case - it is the mechanism. Discovery is two steps that are easy to mistake for one: import the modules, then ask the attribute. A framework that only does the second finds nothing and looks broken.
Before that sweep Plugin.GetClasses() yields nothing; afterwards it yields one class per imported
plug-in. Nothing else changed - the registry was simply empty because no decorator had run yet.
Tip
Scanning a directory is the simplest loader and the right one for plug-ins shipped inside the application. For
plug-ins installed as separate distributions, use
entry points: the installer records
them, importlib.metadata.entry_points() lists them without importing anything, and calling
load() on one imports that module - at which point the attribute registry
fills exactly as above. The two compose; the attribute doesn’t care which one imported the module.
from importlib import import_module
from pathlib import Path
def LoadPlugins(directory: Path) -> None:
"""Import every plug-in module, so its decorators run and register what they annotate."""
for module in sorted(directory.glob("*.py")):
if not module.stem.startswith("_"):
import_module(f"{directory.name}.{module.stem}")
Step 4: find what was annotated
Three class-methods answer the three kinds of entity, and each yields what was annotated - not the attribute instances:
Method |
Yields |
|---|---|
every class annotated with |
|
every function annotated with it |
|
every method annotated with it |
|
the attribute instances on one entity - this is where the data is read |
So finding the plug-ins and reading their metadata are two steps:
subclassOf= is what makes this usable in an application with more than one kind of plug-in. It filters the
annotated classes by their own base-class, so one attribute can mark every plug-in while each extension point
asks only for the ones it can use:
scope= restricts the result to entities declared in one module or nested in one class - useful when two
plug-ins declare a class of the same name.
Attention
The registry records one entry per application, so an entity carrying the attribute twice is yielded twice
by GetClasses(). Wrap the result in a set (or dict.fromkeys(), to keep the order) when
each entity should be processed once.
for pluginClass in Plugin.GetClasses():
for attribute in Plugin.GetAttributes(pluginClass):
print(f"{attribute.Name:<10} {attribute.Version} -> {pluginClass.__name__}")
markdown 1.2.0 -> MarkdownReader
asciidoc 0.1.0 -> AsciiDocReader
Plugin.GetClasses(subclassOf=Reader) # -> MarkdownReader, AsciiDocReader
Plugin.GetClasses(subclassOf=Writer) # -> HtmlWriter
Example 2: hooks on a plug-in’s methods
The first example annotated classes. The same mechanism works one level down, on the methods inside a plug-in: a plug-in that reacts to events declares which method handles which event, instead of the host calling methods by a magic name.
This example builds on the first - Plugin still marks the class; Hook marks the methods within it.
Step 1: define a second attribute
An attribute class per kind of annotation. Hook carries the event name:
class Hook(Attribute):
"""Marks a method as the handler of one application event."""
_event: str #: Name of the event this method handles.
def __init__(self, event: str) -> None:
self._event = event
@readonly
def Event(self) -> str:
return self._event
Step 2: ask one object what it offers
GetMethods() returns annotated methods of every class, which is rarely
what a host wants - it holds one plug-in instance and asks what does this one offer? A class built with
ExtendedType answers that itself:
Attention
GetMethodsWithAttributes() returns a dictionary of method to
attributes, so iterating it yields methods. .items() is what gives the pairs; iterating the result directly
and unpacking raises TypeError: cannot unpack non-iterable function object.
OnError is a perfectly ordinary public method; it is absent from the result because it carries no @Hook,
not because of how it is named. That is the whole difference from a naming convention.
The same answer can be had from the attribute instead of from the object, which is the right way round when the host wants every plug-in’s handlers rather than one plug-in’s:
Important
GetMethods() only ever finds methods of classes built with
ExtendedType. When the decorator runs, a method in a class body is still a
plain function - it becomes a method only once the class object exists - so the annotation is filed under
functions. ExtendedType re-files it while creating the class. Without the meta-class, the same method
turns up in GetFunctions() and GetMethods() stays empty.
predicate= accepts an attribute class or an iterable of them, and it matches sub-classes too - which is
the point of the next step.
from pyTooling.MetaClasses import ExtendedType
@Plugin(name="markdown", version="1.2.0")
class MarkdownReader(Reader, metaclass=ExtendedType):
@Hook("open")
def OnOpen(self, path):
...
@Hook("close")
def OnClose(self, path):
...
def OnError(self, error): # not annotated, so not found
...
plugin = MarkdownReader()
for method, attributes in plugin.GetMethodsWithAttributes(predicate=Hook).items():
for attribute in attributes:
print(f"{attribute.Event:<6} -> {method.__name__}")
open -> OnOpen
close -> OnClose
for method in Hook.GetMethods():
for attribute in Hook.GetAttributes(method):
print(f"{attribute.Event:<6} -> {method.__qualname__}")
open -> MarkdownReader.OnOpen
close -> MarkdownReader.OnClose
Step 3: build a hierarchy and filter by it
Attribute classes inherit, and each derived class gets its own registry - so a specialised attribute is found by its own name, and only by its own name:
Important
Base and derived registries are separate, not nested. Plugin.GetClasses() does not return the
class annotated with @ExperimentalPlugin. Each derived class receives fresh registries in
Attribute.__init_subclass__, which is what stops a derived attribute from reporting entities it was never
attached to - and the cost is that the base doesn’t collect its children’s.
So an application wanting every plug-in has to ask each attribute class it defines. Where sub-class matching
is wanted for free, it comes from
GetMethodsWithAttributes() with predicate=Plugin, and from
GetAttributes() with includeSubClasses=True - both of which
test isinstance rather than reading one registry.
class ExperimentalPlugin(Plugin):
"""A plug-in that may be withdrawn without a deprecation period."""
@Plugin(name="markdown", version="1.2.0")
class MarkdownReader(Reader): ...
@ExperimentalPlugin(name="asciidoc", version="0.1.0")
class AsciiDocReader(Reader): ...
ExperimentalPlugin.GetClasses() # -> AsciiDocReader
Plugin.GetClasses() # -> MarkdownReader (only!)
A word on AttributeScope
An attribute class may declare where it is meant to be used:
from pyTooling.Attributes import Attribute, AttributeScope
class Hook(Attribute):
_scope = AttributeScope.Method
Caution
_scope currently documents intent, and nothing enforces it - see
#384. Applying a Class-scoped attribute to a
function is accepted silently, and the function is then registered in a list that attribute’s own scope says it
can never hold.
Two cases have to be told apart, because only one of them is fixable where the attribute is applied:
class versus function/method is decidable at that moment, so this mismatch should raise and today does not;
method versus function is not decidable there - as the previous example explains, a method is still a plain function while the decorator runs.
So until #384 is resolved: check the entity kind yourself if a misapplication has to fail.
Note also that _scope reads back from an attribute instance - Hook("open").Scope - and not
from the class.
Attribute use cases
Plug-ins are one application of the pattern. It recurs wherever a framework has to find code by what it means rather than by what it is called - and three of those are inside pyTooling itself.
Finding testcases and testsuites
A test runner finds tests by a magic name - test_* - which forces the identifier to be both the selector and
the description, and leaves nowhere to put a title with spaces in it. An attribute separates the two:
The method keeps a name a developer can type on a command line, and the annotation carries the sentence a report
should print. It is the same trade the plug-in example makes: MarkdownReader keeps a class name Python can
import, and @Plugin(name="markdown") carries the name a user types.
pyTooling ships this as finding testcases and testsuites.
class testcase(Attribute):
"""Marks a method as a testcase and gives it a human-readable title."""
def __init__(self, title: str) -> None:
self._title = title
@readonly
def Title(self) -> str:
return self._title
class ArithmeticTests(metaclass=ExtendedType):
@testcase("adds two positive numbers")
def CheckAddition(self) -> None:
...
Declarative argparse
A command line parser is usually built imperatively - one add_argument() call per flag, far away from the
function that handles it. ArgParse inverts that: the commands, their flags and their handlers are
attributes on the handler methods, and the ArgumentParser is assembled from them.
The flag and the code that receives it cannot drift apart, because they are the same declaration.
@CommandHandler("build", help="Build the project.")
@LongValuedFlag("--target", dest="target", help="Build target.")
def HandleBuild(self, args) -> None:
...
Collecting a program’s CLI arguments
CLIArgument marks the nested argument classes of a
Program, so the outer class collects them when it is created - the same
find-by-annotation step as Plugin.GetClasses(), scoped to one class instead of a whole module.