Writing nodes¶
fnloom wires a graph by name, just as Apache Hamilton does: a function's name is its node, and its parameter names are its edges. There's nothing to register, and a plain function needs no decorator. This page covers the few rules that decide what becomes a node.
Which functions are nodes¶
fnloom.build(*modules) scans each module and makes a node of every function that is:
- public, meaning its name doesn't start with
_; - defined in that module. Imported functions are left out, so
from math import floordoesn't add a node calledfloor.
# file: orders.py
from math import floor
def _round_down(value: float) -> int:
"""A private helper: not a node."""
return floor(value)
def units(quantity: float) -> int:
"""Whole units ordered."""
return _round_down(quantity)
import fnloom
import orders
fnloom.build(orders).node_names()
# -> ['quantity', 'units']
Keeping a function out¶
A public function that isn't meant to be a node, such as a helper, can be kept out in two ways (Q73).
@fnloom.exclude marks one function. It works bare or called, @fnloom.exclude(), and only records the mark: the
function is returned unchanged, so you can still call it, and it stacks with the other decorators in either order. An
excluded function is never read, so it may omit its annotations. Hamilton's @hamilton_exclude raises when called.
# file: pricing.py
import fnloom
@fnloom.exclude
def round_cents(value: float) -> float:
"""A public helper the graph doesn't need to know about."""
return round(value, 2)
def price(cost: float) -> float:
return round_cents(cost * 1.2)
import fnloom
import pricing
fnloom.build(pricing).node_names()
# -> ['cost', 'price']
pricing.round_cents(1.239)
# -> 1.24
__all__ limits a module to the functions it names. Hamilton ignores __all__; fnloom honours it, which is a
breaking change for a module that defines one and relied on the rest becoming nodes. Names it lists that aren't
functions the module defines, such as re-exports and constants, are ignored, and listing a private function doesn't
make it a node. @fnloom.exclude wins when a function is both listed and marked.
# file: public_api.py
import fnloom
__all__ = ["revenue", "margin"]
def revenue(units: int) -> int:
return units * 10
def margin(revenue: int) -> int:
return revenue // 4
def scratch(units: int) -> int:
"""Not listed: not a node."""
return units
import fnloom
import public_api
fnloom.build(public_api).node_names()
# -> ['margin', 'revenue', 'units']
__all__ is read as the attribute names a module defines, so a @fnloom.config.when variant price__eu has to be
listed as price__eu to be considered at all. An __all__ that is not a list or tuple of str is a TypeError at
build, naming every such module.
When something reads the name of a function that was left out, it's a missing input, and the error adds a line saying why the name isn't a node:
# file: reports.py
import fnloom
__all__ = ["summary"]
def discount(rate: float) -> float:
return rate / 2
def summary(discount: float) -> float:
return discount
import fnloom
import reports
try:
fnloom.build(reports).execute(["summary"])
except ValueError as error:
hint = str(error).splitlines()[-1]
hint
# -> "'discount' is a function in module 'reports' not listed in its __all__"
A function @fnloom.exclude marked gets marked @exclude in place of not listed in its __all__. A module whose
functions are all left out has no nodes, and the build says so, giving each function's reason.
Inputs¶
A parameter that doesn't name any function becomes an input node, whose value comes from the caller. Above,
quantity is an input. Inputs show up in node_names() next to the functions, and you supply them by name:
fnloom.build(orders).execute(["units"], inputs={"quantity": 7.9})
# -> {'units': 7}
Optional inputs¶
A parameter with a default is optional. If the caller doesn't supply it, the function is called without it, so its default applies:
# file: greeting.py
def greeting(name: str, punctuation: str = "!") -> str:
"""Hello, with the punctuation the caller chose."""
return f"Hello, {name}{punctuation}"
import fnloom
import greeting
driver = fnloom.build(greeting)
driver.execute(["greeting"], inputs={"name": "Ada"})
# -> {'greeting': 'Hello, Ada!'}
driver.execute(["greeting"], inputs={"name": "Ada", "punctuation": "."})
# -> {'greeting': 'Hello, Ada.'}
Note
A default only makes an edge optional when the parameter reads an input. If a parameter with a default names another function, it still reads that function's value.
Defaults the build refuses¶
A default only applies to an input nobody supplies. Two patterns make a default surprising, and Hamilton accepts
both. fnloom refuses them when you build (Q76), each with one ValueError that lists every case, sorted.
A node shadows the default. A parameter with a default that reads a node, by its own name or through
source("..."), takes the node's value. The default is never used, and an input of that name is ignored:
# file: surcharge.py
def fee() -> int:
return 100
def total(fee: int = 3) -> int:
"""The author meant `3` unless the caller supplied a fee."""
return fee
import fnloom
import surcharge
fnloom.build(surcharge)
# raises ValueError: shadowed default: parameter 'fee' of 'total' has a default, but it reads the node 'fee', which replaces it; rename one, or let the node win with @fnloom.defaults(shadowed=('fee',)) on 'total'
A value() binding has no edge, so a node of that name doesn't shadow it.
Two consumers disagree. Each consumer keeps its own default when the input isn't supplied, but one input then reads as two different values, and the same one for both when it is supplied:
# file: windows.py
def short_window(days: int = 7) -> int:
return days
def long_window(days: int = 30) -> int:
return days
import fnloom
import windows
fnloom.build(windows)
# raises ValueError: differing defaults: input 'days' has different defaults in 'long_window' (parameter 'days') = 30, 'short_window' (parameter 'days') = 7; make them equal, or write @fnloom.defaults(differing=(<parameter>,)) on each of these consumers but one
Defaults are compared with ==, and two NaNs are equal. An unclear comparison counts as different: a result that
isn't a bool (numpy's scalar bool is one), as numpy arrays give, an error, or two objects without __eq__ that
aren't the same object. A consumer without a default has
none to differ from.
Opting out. When you mean it, say so on the consumer with @fnloom.defaults, naming the parameters:
# file: windows_ok.py
import fnloom
def fee() -> int:
return 100
@fnloom.defaults(shadowed=("fee",))
def total(fee: int = 3) -> int:
return fee
@fnloom.defaults(differing=("days",))
def short_window(days: int = 7) -> int:
return days
def long_window(days: int = 30) -> int:
return days
import fnloom
import windows_ok
driver = fnloom.build(windows_ok)
driver.execute(["total", "short_window", "long_window"])
# -> {'total': 100, 'short_window': 7, 'long_window': 30}
driver.execute(["short_window", "long_window"], inputs={"days": 14})
# -> {'short_window': 14, 'long_window': 14}
- With
shadowed, the node wins, as it does in Hamilton. - With
differing, each consumer keeps its default. A consumer that opts out leaves the comparison, so naming the input on every consumer but one is enough: above,long_windowis alone. @defaultsonly records names, so it stacks with the other decorators in any order and returns the function itself. Applying it twice merges the names.- A name that isn't a parameter with a default is a
ValueErrorwhen the decorator is applied, listing every such name.
Annotations¶
Every parameter and every return value needs a type annotation, so each edge in the graph is typed where it's
declared. fnloom then checks edges, inputs and overrides against them; see Type checks.
Annotate what a parameter really takes. A parameter that may be None needs T | None (or Optional[T]), even with
a default of None: None passed for x: int is refused (Q79). A fixed-length tuple, tuple[int, str], is checked
by its length and each item (Q80).
Annotations are evaluated when the graph is built, as in Hamilton. String annotations
(from __future__ import annotations) and forward references to a class further down the module are fine.
An annotation naming something that doesn't exist at run time, such as a class imported only under
if TYPE_CHECKING: (as ruff's TC003 rule suggests), still builds. Hamilton raises NameError instead (Q81). The
annotation becomes an opaque type, shown as unresolved 'Decimal', with its text as written:
# file: rates.py
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from decimal import Decimal
def rate() -> "Decimal":
"""The exchange rate."""
raise NotImplementedError
def converted(rate: "Decimal") -> "Decimal":
"""Fed by `rate`: the same unresolved text on both ends."""
return rate
def checked(amount: "Decimal") -> "Decimal":
"""Reads an input whose type didn't resolve."""
return amount
import fnloom
import rates
driver = fnloom.build(rates)
repr(driver.nodes["rate"].type)
# -> "unresolved 'Decimal'"
driver.execute(["checked"], inputs={"amount": 1})
# raises TypeError: amount: cannot check a value against unresolved 'Decimal': it did not resolve when the graph was built; import what it names at run time, not only under TYPE_CHECKING
It surfaces only where a check needs it:
- Edges: an edge matches it only from or to an annotation of the same text, or
Any. Any other edge with it fails the build, naming the text. - Values: an input or override checked against it raises a
TypeErrorsaying to import the name at run time. A node never asked for is never checked, andwithout_input_type_checks()skips the check. An input two nodes share is checked as its merged type, the narrower of their annotations, so an unresolved one wins overAnyand is checked even for theAnyconsumer.
Keep annotation imports real
An unresolved annotation can't check a value. If a node's inputs should be checked, import the names its
annotations use at run time, not only under TYPE_CHECKING.
Checking results¶
By default a node's result isn't checked against its return annotation, as in Hamilton: the build checks that each
edge's types agree, and a run checks inputs and overrides, but a node that returns the wrong type isn't caught until
something downstream fails on it. Builder.with_output_type_checks() checks every result (Q87):
# file: scores.py
def score(raw: int) -> float:
"""The raw score, as a float."""
return raw
def best(scores: list[float]) -> float | None:
"""The highest score, or None for no scores."""
return max(scores, default=None)
import scores
fnloom.build(scores).execute(["score"], inputs={"raw": 3})
# -> {'score': 3}
checked = fnloom.Builder().with_modules(scores).with_output_type_checks()
checked.build().execute(["score"], inputs={"raw": 3})
# raises TypeError: score: returned int, expected float
checked.with_numeric_widening().build().execute(["score"], inputs={"raw": 3})
# -> {'score': 3}
checked.build().execute(["best"], inputs={"scores": []})
# -> {'best': None}
A result is checked by the same rules as an input: None only for a type that admits it (Q79), a fixed-length tuple
by its length and items (Q80), NewTypes and generics such as Callable[[int], int] or frozenset[str] by value,
and with deep checks and numeric widening when they're on. A mismatch
is a TypeError raised as the node's own failure, so it carries the node's in node 'score' note, and a
hook's after_node gets ok=False with that error. A node that raises keeps its own error: its missing
result is never checked. An async def node's awaited result is what's checked. On an AsyncDriver, a sync node
that returns a coroutine still gets the driver's own error, saying to make it an async def.
The checks are made at build, around only the nodes that need one. A node returning Any or object gets no check
and costs nothing, and without the setting no node is touched. An extracted field is checked either way
(Extracting fields); with the setting, its producer's result is checked too. A value loaded from
a cache isn't checked again. Hamilton's FunctionInputOutputTypeChecker does the same job, but crashes
on fixed tuples, parameterized generics and NewTypes, and reports a failing node as returning None.
A custom value check applies to results too.
Checking values¶
@fnloom.check_output checks a node's value after its function returns, with built-in checks given as keywords. A
value that fails is the node's own failure, a fnloom.ValidationError, which is a ValueError (Q99, Q100):
# file: readings.py
from fnloom import check_output
@check_output(range=(0, 100))
def humidity(raw: float) -> float:
"""Relative humidity, in percent."""
return raw * 100
@check_output(values_in=["ok", "stale"], importance="warn")
def status(age: int) -> str:
"""Whether the reading is fresh."""
return "ok" if age < 60 else "expired"
import readings
from fnloom import ValidationError
sensors = fnloom.build(readings)
sensors.execute(["humidity"], inputs={"raw": 0.4})
# -> {'humidity': 40.0}
sensors.execute(["humidity"], inputs={"raw": 1.2})
# raises ValidationError: humidity: check range failed: 120.0 is not in [0, 100]
The node keeps its name and its place in the graph: no node is added, unlike Hamilton's a_raw and validator nodes.
importance="warn" logs one WARNING on the fnloom logger for each failed check in a run, and the run goes on:
sensors.execute(["status"], inputs={"age": 90})
# -> {'status': 'expired'}
| Keyword | Passes when |
|---|---|
range=(lo, hi) |
the value, or every item, is between lo and hi, inclusive |
values_in=[...] |
the value, or every item, is one of these |
data_type=T |
fnloom.value_matches(T, value), so list[int] and int \| None work |
allow_none=False |
the value isn't None |
contains=, does_not_contain= |
a str value contains, or doesn't contain, each str given |
allow_nans=False, max_fraction_nans=f |
no item, or at most that fraction of items, is NaN |
range, values_in and the NaN checks read a scalar, or each item of a value that iterates: a list, a numpy array or
a pandas Series or DataFrame, the last ones through their own vectorised methods, with no import from fnloom. A str,
bytes or mapping is one value. A one-shot iterator is never read, since that would consume it: a node annotated
Iterator is refused at build, and one met at run is a TypeError. A check that can't read the return annotation,
such as range on a str, is a TypeError at build, naming the node (Q101).
@fnloom.check_output_custom takes checks of your own. A function takes the value, and an inputs parameter if it
wants the node's arguments, and returns True or None for a good value, or False or a message for a bad one. A
fnloom.Validator subclass does the same in validate(value, inputs), and may refuse a return annotation at build
with applies_to (Q102):
# file: deliveries.py
from fnloom import check_output_custom
def _no_more_than_ordered(value: int, inputs) -> str | None:
if value > inputs["ordered"]:
return f"shipped {value}, more than the {inputs['ordered']} ordered"
return None
@check_output_custom(_no_more_than_ordered)
def shipped(ordered: int, packed: int) -> int:
"""What leaves the warehouse."""
return packed
import deliveries
fnloom.build(deliveries).execute(["shipped"], inputs={"ordered": 3, "packed": 5})
# raises ValidationError: shipped: check _no_more_than_ordered failed: shipped 5, more than the 3 ordered
Checks stack in any number and in any order, and run in the order written, top first. A check is on the function's
value after its @pipe_output and @mutate steps; field="p" puts it on an @extract_fields or
@extract_columns field instead, whatever the decorator order. Every @parameterize row and @config.when variant
is checked. An overridden node doesn't run, so it isn't checked, and a cache hit isn't checked again.
The checks are part of the node's cache key, so adding a stricter check makes the next run compute the value afresh
(Q103). A hook's after_node gets every check's result as NodeEnd.checks.
Hamilton's pandas statistics checks are a few lines as a custom check:
def _mean_in_range(series) -> bool:
return 0 <= series.mean() <= 10
Steps¶
A step is a small function run on a value on its way into a node, or on its way out. fnloom.step(fn, **bindings)
names one: fn's first parameter takes the value, and its other parameters are bound with source("node"),
value(v) or a plain literal. @pipe_input runs steps on a parameter before the function reads it, and
@pipe_output runs them on what the function returns (Q104):
# file: cleaning.py
from fnloom import pipe_input, pipe_output, source, step
def _clip(v: float, low: float, high: float = 100.0) -> float:
return min(max(v, low), high)
def _scale(v: float, factor: float) -> float:
return v * factor
@pipe_input(step(_clip, low=0.0))
def reading(raw: float) -> float:
"""A raw reading, clipped to [0, 100] first."""
return raw
@pipe_output(step(_scale, factor=source("unit")), step(_clip, low=0.0).named("bounded"))
def adjusted(reading: float, offset: float) -> float:
"""The reading, offset, then scaled to the unit and bounded."""
return reading + offset
Each step is an ordinary node, named after the node it serves: reading.with_clip for the first, and
adjusted.raw, adjusted.with_scale and adjusted.bounded for the second, where adjusted.raw is the function
itself and adjusted the last step's value. A repeated step is numbered, with_clip_1. Steps can be requested, are
cached and get hook events like any node:
import cleaning
steps = fnloom.build(cleaning)
steps.execute(
["adjusted", "adjusted.raw"], inputs={"raw": 120.0, "offset": -30.0, "unit": 2.0}
)
# -> {'adjusted': 100.0, 'adjusted.raw': 70.0}
high has a default and isn't bound, so it keeps its default: a step's parameters are never graph inputs, where
Hamilton makes every such parameter an input shared by every step that has it (Q105). A required parameter left
unbound is refused when decorating.
.named("x")names a step's node<node>.x..when(mode="fast"),.when_not,.when_inand.when_not_inrun a step only under that config. A step's number counts every step written, so names don't change with the config.on_input="b"on@pipe_inputfeeds the parameterbrather than the first.- On a function with extracted fields,
@pipe_output's steps apply to each field, and.on_output("p")picks the fields a step applies to. - With
@parameterize, each row gets its own chain, named under the row (Q108).
The first step must take the parameter's own type, checked at build, and so must each step the one before it.
Misuse, such as on_input naming no parameter, is refused rather than ignored (Q106). Output checks on a
@pipe_output function check the last step's value, whatever the decorator order. A public step function is a node
of its own too, as any public function is; name it with a leading _, or mark it @exclude.
Steps from elsewhere: @mutate¶
@mutate adds the decorated function as a step to other functions' @pipe_output, so a cross-cutting change lives
in one place. Each target is a function, or apply_to(fn, **bindings) to bind the step differently for it; the steps
follow the target's own, in the order the mutators are defined, and the mutator itself is no node (Q107):
# file: rounding.py
from fnloom import apply_to, mutate
def price(cost: float) -> float:
"""The price before rounding."""
return cost * 1.2
def tax(price: float) -> float:
"""The tax on the price."""
return price * 0.2
@mutate(price, apply_to(tax, places=1))
def _rounded(v: float, places: int = 2) -> float:
return round(v, places)
import rounding
fnloom.build(rounding).execute(["price", "tax"], inputs={"cost": 9.99})
# -> {'price': 11.99, 'tax': 2.4}
A target may be in another module, as long as both are built. A target no built module defines fails the build, and so does a node mutated from a module the build doesn't include. A redefinition of a mutator, as re-running a notebook cell makes, replaces its step.
Bindings and decorators from config¶
@fnloom.inject binds parameters with source, value or group, like one @parameterize
row, but keeps the function's name (Q109):
# file: totals_injected.py
from fnloom import group, inject, source, value
@inject(parts=group(source("food"), source("drink"), value(2.5)))
def bill(parts: list[float]) -> float:
"""Everything ordered, plus the cover charge."""
return sum(parts)
import totals_injected
fnloom.build(totals_injected).execute(["bill"], inputs={"food": 20.0, "drink": 6.0})
# -> {'bill': 28.5}
@fnloom.resolve(decorate_with) decides a function's decorators from the config at build.
decorate_with's parameters name config keys, a defaulted one optional, and it returns a decorator, or a list of them,
applied as if written on the function (Q110):
# file: regions.py
from fnloom import parameterize, resolve, source
@resolve(
lambda regions: parameterize(
**{f"sales_{r}": {"orders": source(f"orders_{r}")} for r in regions}
)
)
def sales(orders: int) -> int:
"""Sales in one region."""
return orders * 10
import regions
by_region = (
fnloom.Builder().with_modules(regions).with_config({"regions": ["eu", "us"]})
)
by_region.build().execute(
["sales_eu", "sales_us"], inputs={"orders_eu": 1, "orders_us": 2}
)
# -> {'sales_eu': 10, 'sales_us': 20}
No power-user mode is needed. A missing required key fails the build, naming the function and the key. The
decorators go on a copy of the function made for that build, so builds with other configs never see each other's, and
the function still stands for its nodes in a request. A resolver may not apply @config.when*, @exclude, @mutate
or @resolve: those decide which functions are nodes, which the config can't do from inside one.
Modules together¶
fnloom.build takes several modules, and so does Builder.with_modules. Nodes are matched by name across all of
them, so a function in one module can read a node defined in another:
# file: sizes.py
def doubled(base: int) -> int:
"""Twice the base size."""
return base * 2
# file: report.py
def message(doubled: int) -> str:
"""The doubled size, as text."""
return f"size {doubled}"
import fnloom
import report
import sizes
driver = fnloom.Builder().with_modules(sizes).with_modules(report).build()
driver.execute(["message"], inputs={"base": 21})
# -> {'message': 'size 42'}
A few details:
with_modulesreturns a new builder instead of changing the one you called it on, so you can share and extend a builder safely.- Passing the same module twice adds its nodes once.
- Defining the same name in two different modules is a
ValueErrornaming both.
Namespaces¶
Builder.with_modules(*modules, namespace="eu") names each node of those modules eu.<name>, so one module can be
used more than once in a graph (Q74). A namespace is a dotted path of identifiers, such as "eu" or "shop.eu":
# file: regional.py
def net(gross: float, tax_rate: float) -> float:
"""The price without tax."""
return gross / (1 + tax_rate)
def tax_rate() -> float:
"""This region's tax rate, 20%."""
return 0.2
# file: totals.py
def tax_rate() -> float:
"""The default tax rate, 10%."""
return 0.1
def both(eu_net: float, us_net: float) -> tuple[float, float]:
"""Each region's net price."""
return (eu_net, us_net)
Inside a namespace, a parameter reads the namespace's own node when one of its modules defines that name, and
otherwise the plain name: a node given without a namespace, or an input. Here eu.net reads eu.tax_rate, which
shadows the plain tax_rate for readers inside eu only. An input is never namespaced, so both copies of net read
the one input gross:
import fnloom
import regional
import totals
driver = (
fnloom.Builder()
.with_modules(totals)
.with_modules(regional, namespace="eu")
.with_modules(regional, namespace="us")
.build()
)
driver.node_names()
# -> ['both', 'eu.net', 'eu.tax_rate', 'eu_net', 'gross', 'tax_rate', 'us.net', 'us.tax_rate', 'us_net']
driver.nodes["eu.net"].dependencies
# -> ('eu.tax_rate', 'gross')
driver.nodes["eu.net"].namespace
# -> 'eu'
list(driver.inputs())
# -> ['eu_net', 'gross', 'us_net']
driver.execute(["eu.net", "tax_rate"], inputs={"gross": 12.0})
# -> {'eu.net': 10.0, 'tax_rate': 0.1}
A node outside reads a namespaced node by its full name, with source(...) in a @parameterize row or
fnloom.node(fn, bind=...), since a parameter name can't hold a dot:
import fnloom
import regional
import totals
driver = (
fnloom.Builder()
.with_modules(regional, namespace="eu")
.with_modules(regional, namespace="us")
.with_functions(
fnloom.node(
totals.both,
bind={"eu_net": fnloom.source("eu.net"), "us_net": fnloom.source("us.net")},
)
)
.build()
)
driver.execute(["both"], inputs={"gross": 12.0})
# -> {'both': (10.0, 10.0)}
bind= feeds a namespace inputs of its own (Q112). Each key is a name the namespace's nodes read that none of its
modules defines. Bound to source("x"), it reads x outside the namespace: an input, a node given without a
namespace, or another namespace's node by its full name. Bound to value(v), it's a constant:
import fnloom
import regional
driver = (
fnloom.Builder()
.with_modules(regional, namespace="eu", bind={"gross": fnloom.source("eu_gross")})
.with_modules(regional, namespace="us", bind={"gross": fnloom.value(24.0)})
.build()
)
list(driver.inputs())
# -> ['eu_gross']
driver.nodes["eu.net"].dependencies
# -> ('eu.tax_rate', 'eu_gross')
driver.execute(["eu.net", "us.net"], inputs={"eu_gross": 12.0})
# -> {'eu.net': 10.0, 'us.net': 20.0}
A binding holds for the whole namespace, across with_modules calls, and reaches every reader there: a plain
parameter, a @parameterize row's or @inject's source(...), and a step's. It's shown as each reader's dependency,
is in the export, graph_version and the cache key as a value() parameter is, and is carried by pickling, copy
and rebuild(). with_modules refuses bind= without namespace=, a binding that is neither source nor value,
and a key the namespace already binds to something else. build() refuses a key no selected node of the namespace
reads, and a key the namespace defines.
A few details:
- A parameter can't read another namespace's node: a parameter
netin namespaceusreadsus.netor the plainnet, nevereu.net. Naming the full name insource("eu.net")reads that node from another namespace too, unless the namespace it's written in has a node of its own namedeu.net(in full,us.eu.net), which it reads instead. - Every name a namespaced module makes is prefixed:
@configvariants,@parameterizerows and extracted fields. Each namespace selects its own@configvariant, and the errors,validate,inputs(),outputs(),what_is_the_path_betweenand suggestions all use full names. - The same module under the same namespace twice counts once. Under two namespaces, the same function makes a node in
each, so passing the function in
final_varsis aTypeErrornaming both nodes: pass a name instead. - Names clash as they do without namespaces, and nothing overrides: a
@parameterizerow named"eu.net"in a module given without a namespace is a build error naming both definitions. Hamilton'sallow_module_overrides()silently keeps the later module's function instead. - Cache keys use the full node name, and each function keeps one code version.
with_functionstakes nonamespace=.
Graphs without modules¶
Builder.with_functions builds from functions directly, with no module to scan (Q72). Each function makes the nodes it
would make in a module, named by its __name__, and keeps its decorators, so @tag, @parameterize, @config.when*,
@extract_fields and @cache all work:
# file: scoring.py
import fnloom
def raw(points: int) -> int:
return points * 10
@fnloom.tag(owner="games")
def bonus(raw: int) -> int:
return raw + 5
def _scaled(raw: int, factor: int) -> int:
return raw * factor
import sys
import fnloom
import scoring
modules_before = set(sys.modules)
driver = fnloom.Builder().with_functions(scoring.raw, scoring.bonus).build()
driver.execute(["bonus"], inputs={"points": 2})
# -> {'bonus': 25}
driver.nodes["bonus"].module
# -> 'scoring'
driver.modules
# -> ()
set(sys.modules) == modules_before
# -> True
Nothing is copied and no module is made: each node is the function itself, so its module, cache key and code version
are the function's own, and the function in final_vars means its node. Hamilton's create_temporary_module copies
the functions into a module it registers in sys.modules.
fnloom.node(fn, *, name=None, bind=None) makes one node from a function, named and bound without decorating it.
bind takes source(...) and value(...), as a @parameterize row does, and is checked the same way. One function
can make several nodes, each from its own node(...), and a function given on purpose may have a private name:
import fnloom
import scoring
driver = (
fnloom.Builder()
.with_functions(
scoring.raw,
fnloom.node(scoring._scaled, name="doubled", bind={"factor": fnloom.value(2)}),
fnloom.node(scoring._scaled, name="tripled", bind={"factor": fnloom.value(3)}),
)
.build()
)
driver.execute(["doubled", "tripled"], inputs={"points": 1})
# -> {'doubled': 20, 'tripled': 30}
A node's name must be an identifier, so a dotted name such as "ns.score" is refused. A lambda has no name a node
can take, so it needs name=:
import fnloom
fnloom.Builder().with_functions(lambda points: points)
# raises ValueError: function '<lambda>' has no name a node can take: give it one with fnloom.node(fn, name=...)
A few details:
with_functionsis cumulative, likewith_modules, and mixes with it: nodes are matched by name across both, and a name defined twice, in a module and among the functions, is the usual duplicate error. The same function counts once, whether it's given twice or also found in a module given to the builder.- Only a Python function is accepted. For a
functools.partial, give the function withbind=; wrap a callable instance in adef. - A function marked
@excludeis refused rather than dropped, since you gave it on purpose. A module's__all__only decides what that module's scan finds, so it doesn't apply. - A driver built this way pickles when each function is importable by its module and qualified name: defined at the
top of a module, or in a class there. A local function or a lambda is a
TypeErrornaming it when pickled. Loading finds each function again by that name, as pickle does: if the name now holds a different function, that one is used, and if it holds nothing or something else, loading raisesTypeError: cannot load function 'm.f': …. A driver that is already running keeps the functions it was built with.
One function, several nodes¶
@parameterize turns one function into several nodes, one per row. Each keyword names a node, and its mapping binds
some of the function's parameters:
source("x")reads the parameter from the node or inputx;value(v)passes the constantv.
The function itself isn't a node, unless a row takes its name.
# file: thresholds.py
from fnloom import parameterize, source, value
@parameterize(
above_low={"limit": value(10)},
above_high={"limit": source("high_limit")},
)
def above(readings: list[int], limit: int) -> int:
"""How many readings exceed the limit."""
return sum(reading > limit for reading in readings)
import fnloom
import thresholds
driver = fnloom.build(thresholds)
driver.node_names()
# -> ['above_high', 'above_low', 'high_limit', 'readings']
driver.execute(
["above_low", "above_high"],
inputs={"readings": [5, 12, 30], "high_limit": 20},
)
# -> {'above_low': 2, 'above_high': 1}
- A parameter a row does not mention reads the node of its own name, as in a plain function:
readingsabove. - Rows are ordinary nodes, so one row can
source()another, including a row of the same function. - A parameter with a default that a row binds to
source("x")makesxoptional: whenxis not supplied, the function is called without that parameter and its default applies. Hamilton raisesKeyErrorthere (Q35). - A
value()is passed as the object itself on every call, never a copy, so a node that changes a bound list changes it for the next run too. - A row naming a parameter the function does not have is a
ValueErrorwhen the decorator is applied (Q33).
The decorator returns the function unchanged, so you can still call it directly, and type checkers still see its signature.
Docs for each row¶
A row's node takes the function's docstring as a template, filled in per row with str.format_map: {output_name} is
the row's name, and each parameter the row binds is a source's name or a value's own value, so a format spec such as
{limit:.1f} works. A group(...) parameter shows its items joined: x, 3 for a list, k=x, j=3 for a dict. Write a
literal brace as {{ or }}. A function without a docstring gives None for every row.
A placeholder the row lacks, a positional field such as {} or {0}, or a stray brace is a ValueError naming the row
and the placeholder, when the decorator is applied. Hamilton prints a missing placeholder as the bare key (Q82).
To give a row its own doc, write the row as (bindings, doc). The doc is used as written, never templated, and None
means no doc at all, not the function's. A tuple of any other length, or a doc that is not a str, is a TypeError.
# file: limits.py
from fnloom import parameterize, source, value
@parameterize(
low={"limit": value(10)},
high={"limit": source("high_limit")},
custom=({"limit": value(50)}, "Written as is: {limit} stays braced."),
quiet=({"limit": value(99)}, None),
)
def above(readings: list[int], limit: int) -> int:
"""{output_name}: readings over {limit}."""
return sum(reading > limit for reading in readings)
import fnloom
import limits
driver = fnloom.build(limits)
{name: driver.nodes[name].doc for name in ["low", "high", "custom", "quiet"]}
# -> {'low': 'low: readings over 10.', 'high': 'high: readings over high_limit.', 'custom': 'Written as is: {limit} stays braced.', 'quiet': None}
from fnloom import parameterize, value
def bad(limit: int) -> int:
"""Over {limit}, or {ceiling}."""
return limit
parameterize(low={"limit": value(10)})(bad)
# raises ValueError: the docstring of 'bad' is a template for each row's doc: row 'low': placeholder {ceiling} is not 'output_name' or a parameter the row binds; write a literal brace as {{ or }}, or give the row its own doc with (bindings, doc)
Lists and dicts: group¶
group(...) binds one parameter to a list or a dict built from several sources and values. Positional items make a list,
for a parameter annotated list[X]; keywords make a dict, for one annotated dict[str, X]. Optional of either is
fine.
# file: bundles.py
from fnloom import group, parameterize, source, value
@parameterize(
scores={"parts": group(source("math"), value(10), source("art"))},
named={"marks": group(math=source("math"), bonus=value(10))},
)
def bundle(parts: list[int] = [], marks: dict[str, int] = {}) -> object:
return parts or marks
import fnloom
import bundles
driver = fnloom.build(bundles)
driver.execute(["scores", "named"], inputs={"math": 7, "art": 9})
# -> {'scores': [7, 10, 9], 'named': {'math': 7, 'bonus': 10}}
driver.nodes["math"].type is int
# -> True
- Each call builds a fresh list or dict. A
value(...)item is the same object every time, asvalue()is everywhere. - Each
sourceis an ordinary input, or node, typed by the element:list[int]makes it anint. It is required, even when the parameter has a default, since a group is never partly built; Hamilton makes it optional and then fails with aKeyErrorwhen one is missing. NodeView.paramslists oneParamper item, with the item's place inslot: its index in a list, or its key in a dict.- Misuse is refused when decorating, each offender listed once, in one message:
- a parameter not annotated as
list[X]ordict[str, X]. A string annotation, asfrom __future__ import annotationsmakes every one, is read then too. One that is only a name defined further down the module, quoted or not, is checked at build instead, once it resolves, with the same message. A name that never resolves, such as one imported only underTYPE_CHECKING, is refused at build, since its shape can't be read (Q81); insidelist[...]ordict[str, ...]it is fine, and each item is typedunresolved; - positional and keyword items mixed, an empty
group(), a nestedgroup, or a bare string.
- a parameter not annotated as
fnloom.node(fn, bind=...)takes agroup(...)as a@parameterizerow does.
Shorthands¶
parameterize_sources is @parameterize with every binding a source, and parameterize_values is one parameter
bound to a constant per node, each with its own doc:
# file: shorthands.py
from fnloom import parameterize_sources, parameterize_values
@parameterize_sources(west=dict(rate="west_rate"), east=dict(rate="east_rate"))
def price(rate: float, base: float = 100.0) -> float:
"""{output_name} price at {rate}."""
return base * rate
@parameterize_values("tax", {("low_tax", "A low tax."): 0.1, ("high_tax", None): 0.3})
def taxed(tax: float, base: float = 100.0) -> float:
return base * (1 + tax)
import fnloom
import shorthands
driver = fnloom.build(shorthands)
driver.execute(["west", "high_tax"], inputs={"west_rate": 1.5})
# -> {'west': 150.0, 'high_tax': 130.0}
driver.nodes["west"].doc
# -> 'west price at west_rate.'
driver.nodes["low_tax"].doc
# -> 'A low tax.'
parameterize_sourcestakes astrfor each source, refused otherwise when decorating; Hamilton fails later with a rawTypeError. No row may be empty.parameterize_valuestakes a mapping of(name, doc)keys to values; a doc may beNone, and a name may appear once. An empty mapping is refused; Hamilton builds no node.- All three compose with
@config,@extract_fields(one row only),@tag,@cache,@defaultsand namespaces, and each row is checked as a@parameterizerow is.
Config¶
@config.when and its relatives make one node out of several variants, picking one when you build. Name each
variant after its node, followed by __ and anything you like: cost__post and cost__courier are both variants of
cost. The name is cut at the last __, and only on a config-decorated function, so a plain cost__total stays
cost__total. Pass name= to choose the node name yourself.
| Decorator | The variant is used when |
|---|---|
@config.when(carrier="post") |
every key equals its value |
@config.when_not(carrier="post") |
every key differs from its value |
@config.when_in(carrier=["courier", "express"]) |
every key is one of its values |
@config.when_not_in(carrier=["post"]) |
every key is none of its values |
@config.default |
no other variant of its node matches |
# file: shipping.py
from fnloom import config
@config.when(carrier="post")
def cost__post(weight: float) -> float:
"""A flat fee plus the weight."""
return 2.0 + weight
@config.when_in(carrier=["courier", "express"])
def cost__courier(weight: float) -> float:
"""A higher fee, and twice the weight."""
return 5.0 + 2 * weight
@config.default
def cost__free() -> float:
"""Free shipping when no carrier matches."""
return 0.0
def total(price: float, cost: float) -> float:
"""What the customer pays."""
return price + cost
Builder.with_config gives the config. Each call merges into a copy, and a key given again takes its later value:
import fnloom
import shipping
builder = fnloom.Builder().with_modules(shipping)
post = builder.with_config({"carrier": "post"}).build()
post.execute(["total"], inputs={"price": 10.0, "weight": 1.0})
# -> {'total': 13.0}
express = builder.with_config({"carrier": "express"}).build()
express.execute(["total"], inputs={"price": 10.0, "weight": 1.0})
# -> {'total': 17.0}
fnloom.build(shipping).execute(["total"], inputs={"price": 10.0})
# -> {'total': 10.0}
dict(post.config)
# -> {'carrier': 'post'}
post.nodes["cost"].origin is shipping.cost__post
# -> True
[condition.key for condition in post.nodes["cost"].config]
# -> ['carrier']
- Config shapes the graph, and nothing else. A config key is never a node, an input or an output, and its value
is never passed to a function. A parameter named like a config key is an ordinary input you supply in
inputs=. Hamilton passes config values to parameters (Q56). - A key the config doesn't have never matches, whichever form tests it. Hamilton reads a missing key as
None, sowhen_not(carrier="post")matches an empty config there (Q57). - Values compare with
==, sowhen(level=1)matches a config of{"level": True}. An==that returns anything but abool, as a NumPy array's does, is aTypeErrornaming the key; NumPy's boolean scalar counts as abool. - Stacked decorators all apply:
@config.when(carrier="post")over@config.when_not(region="eu")needs both. The order you stack them in, and their order with@parameterizeand@tag, never matters. @parameterizeunder a config decorator gives its rows only when the variant is selected.Driver.configis a read-only, shallow copy of the config, and a pickled driver keeps it. Shallow means a list value you change afterwith_configshows the change there too.NodeView.configholds the conditions that selected a node, each afnloom.Condition.
A node that no variant matches, with no @config.default, is left out. That's fine when nothing reads it, and
requesting it says why it's missing. When another node reads it, the build fails, naming what each variant needs:
# file: tiers.py
from fnloom import config
@config.when(tier="gold")
def discount__gold() -> float:
"""Gold customers save a fifth."""
return 0.2
def price(list_price: float, discount: float) -> float:
"""The list price after the discount."""
return list_price * (1 - discount)
import tiers
fnloom.Builder().with_config({"tier": "basic"}).with_modules(tiers).build()
# raises ValueError: no variant of 'discount' matches the config {'tier': 'basic'}; its variants need tier == 'gold' (discount__gold); add @config.default or a variant for it
The same holds for a name only an unselected variant gives: a @parameterize row, or a field it extracts. It's never
made an input. Reading it fails the build with no selected variant gives 'r2': ..., naming the variants that do.
An @extract_fields() that names no fields gives its TypedDict's keys, as far as its return annotation evaluates
at build.
Mistakes are refused rather than guessed at (Q58, Q59):
- Two variants of one node that both match, or two
@config.defaults for one node, fail the build naming both. - A config-decorated function defined twice in one module fails the build, naming the lines: Python would keep only the last one. A function bound to a second name is still one variant.
@config.whenwithout parentheses, astrgiven towhen_in, a positional string, a name ending in__and awhenwith no conditions are refused when the decorator is applied.name=is always the node name. To test a config key that's literally calledname, pass a mapping:@config.when({"name": "x"}).
The decorators never change your function, and add no tags (Q60).
Extracting fields¶
@extract_fields makes a node of each field of the mapping a function returns. The function stays a node of its own
name, returning the whole mapping, and each field node reads it. Give the fields a type each, in a mapping:
# file: stats.py
from typing import TypedDict
from fnloom import extract_fields
@extract_fields({"low": int, "high": int})
def bounds(readings: list[int]) -> dict:
"""The smallest and largest reading."""
return {"low": min(readings), "high": max(readings)}
class Summary(TypedDict):
count: int
label: str
@extract_fields()
def summary(readings: list[int]) -> Summary:
"""Every field of a TypedDict return is a node."""
return {"count": len(readings), "label": "readings"}
@extract_fields("mean", fill_with=0.0)
def averages(readings: list[int]) -> dict[str, float]:
"""A named field takes its type from the return: here, float."""
return {"mean": sum(readings) / len(readings)} if readings else {}
def spread(low: int, high: int) -> int:
"""Field nodes feed other nodes like any node."""
return high - low
import stats
driver = fnloom.build(stats)
driver.node_names()
# -> ['averages', 'bounds', 'count', 'high', 'label', 'low', 'mean', 'readings', 'spread', 'summary']
driver.execute(["spread", "count", "mean"], inputs={"readings": [3, 9, 6]})
# -> {'spread': 6, 'count': 3, 'mean': 6.0}
driver.execute(["mean", "averages"], inputs={"readings": []})
# -> {'mean': 0.0, 'averages': {}}
driver.nodes["mean"].type
# -> float
- Three forms. A mapping of names to types; names alone, typed from a
dict[str, T],Mapping[str, T]orTypedDictreturn; or no arguments, for every field of aTypedDictreturn. A mapping and names together are refused, as is a name theTypedDictdoesn't have (Q61). - Fields are checked on every run against their types, with the same check as inputs, even when you build
without_input_type_checks(). A wrong type is aValueErrornaming the producer, the field and both types, and a missing field is aValueErrornaming the fields the producer did return. A declared type the return annotation can't feed fails the build (Q61). fill_withis the value of a missing field. Only that field's node sees it: the producer's mapping is never changed, andfill_with=Nonefills withNone(Q62).@tagtags the producer and every field node (Q63). A field node shares the producer's docstring and config.@parameterizewith one row works, and the producer takes the row's name. Two rows are refused when decorating.
# file: halves.py
from fnloom import extract_fields
@extract_fields({"left": int})
def split(number: int) -> dict:
"""Returns the wrong type for `left`."""
return {"left": str(number // 2)}
import halves
fnloom.build(halves).execute(["left"], inputs={"number": 8})
# raises ValueError: 'split' returned field 'left' as str, but it is declared int
Extracting columns¶
@extract_columns makes a node of each named column of the value a function returns: a pandas or polars
DataFrame, a mapping, or any value that supports in and []. No extension needs loading, and fnloom never imports
pandas or polars itself. Name a column, or give (name, doc) to set that node's docstring:
# file: table.py
from fnloom import extract_columns
@extract_columns("price", ("volume", "Shares traded."), fill_with=0)
def bars(symbol: str) -> dict[str, list[float]]:
"""Columns of bars, keyed by name."""
return {"price": [10.0, 10.5], "spread": [0.1, 0.2]}
def notional(price: list[float], volume: object) -> float:
"""Column nodes feed other nodes like any node."""
return sum(price) * (volume or 1)
import table
driver = fnloom.build(table)
driver.node_names()
# -> ['bars', 'notional', 'price', 'symbol', 'volume']
driver.execute(["price", "volume", "notional"], inputs={"symbol": "X"})
# -> {'price': [10.0, 10.5], 'volume': 0, 'notional': 20.5}
driver.nodes["volume"].doc
# -> 'Shares traded.'
driver.nodes["price"].doc
# -> 'Columns of bars, keyed by name.'
- A column's type comes from the return annotation. A
pandas.DataFramereturn givespandas.Seriescolumns, apolars.DataFramegivespolars.Series, and anything else givestyping.Any. Each value is checked against its type on every run, so a name the frame holds twice, which reads a DataFrame, is aValueError(Q64). - A missing column is a
ValueErrorwhen run, naming the producer and the columns it returned, sorted. fill_withis the value of a missing column, and never changes the producer's value. For a pandas frame it is a fullSerieson the frame's index, for a polars frame aSeriesas long as the frame, and for a mapping the value itself. Any other type with a missing column is aTypeErrornaming it. A(name, doc)column fills byname(Q64).- Refused when decorating: no columns, a list, and a column name given more than once, in any form (Q64). Put
*in front of a list:extract_columns(*names). @tagtags the producer and every column node (Q63).@configand one@parameterizerow work as they do with@extract_fields.
# file: gaps.py
from fnloom import extract_columns
@extract_columns("close")
def quotes() -> dict[str, list[float]]:
"""Has no `close` column."""
return {"open": [1.0], "high": [2.0]}
import gaps
fnloom.build(gaps).execute(["close"])
# raises ValueError: 'quotes' returned no column 'close'; it returned 'high', 'open'
What is refused¶
fnloom calls a node with one keyword argument per parameter, and expects it to return its value. Functions that can't
be called that way are refused when you build, each with a ValueError naming the function:
| Refused | Why |
|---|---|
A positional-only parameter (def f(a, /)) |
It cannot be passed by keyword. |
*args or **kwargs |
Neither binds one named argument as a parameter. |
| A missing parameter or return annotation | Every edge is typed where it is declared. |
A generator function, sync or async, and an async def given to build() |
Calling one returns a generator or a coroutine, not the node's value. build_async() awaits an async def. |
| A dependency cycle, including a function reading itself | A cycle has no order to run in; the error names the cycle. |
Hamilton silently accepts the first, second and fourth, and overflows the stack on a cycle. See the differences (Q27, Q29, Q32).