Working with targets¶
Artisan allows CLI, REST API, and web UI users to construct objects called
“build targets”, or “targets”, for short. New target types can be created by
extending artisan.Target
. Target construction can be customized via
user-defined specification objects, and target types support a feature called
“subclass forwarding” by which a specification can determine a target’s type.
An example target type definition:
from typing import Protocol
from artisan import Target
from .my_lib import Integrator
class SolarSystem(Target):
'An N-body simulation in 3 dimensions.'
class Spec(Protocol):
m: List[float]; 'Body masses, in kilograms.'
xyz_init: List[List[float]]; 'Initial (x, y, z) positions, in meters.'
dt: float = 86400.0; 'The timestep duration, in seconds.'
integrator: Integrator.Spec; 'How to do all the hard math.'
def __init__(self, spec: Spec) -> None:
self.bodies = list(zip(spec.m, spec.xyz_init))
self.integrator = Integrator(spec.integrator)
self.dt = spec.dt
def update(self) -> None:
'Advance one timestep into the future.'
# Hold on tight...
#
# ☄️ ⬅ 🪐
# ⬇
# 🌞
# ⬆
# 🌎 ➡ 🐄
#
Target specifications¶
A target constructor must accept a specification object as its first non-self
argument. Specifications are namespace-like objects (objects with a __dict__
attribute) containing integers, floating-point numbers, strings, True
,
False
, None
, pathlib.Path
objects, artifacts, sequences of allowed values, and/or namespace-like collections of
allowed values.
Targets can be constructed from JSON-encodable objects using artisan.build
,
which deeply converts dictionaries to namespaces and strings in the form
“@/<path>” to artifacts, if the path corresponds to a directory, or Path
objects, otherwise. Paths are constructed relative to the active context’s root artifact directory, which is the current working
directory by default.
solar_system = artisan.build(SolarSystem, {
'integrator': {'method': 'Euler', 'precision': 'float64'},
'xyz_init': [[1e9, 1e9, 2e9], [3e9, 5e9, 8e9]],
'm': [2.7182e8, 3.1415e9],
'dt': 525600})
Specification types¶
Target
types can define an inner Spec
class to indicate the specification
object’s expected type. Defining this class as a protocol indicates to
readers and type checkers that the specification’s precise type is not
important, so long as it has the expected structure. If Spec
is not defined
explicitly, it will be defined implicitly as a protocol with no required
attributes.
Public, non-callable attributes of Spec
will be used to fill in missing
specification attributes:
from types import SimpleNamespace as Ns
solar_system = SolarSystem(Ns(
integrator = Ns(method='Euler', precision='float64'),
xyz_init = [[1e9, 1e9, 2e9], [3e9, 5e9, 8e9]],
m = [2.7182e8, 3.1415e9]))
solar_system.dt # => 86400.0 (taken from `SolarSystem.Spec`)
Inner Spec
classes also help Artisan generate more useful APIs and user
interfaces, and help static analysis tools like MyPy
and Jedi detect errors and provide
suggestions.
Subclass forwarding¶
If a target is constructed with a specification that has a type
attribute,
that attribute is dereferenced in the active context’s target-type scope and an
instance of the resulting type is returned. The default target-type scope
contains every Target
subclass defined outside of the Artisan library, keyed
by its name, if its name is unique, and “<name> (<module name>)”, otherwise.
class Animal(Target):
'Like a plant, but faster.'
class Bat(Animal):
class Spec(Protocol):
wingspan: float; 'In inches.'
has_vampirism: bool = False
class Capybara(Animal):
class Spec(Protocol):
tooth_length: float; 'Also in inches.'
Animal(Ns(type='Bat', wingspan=8.0)) # constructs a bat
Animal(Ns(type='Capybara', tooth_length=2.3)) # constructs a capybara
Custom scopes can be activated using the context API:
with artisan.using_context(scope={'FlutterMouse': Bat, 'WaterPig': Capybara}):
Animal(Ns(type='FlutterMouse', wingspan=8.0)) # constructs a bat
And types can be specified directly as well:
Animal(Ns(type=Capybara, tooth_length=2.3)) # constructs a capybara
Instantiating targets with artisan.build
can help avoid confusing
type-checkers when using subclass forwarding with abstract types:
from abc import abstractmethod
class TalkingAnimal(Target):
@abstractmethod
def talk(self) -> str: ...
class TalkingCat(TalkingAnimal):
def talk(self) -> str:
return 'I can has cheezburger, but I chooz not to.'
cat = artisan.build(TalkingAnimal, {'type': 'TalkingCat'})
type(cat) # => <class 'TalkingCat'>