Declaring and running steps
A step is an ordinary annotated Python function. The decorator records its signature; the graph is built from those signatures without calling any of them.
step
step(
function: 'Callable[P,
R] | None' = None,
/,
*,
tool: 'str | Sequence[str] | None' = None,
coordinates: 'str | None' = None,
cite: 'str | Sequence[str] | None' = None,
conversion: 'str | None' = None,
stochastic: 'bool' = False,
seed: 'str | None' = None,
) -> 'Step[P, R] | Callable[[Callable[P, R]], Step[P, R]]'Make a function a pipeline step, without ever running it.
The signature is read once, here. Each parameter annotated with a Ligase
data type becomes an edge in the graph; the rest are plain values. The
return annotation is what says the step produces an Alignment, since
nothing will ask the body.
Parameters
| Parameter | Description |
|---|---|
function | The function to decorate, when used bare as @lg.step. |
tool | The external tool the step shells out to, e.g. minimap2, or several of them: tool=("minimap2", "samtools") for a body that pipes one into the other. Each one’s --version joins the cache key, so an upgrade that changes a result reruns the steps it could have changed. |
coordinates | The coordinate convention the step writes, one of "0-based half-open" or "1-based closed". Only meaningful for a step that produces Intervals: alignments and variant calls are 1-based closed by specification, while a tool that writes BED from a VCF changes the convention and must say so. |
cite | How the step asks to be cited, as one reference or several. ligase methods knows the reference pipeline’s own tools; a step using anything else names its reference here, or the methods section reports that tool as uncited. A citation never joins the cache key — it cannot change a result, so adding one must not rerun anything. |
conversion | The registered conversion this step performs, named as the engine knows it — "map_identifiers" is the only one today. A conversion step is the one kind that crosses a dimension instead of inheriting it: its output declares the value its call’s to= names, and every other dimension travels through unchanged. The step must take a to parameter, or it has not said where it converts to. Ordinary steps leave this alone. It exists so that lg.map_identifiers is an entry in the engine’s conversion registry rather than a special case in the graph, which is what lets a future liftover be another entry. |
stochastic | That the step’s tool draws on randomness — IQ-TREE, RAxML, BEAST, AutoDock Vina, any model training. Given with seed, never without it. What it buys is in the audit. ligase audit distinguishes deterministic from reproducible, and a step that declares this is never called deterministic, however its bytes fall: a seeded tool is byte-identical because its seed was held still, and saying deterministic of it would tell a reader there is no randomness in the step. “This pipeline is reproducible” is a far stronger claim once the engine refuses to let you write an irreproducible step by accident. |
seed | The name of the parameter that seeds the step. It must be a parameter the function takes and it must be annotated int; either failing is a StepDefinitionError as the module is imported, like a missing return annotation. Given with stochastic, never without it. Naming a seed without declaring the step stochastic is refused rather than read as a shorthand, because one declaration with two spellings is how a second untested path gets in — and because what the decision asks for is a step that declares itself. The seed is an ordinary typed parameter besides, so its value is hashed into the cache key like any other and two seeds are two runs. Nothing special is done to arrange that. |
Returns
A Step, which a type checker sees as the function it decorated.
Raises
| Raises | When |
|---|---|
StepDefinitionError | If the signature is one a graph cannot use — an unannotated parameter, a missing or non-Ligase return annotation, or *args/**kwargs — or if conversion names a conversion the engine does not have, or the step performs one and takes no to parameter, or stochastic and seed do not agree, or the named seed is not a parameter of the step typed int. |
ValueError | If coordinates is not one of the two conventions, or a tool was named as an empty string. |
sh
sh(command: 'str') -> 'R'Produce the output by running a shell command.
The command is not run here and the string is not formatted here: the engine fills it in once it knows where the inputs are, and runs it at whatever width the machine allows.
Placeholders name the step’s own parameters:
{ref}— the file bound to the parameterref.{reads.r1},{reads.r2}— the mates of a paired input.{min_freq}— a plain parameter’s value.{out}— the file the step must write. A step that writes anywhere else produces nothing, as far as the engine is concerned.
Every value is substituted as exactly one shell word, so a path with a
space in it stays one path. For a literal brace — an awk '{{print $1}}'
— double it.
Parameters
| Parameter | Description |
|---|---|
command | The command, with placeholders. |
Returns
A recipe, typed as the data the step declares, so a body reads as
return lg.sh(...) under a -> Alignment annotation.
write
write(function: 'Callable[[Path], Iterable[Noticed] | None]') -> 'R'Produce the output in Python, by writing the file yourself.
function is called with the path to write to. It is the one thing in a
run that takes the GIL, so steps written this way do not run in parallel
with each other — which is the right trade for an interpretation step
and the wrong one for anything that does real work. Prefer lg.sh.
A body may also return what it noticed about its own work, as Noticed
pairs of a note’s code and what it is about. That is the only way such
a thing can be said: an assumption the work rested on need leave no
trace in the file it wrote — a resistance reading made without coverage
writes the same report a fully covered one does — so the body says it
and the engine carries it into the run report, provenance, a bundle and
the methods section. A note never fails a step; the work was done and
its result stands.
Parameters
| Parameter | Description |
|---|---|
function | Called with the path to write to. It returns None, or what it noticed. |
Returns
A recipe, typed as the data the step declares.
handle
handle(output: 'object') -> 'StepOutput'The handle behind what a step returned.
A type checker sees align(reads, ref) as returning an Alignment,
which is what lets it follow a pipeline and catch a mis-wired one while
you are still editing. At runtime the value is a StepOutput — data that
does not exist yet — and this is how to reach it without writing a cast
at every call site.
Parameters
| Parameter | Description |
|---|---|
output | What a step returned. |
Returns
The handle, with its step, its arguments and (once a Pipeline has
been built) the dimensions the output will carry.
Raises
| Raises | When |
|---|---|
TypeError | If this is not a step’s output — loaded data, say, which is already the thing itself. |
map_identifiers
map_identifiers(
entities: 'EntitySet',
table: 'EntitySet',
*,
column: 'str',
to: 'str',
id_column: 'str' = 'id',
table_id_column: 'str' = 'id',
ambiguous: 'str' = 'drop',
) -> 'EntitySet'Rewrite a gene list’s identifiers through a mapping table.
The output declares the space named by to, and carries every other
dimension of entities — its organism, its subject, its release —
because a conversion crosses one axis and is not a new list.
Parameters
| Parameter | Description |
|---|---|
entities | The list to convert. |
table | The mapping, as an EntitySet whose own identifiers are the source side. Typed rather than a path so that its contents join the cache key and its space is checked against entities: a table of Entrez IDs handed a list of HGNC symbols is refused before anything runs. |
column | The column of table holding the identifiers to convert to. Naming the table’s own identifier column is an error — it would map every identifier to itself and then declare a space the identifiers are not in. |
to | The space the output is in, such as Ensembl gene. Declared rather than inferred: what the output is must not depend on which identifiers happened to cross. |
id_column | The column of entities holding its identifiers. |
table_id_column | The column of table holding the source identifiers. |
ambiguous | What becomes of an identifier the table sends to several targets. "drop", the default, leaves it out and names it. "fan-out" emits a row for each target, which is what a bifunctional enzyme, a named complex or a paralogue family needs — those genuinely denote more than one entity, and picking one is the wrong answer. Declared at the call site rather than inferred, because the engine cannot tell untidy curation from the organism. |
Returns
A recipe, typed as the gene list the step produces.
map_ranked_identifiers
map_ranked_identifiers(
entities: 'RankedList',
table: 'EntitySet',
*,
column: 'str',
to: 'str',
id_column: 'str' = 'id',
table_id_column: 'str' = 'id',
) -> 'RankedList'Rewrite a ranked list’s identifiers, and say that its ranks moved.
map_identifiers with one difference, and the difference is the reason
this is a second step rather than an argument: the output is a
RankedList, and a step’s output kind is read off its return
annotation once, when it is decorated. One function cannot produce
both kinds.
The mapping itself is the same work and keeps the same guarantees. Rows come out in the order they went in, so rank order survives; an identifier that does not cross is named rather than dropped quietly or carried into a space it is not in (D38).
What is different, and why it needs saying
An EntitySet that loses members is still an honest set of the ones
that crossed. A ranking that loses rows is not simply a shorter
ranking: rank is the row position (D41), so every row below a
dropped one moves up. If the top-ranked identifier had no mapping,
the second is now first.
The output is a perfectly well-formed ranking of the entities that
crossed, with ranks 1..n and nothing in the file to show that its rank
3 was not the input’s rank 3. So this step says it, with a
ranks-renumbered note carrying what rank the output’s first row held
in the input — in the run report, in provenance, in a bundle and in the
methods section, like every other note.
The practical consequence that note is there to stop: take a cut-off before mapping, not after. The top ten of a mapped ranking is the top ten of what crossed, which is not the mapping of the top ten.
There is no ambiguous= here
map_identifiers takes ambiguous="fan-out" for the identifiers that
genuinely denote more than one entity. A ranking cannot: rank is the
row position (D41), and one row becoming two has no defensible rank —
tie ranks would be invented, and renumbering would make the output a
ranking of something nobody computed. So an ambiguous identifier is
always left out here, and fan_out_ranked_identifiers is the step that
fans a ranking: it returns an EntitySet, because that is what the
data has become.
Parameters
| Parameter | Description |
|---|---|
entities | The ranking to convert. |
table | The mapping, as an EntitySet whose own identifiers are the source side — in the same space as the ranking, so a table of the wrong space is refused before anything runs. |
column | The column of table holding the identifiers to convert to. |
to | The space the output is in, such as Ensembl gene. Declared rather than inferred, so what the output is does not depend on which identifiers happened to cross. |
id_column | The column of entities holding its identifiers. |
table_id_column | The column of table holding the source identifiers. |
Returns
A recipe, typed as the ranking the step produces.
fan_out_ranked_identifiers
fan_out_ranked_identifiers(
entities: 'RankedList',
table: 'EntitySet',
*,
column: 'str',
to: 'str',
id_column: 'str' = 'id',
table_id_column: 'str' = 'id',
) -> 'EntitySet'Map a ranking’s identifiers, fanning the ambiguous ones, as a set.
The declared way across a one-to-many crossing for data that arrived
ranked. map_ranked_identifiers leaves an ambiguous identifier out,
because rank is the row position (D41) and one row becoming two has no
rank; this step emits a row per target and returns an EntitySet,
because a table that has gained rows is no longer the ranking that was
computed.
Why the ranking cannot be kept, stated once
Three ways to keep it were available and all three are worse. Giving the fanned rows tie ranks invents an order the pipeline never produced. Renumbering them makes rank n of the output mean something no computation assigned. Keeping the fanned entity at its original rank and pushing the rest down does both at once. Rank is the result for this kind, so a step that cannot preserve it has to say the result is gone rather than hand back something that looks like one.
The function’s name is that declaration: there is no ambiguous=
argument, because choosing it on a ranking would change the output’s
type, and a step’s output kind is read off its return annotation
once, when it is decorated.
What to do with the order you had
Take any cut-off — a top ten, a significance line — on the ranking before this step, and fan the members of that cut-off. The top ten of a fanned set is not a thing; the fan-out of the top ten is.
Parameters
| Parameter | Description |
|---|---|
entities | The ranking whose identifiers are to cross. Its order is read, and is not carried: the output is a set. |
table | The mapping, as an EntitySet whose own identifiers are the source side — in the same space as the ranking, so a table of the wrong space is refused before anything runs. |
column | The column of table holding the identifiers to convert to. |
to | The space the output is in, such as Ensembl gene. Declared rather than inferred, so what the output is does not depend on which identifiers happened to cross. |
id_column | The column of entities holding its identifiers. |
table_id_column | The column of table holding the source identifiers. |
Returns
A recipe, typed as the gene list the step produces — not a ranking.
Pipeline
class Pipeline(
outputs: 'Dataset | Iterable[Dataset]',
*,
claims: 'Sequence[Claim]' = (),
) -> 'None'A graph of steps, built from its outputs and checked before it runs.
Construction walks back from the outputs through the handles each step was given, so a file passed to two steps becomes one node rather than two. Nothing is executed and nothing is read: the graph is built from signatures and from the dimensions the inputs were loaded with.
Pipeline.audit
audit(
self,
step: 'str',
*,
sample: 'str | None' = None,
jobs: 'int | None' = None,
cache: 'str | Path | None' = None,
) -> 'AuditReport'Run one step twice and report whether it is reproducible.
The pipeline is run first, normally and through the cache, so that the step being audited has its inputs. Then each node of that step is run twice more, into scratch files that are thrown away, and the two outputs are compared both as bytes and as meaning.
Three verdicts, because two would not do. A step whose bytes move
while its result does not — which is every tool that records its own
command line — is reproducible, not a failure. Only a step whose
result moves is not reproducible.
Parameters
| Parameter | Description |
|---|---|
step | The step’s name, as written. Under per-sample fan-out there is one node of it per sample, and all of them are audited. |
sample | Audit only the node for this sample. |
jobs | How many steps may run at once in the preliminary run. The audited steps are taken in turn whatever this says, so that the durations reported mean something. |
cache | Where to keep the cache. Defaults to .ligase under the working directory. |
Returns
What two runs of each node showed.
Raises
| Raises | When |
|---|---|
CheckError | If the pipeline does not type-check. Nothing runs. |
ValueError | If no step of that name is in the pipeline, or no node of it is for that sample. |
Pipeline.check
check(self) -> 'None'Type-check the whole pipeline. Nothing is computed.
Every problem is found before anything is raised, so the raised
error’s errors attribute holds all of them.
Raises
| Raises | When |
|---|---|
CheckError | If anything is wrong. The subclass says what: DimensionError and its own subclasses for data that disagrees, StepTypeError for a step wired to the wrong kind of data. |
Pipeline.claims
claims: tuple[Claim, ...] # propertyThe claims this pipeline declares, as they were written.
Pipeline.errors
errors(self) -> 'list[CheckError]'Return every problem in this pipeline, without raising.
Returns
One error per problem, in the order the steps run. Empty when the pipeline is consistent. Nothing is computed.
Pipeline.methods
methods(
self,
*,
bundle: 'str | Path | None' = None,
thin: 'bool' = False,
verify: 'str | Path | None' = None,
include: 'Sequence[str | Path]' = (),
jobs: 'int | None' = None,
cache: 'str | Path | None' = None,
) -> 'Methods'Say how this pipeline’s results were produced.
The pipeline is run first, normally and through the cache, because a provenance record is filed under a step’s key and computing keys is what a run does. A pipeline already computed costs a cache lookup per step, not a recomputation.
What comes back names, for every step: the tools and the versions they ran at, the parameters as they were hashed, the command as written, and every reference by accession and checksum. A step whose output predates provenance is reported as a gap rather than left out, because nothing can reconstruct how it was produced — the command that would have said so is deliberately not in the hash.
Parameters
| Parameter | Description |
|---|---|
bundle | Write a reproducibility bundle into this directory. |
thin | Leave the object bodies out of the bundle and name the store they are in. Smaller, and not self-contained: a thin bundle needs access to that store, including for the recorded query responses that a fat one carries so a reviewer can replay with no network and no API key. For a collaborator who can already reach the lab’s store; write a fat one for a reviewer or an archive. |
verify | Check the bundle in this directory against what this machine just produced, and report where they differ. |
include | Files to copy into the bundle as they are, such as the pipeline itself. |
jobs | How many steps may run at once in the run beforehand. |
cache | Where to keep the cache. Defaults to .ligase under the working directory. |
Returns
How every step was produced, with the bundle written and the verification done, where either was asked for.
Raises
| Raises | When |
|---|---|
CheckError | If the pipeline does not type-check. Nothing runs. |
RuntimeError | If a step failed, since a pipeline that did not finish has no methods section to write; or if a bundle cannot be written. |
ValueError | If the directory to verify against is not a bundle. |
Pipeline.report
report(self) -> 'Report'Check every step and return everything found, without raising.
Pipeline.run
run(
self,
*,
jobs: 'int | None' = None,
force: 'bool' = False,
cache: 'str | Path | None' = None,
worker: 'str | bool' = False,
lease: 'int | None' = None,
) -> 'RunReport'Check the pipeline, then compute whatever is not already known.
The pipeline is checked first, every time: running an unchecked pipeline is how an overnight run ends in a dimension error.
A step runs when something that could change its result has changed — its code, a parameter, an input’s contents, or the version of a tool it calls — and not otherwise. Independent steps run at once, and a step whose rerun produced the same output as before leaves everything below it alone.
Parameters
| Parameter | Description |
|---|---|
jobs | How many steps may run at once. Defaults to the number of cores. |
force | Run every step, cache or no cache. For the one case the cache cannot see: a file rewritten in place, keeping its length and its timestamp. |
cache | Where to keep the cache. Defaults to .ligase under the working directory. A worker defaults to its own directory beneath that one, because two workers must not share an index. |
worker | Make this run one worker among several, dividing the pipeline with the others through the shared store. True for a name chosen for you, or a string to choose one — worker=os.environ["SLURM_ARRAY_TASK_ID"] is the usual thing. Needs a store every worker can see. This is not an alternative to jobs: jobs is how many steps this machine runs at once, and worker is how many machines there are. |
lease | How many seconds a worker may hold a step before another worker may compute it too. Defaults to an hour. Set it above your longest step: too short duplicates work, too long leaves others waiting after a crash, and neither is a wrong answer. |
Returns
What every step did, and where its output is.
Raises
| Raises | When |
|---|---|
CheckError | If the pipeline does not type-check. Nothing runs. |
RunError | If any step failed. Everything that did succeed is on the error’s report, which for a cohort is usually most of it. |
ValueError | If worker is set and no shared store is configured. Workers divide work by claiming it in a store they can all see, so one without a store has nobody to divide it with and would quietly compute the whole pipeline on its own. |
Pipeline.steps
steps: tuple[str, ...] # propertyThe steps this pipeline runs, in dependency order.
Step
class Step(
function: 'Callable[P,
R]',
*,
tool: 'str | Sequence[str] | None' = None,
coordinates: 'str | None' = None,
cite: 'str | Sequence[str] | None' = None,
conversion: 'str | None' = None,
stochastic: 'bool' = False,
seed: 'str | None' = None,
) -> 'None'A function that records a call instead of running.
Attributes
| Attribute | Description |
|---|---|
function | The undecorated callable. A run calls it once, before anything executes, to ask how the output is produced. |
tool | The external tools the step shells out to, as they were given. |
tools | Those tools as a tuple, whose versions join the cache key. |
coordinates | The coordinate convention the step writes, for the one output kind that does not imply one. |
conversion | The registered conversion this step performs, if it crosses a dimension rather than inheriting it. The target comes from the call rather than from here, read off the parameter the engine names. |
citations | References the step asks to be cited by. |
stochastic | Whether the step declared that its tool draws on randomness. |
seed | The name of the parameter that seeds it, for a stochastic step. Never set without stochastic, and never unset with it: the two are one declaration in two parts, checked here. |
output | The kind of data the step’s return annotation declares, read once when the step is defined. |
parameters | Every parameter the function takes, in declaration order, each already classified as data or a plain value. |
Step.dataset_parameters
dataset_parameters: tuple[Parameter, ...] # propertyThe parameters that take data, in declaration order.
Order matters: a dimension is inherited from the first argument that states it.
Step.name
name: str # propertyThe step’s name, as it appears in messages.
Step.plain_parameters
plain_parameters: tuple[Parameter, ...] # propertyThe parameters that take plain values, in declaration order.
Shell
class Shell(command: 'str') -> NoneA shell command, with the step’s inputs still to fill in.
Attributes
| Attribute | Description |
|---|---|
command | The command as written, with {placeholders}. |