Custom transforms#
This tutorial will demonstrate the integration of an external software tool, fastANI, into the Metasmith
framework.
The Jupyter notebook for this tutorial can be obtained by:
$ msm get tutorials/custom_transforms.ipynb
The completed fastANI transform can be obtained by:
$ msm get transforms/fastani.py
Prerequisites#
Metasmith is installed along with either Docker or Apptainer, since we will be deploying an agent locally
You have completed the tutorial: My first agent since this will be a direct continuation
Note
If you are following along outside of Jupyter (for example as plain Python
scripts), set WORKSPACE = Path("./") and
MLIB = WORKSPACE/"MetasmithLibraries" at the top of each script,
and treat any ipynbButtonLink(...) calls as illustrative — they
are notebook helpers and can be skipped.
The shape of this tutorial#
A transform earns its keep by connecting existing transforms — turning two disconnected halves of the type graph into a single chain that the solver can plan over.
Upstream of fastANI, the standard library already provides
getNcbiAssembly (in the logistics transform library),
which fetches genomes from NCBI and produces sequences::assembly.
Downstream of fastANI, we would like to visualise the all-vs-all comparison
as a pairwise heatmap — one cell per genome pair, color-coded by ANI.
There is no transform for that today either, so we will add a tiny
companion stub called ani_heatmap that consumes
ani::table and produces pangenome::heatmap.
Today, sequences::assembly and pangenome::heatmap are
disconnected: nothing turns a group of genomes into an ANI matrix.
By adding fastani (assembly + pangenome → ani::table), both
connections light up at once: getNcbiAssembly can feed into fastani, and
fastani can feed into ani_heatmap. Below we will render each of these
two connections as its own DAG, and close with a short remark on chaining
all three transforms end to end.
Modelling a new transform#
Metasmith models each tool as a transform between data types. Each transform is described in terms of a contact consisting of
required inputs and promised outputs.
fastANI calculates the average nucleotide identity (ANI) between two nucleotide sequences, typically genomes.
The contract for fastANI should therefore include that it requires a list of genomes and produces ANI values.
The data type sequences::assembly already exists for genomes, but we will need to create a new one for ANI.
To begin, we will create a TransformInstanceLibrary and add a stub for fastANI.
1ani_transforms_path = WORKSPACE/"ani_transforms"
2ani_transforms = TransformInstanceLibrary(ani_transforms_path)
3ani_transforms.AddStub("fastani")
4ani_transforms.Save()
Find and open the newly generated stub, which was just created under the folder ani_transforms_path.
1ipynbButtonLink(url=ani_transforms_path/"fastani.py")
The stub consists of 4 parts:
1# First, the Metasmith API is imported.
2from metasmith.python_api import *
3
4# Second, the contract is defined.
5lib = TransformInstanceLibrary.ResolveParentLibrary(__file__)
6model = Transform()
7dep = model.AddRequirement(lib.GetType("transforms::example input"))
8out = model.AddProduct(lib.GetType("transforms::example output"))
9
10# Third, the protocol for executing the tool is defined as a function.
11def protocol(context: ExecutionContext):
12 dep_path = context.Input(dep)
13 out_path = context.Output(out)
14 context.external_shell.Exec(f"touch {out_path.external}")
15 return ExecutionResult(
16 manifest=[
17 {
18 out: out_path.local,
19 },
20 ],
21 success=out_path.local.exists()
22 )
23
24# Fourth, the above components are brought together
25# to create the actual transform that Metasmith will use.
26TransformInstance(
27 protocol=protocol,
28 model=model,
29 group_by=dep,
30)
The contract#
Have a look at the contracts of two transforms used in My first agent tutorial.
getNcbiAssembly
1# ...
2lib = TransformInstanceLibrary.ResolveParentLibrary(__file__)
3model = Transform()
4dep = model.AddRequirement(lib.GetType("ncbi::assembly_accession"))
5image = model.AddRequirement(lib.GetType("containers::ncbi-datasets.oci"))
6fna = model.AddProduct(lib.GetType("sequences::assembly"))
7faa = model.AddProduct(lib.GetType("sequences::orfs"))
8gff = model.AddProduct(lib.GetType("sequences::gff"))
9gbk = model.AddProduct(lib.GetType("sequences::gbk"))
10# ...
ppanggolin
1# ...
2lib = TransformInstanceLibrary.ResolveParentLibrary(__file__)
3model = Transform()
4pan = model.AddRequirement(lib.GetType("pangenome::pangenome"))
5gbk = model.AddRequirement(lib.GetType("sequences::gbk"), parents={pan})
6image = model.AddRequirement(lib.GetType("containers::ppanggolin.oci"))
7matrix = model.AddProduct(lib.GetType("pangenome::ppanggolin_matrix"))
8pg = model.AddProduct(lib.GetType("pangenome::ppanggolin_raw"))
9# ...
The ppanggolin contract askss for a sequences::gbk, which we see is one of the four
products of getNcbiAssembly. Both also specify a container image to provide the software tool itself.
Tip
Type names are written as “namespace::type”, where the namespace is the name of a collection of related types
within a DataTypeLibrary.
More on data types
Let’s take a peek at the sequences namespace provided by the standard library.
1ipynbButtonLink(url=MLIB/"data_types/sequences.yml")
1types:
2 assembly:
3 properties:
4 Format: FASTA
5 Data: DNA sequence
6 ext: fna
7 gbk:
8 properties:
9 Format: genbank file
10 ext: gbk
11 # ...
The assembly type is described as a fasta file of nucleotide sequences,
which matches the expected input of fastANI. By using sequences::assembly,
we would expect fastANI to be able to plug directly into the output of getNcbiAssembly
and any other tools in the ecosystem that produce a sequences::assembly, without further mental effort.
Let’s modify the stub based on what we’ve learned.
1# ...
2lib = TransformInstanceLibrary.ResolveParentLibrary(__file__)
3model = Transform()
4pan = model.AddRequirement(lib.GetType("pangenome::pangenome"))
5asm = model.AddRequirement(lib.GetType("sequences::assembly"), parents={pan})
6image = model.AddRequirement(lib.GetType("ani::fastani.oci"))
7out = model.AddProduct(lib.GetType("ani::table"))
8# ...
We could add an ani_table to the pangenome namespace
and the container image fastani.oci to the containers namespace,
but let’s create a new ani namespace for the sake of this tutorial.
1ani_types_path = WORKSPACE/"ani_types.yml"
2ani_types_path.touch()
3ipynbButtonLink(url=ani_types_path)
Add the following to the newly created empty file. Since we are not currently
concerned with creating other transforms that will consume the ANI table,
we will simply create a single property to describe ani::table, effectively making it “atomic”.
Setting the file extension ext: tsv will tell Metasmith to create instances of ani::table
in the form of *.tsv and is purely cosmetic.
1types:
2 table:
3 properties:
4 _: average nucleotide identity table
5 ext: tsv
6 fastani.oci:
7 properties:
8 _: url for fastani container image
Our new namespace ani has two types.
1ani_types = DataTypeLibrary.Load(ani_types_path)
2for name, model in ani_types:
3 print(name, model)
4
5# prints:
6
7# table <{_:[average nucleotide identity],ext:tsv}:ubCCa4JV>
8# fastani.oci <[url for fastani container image]:BkrOOCzA>
We will need to inform the TransformInstanceLibrary of available types.
The following will error because the fastANI contract doesn’t agree
with the rest of the transform.
1ani_transforms_path = WORKSPACE/"ani_transforms"
2ani_transforms = TransformInstanceLibrary(ani_transforms_path)
3ani_transforms.AddTypeLibrary(lib=ani_types, namespace="ani") # new
4ani_transforms.AddTypeLibrary(MLIB/"data_types/sequences.yml") # new
5ani_transforms.AddTypeLibrary(MLIB/"data_types/pangenome.yml") # new
6ani_transforms.AddStub("fastani")
7ani_transforms.Save()
The protocol#
The protocol is executed to fullfill the contract. First, we get instances for each of the expected and promised data.
1# ...
2def protocol(context: ExecutionContext):
3 ipan = context.Input(pan)
4 iasm = context.InputGroup(asm)
5 iout = context.Output(out)
Next, we have to coerce the input files into what is expected by fastANI. The documentation suggests that a pairwise, all vs all comparison requires a file specifying the file path to each genome per line. No problem, we can write a bit of python to create the file.
1genomes = "genomes.list"
2with open(genomes, "w") as f:
3 for path in iasm:
4 f.write(str(path.container)+"\n")
Since iasm is an InputGroup, the group can be iterated on to work with each element:
1for instance in iasm:
2 # do something with instance
The most important information held in these instance objects is the path to the given data, or the expected path to the output data, where:
iout.localis the path within the current protocoliout.externalis the absolute path on the filesystemiout.containeris the path when viewed from inside a container
We can now use context.ExecWithContainer(...) to specify how fastANI
will be run with the newly created genomes file.
1threads = context.params.get('cpus')
2threads = "" if threads is None else f"--threads {threads}"
3context.ExecWithContainer(
4 image = image,
5 cmd = f"""
6 fastANI {threads} --queryList {genomes} --refList {genomes} --output {iout.container}
7 """,
8)
At the end of the protocol, we will report on the results by returning a manifest of outputs and indicating success.
1 return ExecutionResult(
2 manifest=[
3 {
4 out: iout.local,
5 },
6 ],
7 success=iout.local.exists(),
8 )
Create the transform#
With both the protocol and contract created, we can formally define the transform
for fastANI, along with default resource requests such as cpu, memory, and upper bound for runtime.
The group_by parameter specifies how to group consecutive inputs. In this case,
all other inputs will be grouped by the pangenome, which we only expect to affect
sequence::assembly.
1# ...
2TransformInstance(
3 protocol=protocol,
4 model=model, # the contract
5 group_by=pan,
6 resources=Resources(
7 cpus=4,
8 memory=Size.GB(8),
9 duration=Duration(hours=3),
10 )
11)
The full fastani.py.
1from metasmith.python_api import *
2
3lib = TransformInstanceLibrary.ResolveParentLibrary(__file__)
4model = Transform()
5pan = model.AddRequirement(lib.GetType("pangenome::pangenome"))
6asm = model.AddRequirement(lib.GetType("sequences::assembly"), parents={pan})
7image = model.AddRequirement(lib.GetType("ani::fastani.oci"))
8out = model.AddProduct(lib.GetType("ani::table"))
9
10def protocol(context: ExecutionContext):
11 ipan = context.Input(pan)
12 iasm = context.InputGroup(asm)
13 iout = context.Output(out)
14
15 genomes = "genomes.list"
16 with open(genomes, "w") as f:
17 for path in iasm:
18 f.write(str(path.container)+"\n")
19
20 threads = context.params.get('cpus')
21 threads = "" if threads is None else f"--threads {threads}"
22 context.ExecWithContainer(
23 image = image,
24 cmd = f"""
25 fastANI {threads} --queryList {genomes} --refList {genomes} --output {iout.container}
26 """,
27 )
28
29 return ExecutionResult(
30 manifest=[
31 {
32 out: iout.local,
33 },
34 ],
35 success=iout.local.exists()
36 )
37
38TransformInstance(
39 protocol=protocol,
40 model=model, # the contract
41 group_by=pan,
42 resources=Resources(
43 cpus=4,
44 memory=Size.GB(8),
45 duration=Duration(hours=3),
46 )
47)
Testing#
Our TransformInstanceLibrary should now save sucessfully.
1ani_transforms_path = WORKSPACE/"ani_transforms"
2ani_transforms = TransformInstanceLibrary(ani_transforms_path)
3ani_transforms.AddTypeLibrary(lib=ani_types, namespace="ani")
4ani_transforms.AddTypeLibrary(MLIB/"data_types/sequences.yml")
5ani_transforms.AddTypeLibrary(MLIB/"data_types/pangenome.yml")
6ani_transforms.AddStub("fastani")
7ani_transforms.AddStub("ani_heatmap")
8ani_transforms.Save()
Note
The ani_heatmap stub is added so the solver can find a
downstream consumer of ani::table. We won’t run it —
the default stub body just touch-es an output file, which
is enough for the DAG demonstrations below. A real ani_heatmap
would render an SVG from the all-vs-all similarity table.
Open the generated ani_heatmap.py and replace its contract
lines with the following (leave the default protocol body and
TransformInstance(...) call untouched):
lib = TransformInstanceLibrary.ResolveParentLibrary(__file__)
model = Transform()
ani = model.AddRequirement(lib.GetType("ani::table"))
out = model.AddProduct(lib.GetType("pangenome::heatmap"))
To test fastANI, we will need to prepare inputs and the container image.
1inputs_path = WORKSPACE/"ani_test_inputs.xgdb"
2try:
3 inputs = DataInstanceLibrary.Load(inputs_path)
4except:
5 inputs = DataInstanceLibrary(inputs_path)
6 # add data types
7 inputs.AddTypeLibrary(MLIB/"data_types/pangenome.yml")
8 inputs.AddTypeLibrary(MLIB/"data_types/ncbi.yml")
9 inputs.AddTypeLibrary(ani_types, namespace="ani")
10
11 # register inputs
12 group = inputs.AddValue("pangenome", "e coli", "pangenome::pangenome")
13 inputs.AddValue("DH10b", "GCF_000019425.1", "ncbi::assembly_accession", parents={group})
14 inputs.AddValue("K12", "GCF_000005845.2", "ncbi::assembly_accession", parents={group})
15 inputs.AddValue("EPI300", "GCF_049667475.1", "ncbi::assembly_accession", parents={group})
16 inputs.AddValue("fastani.oci", "docker://staphb/fastani:1.34", "ani::fastani.oci")
17 inputs.Save()
We will prepare resources and transforms using the same method shown in the My first agent tutorial.
1resources = [
2 DataInstanceLibrary.Load(MLIB/f"resources/{n}")
3 for n in ["containers"]
4] + [
5 view
6 for view in inputs.AsSamples("ani::fastani.oci")
7]
8
9transforms = [
10 TransformInstanceLibrary.Load(MLIB/f"transforms/{n}")
11 for n in ["logistics"]
12] + [
13 ani_transforms
14]
Upstream chain#
The first DAG demonstrates how fastani plugs into the existing
getNcbiAssembly transform. We start from NCBI accessions and
target ani::table; the solver discovers the chain
getNcbiAssembly → fastani automatically.
1agent_home = Source.FromLocal(WORKSPACE/"msm_home")
2smith = Agent(
3 home = agent_home,
4 runtime=ContainerRuntime.DOCKER,
5)
6
7upstream_targets = TargetBuilder()
8upstream_targets.Add("ani::table")
9task = smith.GenerateWorkflow(
10 samples=inputs.AsSamples("ncbi::assembly_accession"),
11 resources=resources,
12 transforms=transforms,
13 targets=upstream_targets,
14)
15
16upstream_dag = task.plan.RenderDAG(WORKSPACE/"ani_dag_upstream.svg")
17ipynbButtonLink(upstream_dag)
getNcbiAssembly produces sequences::assembly, which
fastani consumes to produce ani::table.#
Downstream chain#
The second DAG demonstrates how fastani plugs into the new
ani_heatmap stub. We mock a starting set of
sequences::assembly samples and target pangenome::heatmap;
the solver discovers the chain fastani → ani_heatmap.
1ani_demo_inputs_path = WORKSPACE/"ani_demo_inputs.xgdb"
2try:
3 ani_demo_inputs = DataInstanceLibrary.Load(ani_demo_inputs_path)
4except:
5 ani_demo_inputs = DataInstanceLibrary(ani_demo_inputs_path)
6 ani_demo_inputs.AddTypeLibrary(MLIB/"data_types/pangenome.yml")
7 ani_demo_inputs.AddTypeLibrary(MLIB/"data_types/sequences.yml")
8 ani_demo_inputs.AddTypeLibrary(ani_types, namespace="ani")
9
10 # mock assemblies — the solver only needs samples to plan a DAG;
11 # the values are placeholder paths, never read
12 group = ani_demo_inputs.AddValue("pangenome", "e coli", "pangenome::pangenome")
13 ani_demo_inputs.AddValue("DH10b", "mock_DH10b.fna", "sequences::assembly", parents={group})
14 ani_demo_inputs.AddValue("K12", "mock_K12.fna", "sequences::assembly", parents={group})
15 ani_demo_inputs.AddValue("fastani.oci", "docker://staphb/fastani:1.34", "ani::fastani.oci")
16 ani_demo_inputs.Save()
17
18downstream_resources = [
19 view for view in ani_demo_inputs.AsSamples("ani::fastani.oci")
20]
21downstream_targets = TargetBuilder()
22downstream_targets.Add("pangenome::heatmap")
23downstream_task = smith.GenerateWorkflow(
24 samples=ani_demo_inputs.AsSamples("sequences::assembly"),
25 resources=downstream_resources,
26 transforms=transforms,
27 targets=downstream_targets,
28)
29
30downstream_dag = downstream_task.plan.RenderDAG(WORKSPACE/"ani_dag_downstream.svg")
31ipynbButtonLink(downstream_dag)
The rendered DAG shows fastani producing ani::table and
ani_heatmap consuming it to produce pangenome::heatmap.
Chaining all three#
Tip
Targeting pangenome::heatmap from ncbi::assembly_accession
sources directly would produce the full chain
accession → assembly → ani::table → heatmap in a single DAG.
The construction is identical — change only the target on the upstream
plan above and let the solver discover the full chain. We don’t
demonstrate it here; the two demonstrations above already cover both
of the new connections in isolation.
Stage and run#
Finally, we can stage and run the upstream task to actually compute an ANI table. The following also includes a bit of resource tweaks to let the three download steps execute concurrently.
1smith.StageWorkflow(task, on_exist="update")
2
3smith.RunWorkflow(
4 task,
5 config_file=smith.GetNxfConfigPresets()["local"],
6 params= dict(
7 executor=dict(
8 cpus=14,
9 queueSize=3, # explicitly set 3 jobs to run in parallel
10 ),
11 process=dict(
12 tries=1,
13 ),
14 ),
15 resource_overrides={
16 "*": Resources(
17 memory=Size.GB(1),
18 ),
19 "fastani": Resources(
20 cpus=14, # give fastANI all the threads
21 )
22 }
23)
Tip
It is possible to perform a dry run by setting stub_delay to a positive number.
This will have nextflow execute mock protocols for each process.
1smith.RunWorkflow(
2 # ...
3 stub_delay=3.0,
4)
Once complete, we can have a look at the results.
1results_path = smith.GetResultSource(task).GetPath()
2results = DataInstanceLibrary.Load(results_path)
3
4ipynbButtonLink(results_path/"_metadata/logs.latest/nxf_report.html")
5
6for path, type_name, endpoint in results.Iterate():
7 if path.is_absolute(): continue # inputs have absolute paths
8 ipynbButtonLink(results_path/path, f'view {type_name} {path.name}')
Next steps#
Other tutorials are available in the section panel on the left.