Skip to Content
ReferenceAPIDeclaring and running steps

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

ParameterDescription
functionThe function to decorate, when used bare as @lg.step.
toolThe 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.
coordinatesThe 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.
citeHow 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.
conversionThe 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.
stochasticThat 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.
seedThe 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

RaisesWhen
StepDefinitionErrorIf 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.
ValueErrorIf 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 parameter ref.
  • {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

ParameterDescription
commandThe 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

ParameterDescription
functionCalled 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

ParameterDescription
outputWhat 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

RaisesWhen
TypeErrorIf 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

ParameterDescription
entitiesThe list to convert.
tableThe 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.
columnThe 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.
toThe 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_columnThe column of entities holding its identifiers.
table_id_columnThe column of table holding the source identifiers.
ambiguousWhat 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

ParameterDescription
entitiesThe ranking to convert.
tableThe 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.
columnThe column of table holding the identifiers to convert to.
toThe 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_columnThe column of entities holding its identifiers.
table_id_columnThe 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

ParameterDescription
entitiesThe ranking whose identifiers are to cross. Its order is read, and is not carried: the output is a set.
tableThe 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.
columnThe column of table holding the identifiers to convert to.
toThe 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_columnThe column of entities holding its identifiers.
table_id_columnThe 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

ParameterDescription
stepThe step’s name, as written. Under per-sample fan-out there is one node of it per sample, and all of them are audited.
sampleAudit only the node for this sample.
jobsHow 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.
cacheWhere to keep the cache. Defaults to .ligase under the working directory.

Returns

What two runs of each node showed.

Raises

RaisesWhen
CheckErrorIf the pipeline does not type-check. Nothing runs.
ValueErrorIf 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

RaisesWhen
CheckErrorIf 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, ...] # property

The 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

ParameterDescription
bundleWrite a reproducibility bundle into this directory.
thinLeave 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.
verifyCheck the bundle in this directory against what this machine just produced, and report where they differ.
includeFiles to copy into the bundle as they are, such as the pipeline itself.
jobsHow many steps may run at once in the run beforehand.
cacheWhere 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

RaisesWhen
CheckErrorIf the pipeline does not type-check. Nothing runs.
RuntimeErrorIf a step failed, since a pipeline that did not finish has no methods section to write; or if a bundle cannot be written.
ValueErrorIf 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

ParameterDescription
jobsHow many steps may run at once. Defaults to the number of cores.
forceRun 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.
cacheWhere 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.
workerMake 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.
leaseHow 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

RaisesWhen
CheckErrorIf the pipeline does not type-check. Nothing runs.
RunErrorIf any step failed. Everything that did succeed is on the error’s report, which for a cohort is usually most of it.
ValueErrorIf 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, ...] # property

The 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

AttributeDescription
functionThe undecorated callable. A run calls it once, before anything executes, to ask how the output is produced.
toolThe external tools the step shells out to, as they were given.
toolsThose tools as a tuple, whose versions join the cache key.
coordinatesThe coordinate convention the step writes, for the one output kind that does not imply one.
conversionThe 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.
citationsReferences the step asks to be cited by.
stochasticWhether the step declared that its tool draws on randomness.
seedThe 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.
outputThe kind of data the step’s return annotation declares, read once when the step is defined.
parametersEvery parameter the function takes, in declaration order, each already classified as data or a plain value.

Step.dataset_parameters

dataset_parameters: tuple[Parameter, ...] # property

The parameters that take data, in declaration order.

Order matters: a dimension is inherited from the first argument that states it.

Step.name

name: str # property

The step’s name, as it appears in messages.

Step.plain_parameters

plain_parameters: tuple[Parameter, ...] # property

The parameters that take plain values, in declaration order.

Shell

class Shell(command: 'str') -> None

A shell command, with the step’s inputs still to fill in.

Attributes

AttributeDescription
commandThe command as written, with {placeholders}.
Last updated on