diff --git a/README.md b/README.md index 494e096..56bb138 100644 --- a/README.md +++ b/README.md @@ -32,46 +32,48 @@ Use `"lab-compiler[opentrons,star]"` to install both SDKs. ## Write a protocol -You define the strain, chassis, and plasmids, then compile that transformation for the selected target. This example compiles a heat-shock transformation using material identifiers supplied by the user. +Describe biological designs with native pySBOL3 components. An experiment builder records their laboratory operations in a `Protocol`, and `compile()` validates and compiles that protocol for the selected target. ```python +import sbol3 from lab import compile from lab.equipment import LiquidHandler -from lab.experiments.cloning import ( - Transformation, - transformation_deck, +from lab.experiments.cloning import Transformation, build_transformation, transformation_deck + +designs = sbol3.Document() +strain = sbol3.Component("https://example.org/my_strain", sbol3.SBO_FUNCTIONAL_ENTITY) +chassis = sbol3.Component("https://example.org/my_cells", sbol3.SBO_FUNCTIONAL_ENTITY) +plasmid = sbol3.Component("https://example.org/my_plasmid", sbol3.SBO_DNA) +designs.add([strain, chassis, plasmid]) + +protocol = build_transformation( + [Transformation(strain=strain, chassis=chassis, plasmids=[plasmid])], + name="transformation", ) -from lab.part import Part - -compile( - Transformation( - id="transformation-1", - strain=Part("https://example.org/my-strain/1"), - chassis=Part("https://example.org/my-cells/1"), - plasmids=[Part("https://example.org/my-plasmid/1")], - ), +compiled = compile( + protocol, deck=transformation_deck(on_module=True), liquid_handler=LiquidHandler.OT2, ) ``` -`compile` accepts an `Assembly`, an `AssemblyRequest`, a `Transformation`, a `TransformationRequest`, or a `PlatingRequest`. One assembly is a protocol named by `assembly.id`. One transformation is a protocol named by `transformation.id`. An `AssemblyRequest` names a protocol that holds several assemblies, and a `TransformationRequest` does the same for several transformations. `Transformation` names an output strain, its chassis, and its plasmids as `Part` IRIs. Compilation assigns logical wells and records the transfers, heat shock, and recovery steps. The result includes an output manifest for downstream stages. The [cloning example](https://github.com/the-lab-compiler/lab-py/blob/master/examples/cloning.py) defines its materials, assemblies, and transformations directly and links assembly, transformation, and plating. +`lab.compile()` accepts a `Protocol`. It captures an immutable snapshot, validates resources and volumes, prepares the target, and returns one `Compilation`. Inspect `compiled.protocol` for the captured snapshot. All targets and the document renderer consume those same recorded operations. -`transformation_deck(on_module=True)` names the 24-well DNA block, the cell tubes, and the reaction plate. This is an Opentrons preset; the example uses `LiquidHandler.OT2` and writes the bundle to `~/.lab/transformation-1/OT-2/`. Leave out `deck` and `liquid_handler` for a document with no robot. That writes `~/.lab/transformation-1/Manual/`. +`build_assembly()` takes a sequence of `Assembly` recipes; `build_transformation()` takes a sequence of `Transformation` recipes. Their design fields are native `sbol3.Component` objects, including components loaded from an SBOL file with `designs.read()`. Recipe fields specify the materials used by the procedure; SBOL owns their biological descriptions. Builders copy identities into protocol samples so later edits to SBOL objects cannot change the recorded plan. -`lab.compile()` lays out a cloning request, then snapshots its operations, samples, lineage, and output placements, validates them, and produces one `Compilation`. A `Protocol` compiles the same way. Inspect `compiled.protocol` for that recorded snapshot. Hardware targets consume the same recorded operations used by the document renderer. `build_assembly`, `build_transformation`, and `build_plating` return the `Protocol` when you want it before choosing a target. +`transformation_deck(on_module=True)` names the DNA block, cell tubes, and reaction plate. The example writes to `~/.lab/transformation/OT-2/`. Omit `deck` and `liquid_handler` for a manual document. Set `to` to choose an output directory, or `to=None` to compile without writing. The [transformation example](examples/transformation.py) uses native SBOL designs and explicit procedure quantities. ## Samples and protocol outputs -`compiled.manifest` is an `OutputManifest` containing the declared output samples and their logical placements. Pass an assembly's manifest as `inputs` when compiling a `TransformationRequest`, then pass the transformation's manifest as `inputs` when compiling a `PlatingRequest`. The [cloning example](https://github.com/the-lab-compiler/lab-py/blob/master/examples/cloning.py) compiles each stage separately. +`compiled.manifest` contains declared output samples and logical placements. Pass it as `inputs=` to `build_transformation()`, then pass the resulting transformation manifest to `build_plating()`. The [cloning example](examples/cloning.py) shows assembly, transformation, and plating as separately authored and compiled protocols. Plating processes the manifest's samples in their declared order. Both downstream builders require their source samples to occupy one logical container. -The core cloning types live in `lab.experiments.cloning.types` and are exported from `lab.experiments.cloning`. `Assembly` describes a product and its constituent parts; `Transformation` describes a strain, its chassis, and its plasmids. `AssemblyRequest` groups assemblies, `TransformationRequest` groups transformations, and `PlatingRequest` selects and orders source samples by id. +For custom protocols, use `protocol.add_sample(sample, at=well, is_input=True)` or `is_output=True`. `Sample.design` and `Sample.implementation` hold optional SBOL identity strings. Parent IDs identify contributions in the same protocol; imported samples identify their upstream protocol and sample separately. Compilation checks references, locations, and cyclic lineage. Manifests describe planned outputs. -A plating request's sample ids cover the entire input manifest. Transformation and plating each validate and interpret their input manifest for their own layout, which accepts a single source container. Their requests can set `source_stage_id` to assert the expected input protocol. `Assembly` and `AssemblyRequest` have no upstream input. +`lab.samples` also defines `Location(resource, well)`, `SamplePlacement`, and `OutputManifest`. Recorded operations, samples, target bindings, and volume accounting share that logical location type. Native SBOL documents remain caller-owned and can be written alongside the compilation artifacts. -For custom protocols, declare typed sample metadata with `protocol.add_sample(sample, at=well, is_input=True)` or `is_output=True`, using `Sample` from `lab.samples`. Loads and operations own volume accounting. Parent ids refer to samples in the same protocol; imported samples identify their upstream protocol and sample separately. Compilation checks sample references and locations and rejects cyclic lineage. Manifests describe planned outputs, not completed execution. +## SBOL provenance -`lab.samples` also defines `Location(resource, well)`, `SamplePlacement`, and `OutputManifest`. Recorded operations, sample placements, target bindings, and final volume accounting use the same logical `Location` type. For example, an output placement's `location` can be used directly as a key in `dict(compiled.final_volumes)`. `lab.part.Part` identifies a biological part by its SBOL IRI; cloning types and stage builders live under `lab.experiments.cloning`. +Protocol samples can link to native pySBOL3 designs and implementations by identity. The [SBOL provenance guide](docs/sbol-provenance.md) covers authoring, validation, and annotation propagation. Run `uv run python -m examples.sbol_provenance` for an example. ## Describe a deck diff --git a/docs/sbol-provenance.md b/docs/sbol-provenance.md new file mode 100644 index 0000000..0814fcb --- /dev/null +++ b/docs/sbol-provenance.md @@ -0,0 +1,49 @@ +# SBOL designs and provenance + +Use pySBOL3 to author, read, validate, and write biological designs and provenance. `Component` and `Sequence` describe designs; `Activity`, `Usage`, `Association`, `Agent`, and `Plan` describe their provenance. A collection of designs is an ordinary `sbol3.Document`, named `designs` in the examples. + +## Provide designs to an experiment + +Assembly and transformation recipes take native `sbol3.Component` objects. They can come from Python authoring or an existing SBOL file: + +```python +import sbol3 +from lab import compile +from lab.experiments.cloning import Transformation, build_transformation + +designs = sbol3.Document() +designs.read("designs.ttl") +protocol = build_transformation( + [Transformation( + strain=designs.find("https://example.org/my_strain"), + chassis=designs.find("https://example.org/my_cells"), + plasmids=[designs.find("https://example.org/my_plasmid")], + )], + name="transformation", +) +compiled = compile(protocol, to=None) +``` + +An `Assembly` names the product, backbone, parts, and restriction enzyme used by that procedure. A `Transformation` names the strain, chassis, and plasmids. These recipes specify procedure inputs; their components retain the full native SBOL API for sequences, features, roles, and provenance. Use `designs.validate()` to check the SBOL graph. + +`build_assembly()` and `build_transformation()` copy component identities and names into protocol samples. `build_plating()` consumes a preceding output manifest and preserves design links on its products. `lab.compile()` accepts only the resulting `Protocol`; it captures the immutable snapshot internally. The compiler does not select an experiment from a biological design. + +## Preserve identity and provenance + +`Sample.design` identifies the intended design and must match `material_identity`. `Sample.implementation` optionally identifies a supplied material record. Both fields contain absolute IRI strings and are included in the plan and output manifest. Builders retain no mutable SBOL objects in the protocol snapshot. + +An imported sample retains its upstream annotations. A new product carries its intended design without asserting a physical implementation. Parent sample IDs describe material contributions, not genetic ancestry. Native SBOL provenance stays in the caller-owned `designs` container; references do not automatically fetch or embed other documents. + +Use full SBOL3 identities when authoring objects, such as `https://example.org/dna_1`, to avoid global namespace settings. The final segment must be a valid SBOL display ID. Write designs alongside compilation artifacts with `designs.write("designs.ttl", sbol3.TURTLE)`. + +## Examples + +[transformation.py](../examples/transformation.py) compiles native SBOL designs with explicit procedure quantities. [cloning.py](../examples/cloning.py) carries those identities through assembly, transformation, and plating. Both save the native SBOL graph alongside their artifacts. + +[sbol_provenance.py](../examples/sbol_provenance.py) records computational design creation with SBOL provenance objects, then authors a planned aliquot protocol. Its sequence and quantities are synthetic inputs; its activity describes design authoring, not laboratory execution. + +```sh +uv run python -m examples.sbol_provenance --out build/sbol_provenance +uv run python -m examples.cloning --target manual --out build/cloning +uv run --extra opentrons python -m examples.transformation +``` diff --git a/examples/cloning.py b/examples/cloning.py index 3cca10f..72fd2b1 100644 --- a/examples/cloning.py +++ b/examples/cloning.py @@ -3,47 +3,68 @@ import argparse from pathlib import Path +import sbol3 + from lab import compile from lab.experiments.cloning import ( BSAI, Assembly, - AssemblyRequest, - PlatingRequest, Transformation, - TransformationRequest, assembly_deck, + build_assembly, + build_plating, + build_transformation, plating_deck, transformation_deck, ) -from lab.part import Part from lab.targets import LiquidHandler -PSB1C3 = Part("https://sbolcanvas.org/pSB1C3/1") -J23101 = Part("https://sbolcanvas.org/J23101/1") -J23106 = Part("https://sbolcanvas.org/J23106/1") -B0034 = Part("https://sbolcanvas.org/B0034/1") -GFP = Part("https://sbolcanvas.org/GFP/1") -RFP = Part("https://sbolcanvas.org/RFP/1") -B0015 = Part("https://sbolcanvas.org/B0015/1") -DH5ALPHA = Part("https://sbolcanvas.org/DH5alpha/1") -BL21 = Part("https://sbolcanvas.org/BL21/1") -PLASMID_1 = Part("https://SBOL2Build.org/composite_plasmid_1/1") -PLASMID_2 = Part("https://SBOL2Build.org/composite_plasmid_2/1") -STRAIN_1 = Part("https://SBOL2Build.org/composite_strain_1/1") -STRAIN_2 = Part("https://SBOL2Build.org/composite_strain_2/1") -STRAIN_3 = Part("https://SBOL2Build.org/composite_strain_3/1") -STRAIN_4 = Part("https://SBOL2Build.org/composite_strain_4/1") +PSB1C3 = sbol3.Component("https://sbolcanvas.org/pSB1C3", sbol3.SBO_DNA) +J23101 = sbol3.Component("https://sbolcanvas.org/J23101", sbol3.SBO_DNA) +J23106 = sbol3.Component("https://sbolcanvas.org/J23106", sbol3.SBO_DNA) +B0034 = sbol3.Component("https://sbolcanvas.org/B0034", sbol3.SBO_DNA) +GFP = sbol3.Component("https://sbolcanvas.org/GFP", sbol3.SBO_DNA) +RFP = sbol3.Component("https://sbolcanvas.org/RFP", sbol3.SBO_DNA) +B0015 = sbol3.Component("https://sbolcanvas.org/B0015", sbol3.SBO_DNA) +DH5ALPHA = sbol3.Component("https://sbolcanvas.org/DH5alpha", sbol3.SBO_FUNCTIONAL_ENTITY) +BL21 = sbol3.Component("https://sbolcanvas.org/BL21", sbol3.SBO_FUNCTIONAL_ENTITY) +PLASMID_1 = sbol3.Component("https://SBOL2Build.org/composite_plasmid_1", sbol3.SBO_DNA) +PLASMID_2 = sbol3.Component("https://SBOL2Build.org/composite_plasmid_2", sbol3.SBO_DNA) +STRAIN_1 = sbol3.Component("https://SBOL2Build.org/composite_strain_1", sbol3.SBO_FUNCTIONAL_ENTITY) +STRAIN_2 = sbol3.Component("https://SBOL2Build.org/composite_strain_2", sbol3.SBO_FUNCTIONAL_ENTITY) +STRAIN_3 = sbol3.Component("https://SBOL2Build.org/composite_strain_3", sbol3.SBO_FUNCTIONAL_ENTITY) +STRAIN_4 = sbol3.Component("https://SBOL2Build.org/composite_strain_4", sbol3.SBO_FUNCTIONAL_ENTITY) + +designs = sbol3.Document() +designs.add( + [ + BSAI, + PSB1C3, + J23101, + J23106, + B0034, + GFP, + RFP, + B0015, + DH5ALPHA, + BL21, + PLASMID_1, + PLASMID_2, + STRAIN_1, + STRAIN_2, + STRAIN_3, + STRAIN_4, + ] +) ASSEMBLIES = ( Assembly( - id="assembly-1", product=PLASMID_1, backbone=PSB1C3, parts=[J23101, B0034, GFP, B0015], restriction_enzyme=BSAI, ), Assembly( - id="assembly-2", product=PLASMID_2, backbone=PSB1C3, parts=[J23106, B0034, RFP, B0015], @@ -53,25 +74,21 @@ STRAINS = ( Transformation( - id="transformation-1", strain=STRAIN_1, chassis=DH5ALPHA, plasmids=[PLASMID_1], ), Transformation( - id="transformation-2", strain=STRAIN_2, chassis=DH5ALPHA, plasmids=[PLASMID_2], ), Transformation( - id="transformation-3", strain=STRAIN_3, chassis=BL21, plasmids=[PLASMID_1], ), Transformation( - id="transformation-4", strain=STRAIN_4, chassis=BL21, plasmids=[PLASMID_2], @@ -90,28 +107,31 @@ def main() -> None: parser.add_argument("--out", default=None) args = parser.parse_args() liquid_handler = None if args.target == "manual" else LiquidHandler(args.target) + protocol = build_assembly(ASSEMBLIES, name="sbol-loop-assembly") assembled = compile( - AssemblyRequest(id="sbol-loop-assembly", assemblies=ASSEMBLIES), + protocol, deck=None if liquid_handler is None else assembly_deck(), liquid_handler=liquid_handler, + to=None, ) + protocol = build_transformation(STRAINS, inputs=assembled.manifest, name="heat-shock") transformed = compile( - TransformationRequest(id="heat-shock", transformations=STRAINS), + protocol, deck=None if liquid_handler is None else transformation_deck(), liquid_handler=liquid_handler, - inputs=assembled.manifest, + to=None, ) + protocol = build_plating(transformed.manifest) plated = compile( - PlatingRequest( - id="plating", - sample_ids=tuple(sample.id for sample in transformed.manifest.samples), - source_stage_id=transformed.manifest.protocol_id, - ), + protocol, deck=None if liquid_handler is None else plating_deck(), liquid_handler=liquid_handler, - inputs=transformed.manifest, + to=None, ) out = Path(args.out or f"build/cloning/{args.target}") + out.mkdir(parents=True, exist_ok=True) + assert not designs.validate().errors + designs.write(str(out / "designs.ttl"), sbol3.TURTLE) for name, compiled in ( ("assembly", assembled), ("transformation", transformed), diff --git a/examples/sbol_provenance.py b/examples/sbol_provenance.py new file mode 100644 index 0000000..19eaf44 --- /dev/null +++ b/examples/sbol_provenance.py @@ -0,0 +1,66 @@ +"""Native SBOL design provenance and an annotated aliquot protocol.""" + +import argparse +from pathlib import Path + +import sbol3 + +import lab +from lab.samples import Sample + + +def inputs() -> tuple[sbol3.Document, lab.Protocol]: + namespace = "https://example.org/aliquot" + sequence = sbol3.Sequence( + f"{namespace}/sequence", elements="ACGTACGT", encoding=sbol3.IUPAC_DNA_ENCODING + ) + author = sbol3.Agent(f"{namespace}/author", name="SBOL example script") + activity = sbol3.Activity( + f"{namespace}/design_creation", + usage=[sbol3.Usage(sequence.identity)], + association=[sbol3.Association(author)], + ) + design = sbol3.Component( + f"{namespace}/design", + sbol3.SBO_DNA, + sequences=[sequence], + generated_by=[activity], + ) + designs = sbol3.Document() + designs.add([sequence, author, activity, design]) + protocol = lab.Protocol("Annotated aliquot") + plate = protocol.plate("plate", capacity=100 * lab.uL) + protocol.load(plate["A1"], design.identity, volume=20 * lab.uL) + protocol.add_sample( + Sample(id="source", material_identity=design.identity, label="DNA", design=design.identity), + at=plate["A1"], + is_input=True, + ) + protocol.add_sample( + Sample( + id="aliquot", + material_identity=design.identity, + label="Planned aliquot", + design=design.identity, + parent_ids=("source",), + ), + at=plate["A2"], + is_output=True, + ) + protocol.transfer(plate["A1"], plate["A2"], volume=2 * lab.uL) + return designs, protocol + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--out", type=Path, default=Path("build/sbol_provenance")) + args = parser.parse_args() + designs, protocol = inputs() + assert not designs.validate().errors + output = lab.compile(protocol, to=None).write(args.out) + designs.write(str(output / "designs.ttl"), sbol3.TURTLE) + print(output) + + +if __name__ == "__main__": + main() diff --git a/examples/transformation.py b/examples/transformation.py index d160df1..48e9875 100644 --- a/examples/transformation.py +++ b/examples/transformation.py @@ -6,21 +6,24 @@ from pathlib import Path +import sbol3 + from lab import compile, mL, uL from lab.experiments.cloning import ( Transformation, - TransformationRequest, build_transformation, transformation_deck, ) -from lab.part import Part from lab.targets import LiquidHandler -DNA_1 = Part("https://example.org/dna-1/1") -DNA_2 = Part("https://example.org/dna-2/1") -DH5ALPHA = Part("https://sbolcanvas.org/DH5alpha/1") -STRAIN_1 = Part("https://example.org/transformant-1/1") -STRAIN_2 = Part("https://example.org/transformant-2/1") +DNA_1 = sbol3.Component("https://example.org/dna_1", sbol3.SBO_DNA) +DNA_2 = sbol3.Component("https://example.org/dna_2", sbol3.SBO_DNA) +DH5ALPHA = sbol3.Component("https://sbolcanvas.org/DH5alpha", sbol3.SBO_FUNCTIONAL_ENTITY) +STRAIN_1 = sbol3.Component("https://example.org/transformant_1", sbol3.SBO_FUNCTIONAL_ENTITY) +STRAIN_2 = sbol3.Component("https://example.org/transformant_2", sbol3.SBO_FUNCTIONAL_ENTITY) + +designs = sbol3.Document() +designs.add([DNA_1, DNA_2, DH5ALPHA, STRAIN_1, STRAIN_2]) REPLICATES = 3 CELLS_PER_REACTION = 20 * uL @@ -28,13 +31,11 @@ TRANSFORMATIONS = ( Transformation( - id="transformation-1", strain=STRAIN_1, chassis=DH5ALPHA, plasmids=[DNA_1], ), Transformation( - id="transformation-2", strain=STRAIN_2, chassis=DH5ALPHA, plasmids=[DNA_2], @@ -43,17 +44,21 @@ if __name__ == "__main__": out = Path(f"build/transformation/{LiquidHandler.OT2.value}") + protocol = build_transformation( + TRANSFORMATIONS, + name="transformation", + replicates=REPLICATES, + transfer_volume_competent_cell=CELLS_PER_REACTION, + tube_volume_competent_cell=CELL_ALIQUOT, + ) compiled = compile( - build_transformation( - TransformationRequest(id="transformation", transformations=TRANSFORMATIONS), - replicates=REPLICATES, - transfer_volume_competent_cell=CELLS_PER_REACTION, - tube_volume_competent_cell=CELL_ALIQUOT, - ), + protocol, deck=transformation_deck(on_module=True), liquid_handler=LiquidHandler.OT2, to=out, ) + assert not designs.validate().errors + designs.write(str(out / "designs.ttl"), sbol3.TURTLE) used = len(TRANSFORMATIONS) * REPLICATES * CELLS_PER_REACTION print( f"{compiled.directory}: {used.to(mL):.2f~P} of cells from one {CELL_ALIQUOT:.3g~P} aliquot" diff --git a/pyproject.toml b/pyproject.toml index 2b0a7ee..0d888ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,10 @@ classifiers = [ "Topic :: Scientific/Engineering", "Typing :: Typed", ] -dependencies = ["pint>=0.24,<0.27"] +dependencies = [ + "pint>=0.24,<0.27", + "sbol3==1.2.0.post0", +] [project.urls] Homepage = "https://github.com/the-lab-compiler/lab-py" @@ -87,5 +90,5 @@ check_untyped_defs = true disallow_untyped_defs = true [[tool.mypy.overrides]] -module = ["opentrons.*", "pylabrobot.*"] +module = ["opentrons.*", "pylabrobot.*", "sbol3.*"] ignore_missing_imports = true diff --git a/scripts/check_install.py b/scripts/check_install.py index d3bfb05..fd1bc79 100644 --- a/scripts/check_install.py +++ b/scripts/check_install.py @@ -8,6 +8,8 @@ from pathlib import Path from tempfile import TemporaryDirectory +import sbol3 + import lab from lab.targets import Manual @@ -15,7 +17,6 @@ def main() -> None: # Verify public modules and subpackages are present in the installed distribution. import_module("lab.experiments.cloning") - import_module("lab.part") import_module("lab.samples") package = distribution("lab-compiler") @@ -27,13 +28,17 @@ def main() -> None: assert lab.__file__ is not None assert not Path(lab.__file__).resolve().is_relative_to(Path(__file__).resolve().parents[1]) + designs = sbol3.Document() + designs.add(sbol3.Component("https://example.org/install_check/design", sbol3.SBO_DNA)) + assert not designs.validate().errors + protocol = lab.Protocol("Installed package check") source = protocol.container( "source", contents="water", volume=100 * lab.uL, capacity=200 * lab.uL ) destination = protocol.container("destination", capacity=200 * lab.uL) protocol.transfer(source, destination, volume=10 * lab.uL) - compilation = lab.compile(protocol, Manual()) + compilation = lab.compile(protocol, Manual(), to=None) with TemporaryDirectory() as directory: output = compilation.write(directory) plan = json.loads((output / "plan.json").read_text()) diff --git a/src/lab/_identifiers.py b/src/lab/_identifiers.py new file mode 100644 index 0000000..a2c99ff --- /dev/null +++ b/src/lab/_identifiers.py @@ -0,0 +1,19 @@ +"""Identity validation for protocol records and SBOL links.""" + +import re +from urllib.parse import urlsplit + + +def require_iri(value: str) -> str: + """Require an absolute IRI; never resolve, fetch, or rewrite it.""" + if ( + not isinstance(value, str) + or not value + or re.search(r'[\s<>"{}|\\^`\x00-\x1f\x7f]', value) + or not re.match(r"^[A-Za-z][A-Za-z0-9+.-]*:", value) + ): + raise ValueError(f"Expected an absolute IRI, got {value!r}") + parsed = urlsplit(value) + if parsed.scheme in {"http", "https"} and not parsed.netloc: + raise ValueError(f"Expected an absolute IRI, got {value!r}") + return value diff --git a/src/lab/compiler.py b/src/lab/compiler.py index 2c206b3..a9d71bb 100644 --- a/src/lab/compiler.py +++ b/src/lab/compiler.py @@ -6,22 +6,12 @@ from dataclasses import dataclass, replace from decimal import Decimal from pathlib import Path -from typing import Any, overload +from typing import Any from typing import Protocol as Interface import lab.documents as documents from lab._version import __version__ from lab.deck import Deck -from lab.experiments.cloning.stages.assembly import build_assembly -from lab.experiments.cloning.stages.plating import build_plating -from lab.experiments.cloning.stages.transformation import build_transformation -from lab.experiments.cloning.types import ( - Assembly, - AssemblyRequest, - PlatingRequest, - Transformation, - TransformationRequest, -) from lab.model import Distribute, Mix, RecordedProtocol, TargetPlan, Transfer, encode from lab.protocol import Protocol from lab.samples import Location, OutputManifest @@ -143,71 +133,23 @@ def _write_output(directory: Path, files: dict[str, str]) -> None: path.unlink() -@overload def compile( - protocol: Protocol | Assembly | AssemblyRequest, + protocol: Protocol, target: Target | None = None, *, deck: Deck | None = None, liquid_handler: LiquidHandler | None = None, - inputs: None = None, - to: str | Path | None = ..., -) -> Compilation: ... - - -@overload -def compile( - protocol: Transformation | TransformationRequest, - target: Target | None = None, - *, - deck: Deck | None = None, - liquid_handler: LiquidHandler | None = None, - inputs: OutputManifest | None = None, - to: str | Path | None = ..., -) -> Compilation: ... - - -@overload -def compile( - protocol: PlatingRequest, - target: Target | None = None, - *, - deck: Deck | None = None, - liquid_handler: LiquidHandler | None = None, - inputs: OutputManifest, - to: str | Path | None = ..., -) -> Compilation: ... - - -def compile( - protocol: ( - Protocol - | Assembly - | AssemblyRequest - | Transformation - | TransformationRequest - | PlatingRequest - ), - target: Target | None = None, - *, - deck: Deck | None = None, - liquid_handler: LiquidHandler | None = None, - inputs: OutputManifest | None = None, to: str | Path | None | _DefaultOutput = _DEFAULT_OUTPUT, ) -> Compilation: - """Compile a protocol or a cloning request. + """Snapshot and compile a protocol for a manual document or a robot. - One assembly uses its id as the protocol name. An assembly request names a - protocol that holds every assembly. One transformation uses its id as the - protocol name. A transformation or plating request takes ``inputs`` as the - upstream manifest. Leave out ``deck`` and ``liquid_handler`` for a manual - document. Pass ``deck`` with a liquid handler to compile for a robot. The - bundle is written to ``~/.lab///``, or to ``to``. - ``LAB_HOME`` replaces ``~/.lab``. ``to=None`` skips the write. A deck - contains shared requirements and optional Lab-owned layouts. The selected - backend validates and translates its layout or supported preset. Concrete - backend targets are also accepted for low-level integrations. + Experiment builders produce the protocol before target selection. Pass + ``deck`` with a liquid handler for a robot, or omit both for a manual document. + Bundles are written to ``~/.lab///``; ``LAB_HOME`` changes + that root, ``to`` selects a directory, and ``to=None`` skips writing. """ + if not isinstance(protocol, Protocol): + raise TypeError("Compile a Protocol; use an experiment builder to turn designs into one.") if isinstance(target, Deck): raise TypeError("Pass a deck with deck=.") if deck is not None and target is not None: @@ -217,27 +159,7 @@ def compile( hardware: Target | Deck = ( deck if deck is not None else target if target is not None else Manual() ) - if inputs is not None and not isinstance( - protocol, (Transformation, TransformationRequest, PlatingRequest) - ): - raise TypeError("Pass inputs with a transformation or plating request.") - work: Protocol - if isinstance(protocol, Assembly): - work = build_assembly(AssemblyRequest(id=protocol.id, assemblies=(protocol,))) - elif isinstance(protocol, AssemblyRequest): - work = build_assembly(protocol) - elif isinstance(protocol, Transformation): - work = build_transformation( - TransformationRequest(id=protocol.id, transformations=(protocol,)), - inputs=inputs, - ) - elif isinstance(protocol, TransformationRequest): - work = build_transformation(protocol, inputs=inputs) - elif isinstance(protocol, PlatingRequest): - work = build_plating(protocol, inputs=inputs) - else: - work = protocol - recorded = work.snapshot() + recorded = protocol.snapshot() authored_deck = hardware if isinstance(hardware, Deck) else None if isinstance(hardware, Deck): if not isinstance(liquid_handler, LiquidHandler): diff --git a/src/lab/experiments/cloning/__init__.py b/src/lab/experiments/cloning/__init__.py index 8a350c5..3ba21bc 100644 --- a/src/lab/experiments/cloning/__init__.py +++ b/src/lab/experiments/cloning/__init__.py @@ -24,10 +24,7 @@ from lab.experiments.cloning.types import ( BSAI, Assembly, - AssemblyRequest, - PlatingRequest, Transformation, - TransformationRequest, ) from lab.experiments.cloning.workflow import golden_gate @@ -35,14 +32,11 @@ "Assembly", "AssemblyLayout", "AssemblyReaction", - "AssemblyRequest", "AssemblyVolumes", "BSAI", - "PlatingRequest", "PlatingVolumes", "Transformation", "TransformationLayout", - "TransformationRequest", "assembly_deck", "build_assembly", "build_plating", diff --git a/src/lab/experiments/cloning/stages/assembly.py b/src/lab/experiments/cloning/stages/assembly.py index e8c3145..1b3e168 100644 --- a/src/lab/experiments/cloning/stages/assembly.py +++ b/src/lab/experiments/cloning/stages/assembly.py @@ -14,8 +14,10 @@ from fnmatch import fnmatch from itertools import product +from sbol3 import Component + from lab.experiments.cloning.addresses import microliters, uri_name, well_at -from lab.experiments.cloning.types import AssemblyRequest +from lab.experiments.cloning.types import Assembly from lab.protocol import Plate, Protocol, Well from lab.samples import Sample from lab.units import celsius, minutes, uL @@ -140,41 +142,47 @@ def record_assembly( def build_assembly( - assemblies: AssemblyRequest | Sequence[Mapping[str, object]], + assemblies: Sequence[Assembly], *, name: str = "Loop assembly", **params: object, ) -> Protocol: """A standalone assembly protocol whose plates are named ``reagents`` and ``products``.""" - if isinstance(assemblies, AssemblyRequest): - name = assemblies.id - assemblies = [ - { - "Product": assembly.product.iri, - "Backbone": assembly.backbone.iri, - "PartsList": [part.iri for part in assembly.parts], - "Restriction Enzyme": assembly.restriction_enzyme.iri, - } - for assembly in assemblies.assemblies - ] - layout = layout_assembly(assemblies, **params) # type: ignore[arg-type] + designs: dict[str, Component] = {} + for assembly in assemblies: + designs[assembly.product.identity] = assembly.product + enzyme = assembly.restriction_enzyme + designs[f"Restriction Enzyme {uri_name(enzyme.identity)}"] = enzyme + for part in (assembly.backbone, *assembly.parts): + label = uri_name(part.identity) + if label in designs and designs[label].identity != part.identity: + raise ValueError(f"Two part IRIs extract to the same name {label!r}.") + designs[label] = part + layout = layout_assembly([assembly._inputs() for assembly in assemblies], **params) # type: ignore[arg-type] protocol = Protocol(name, description="Golden Gate assembly on a thermocycler plate.") reagents = protocol.plate("reagents", shape=(4, 6), capacity=1500 * uL, dead_volume=0 * uL) products = protocol.plate("products", shape=(8, 12), capacity=100 * uL, dead_volume=0 * uL) stock_ids: dict[str, str] = {} for index, material, volume in layout.stocks: protocol.load(well_at(reagents, index), material, volume=volume * uL) + design = designs.get(material) sample = Sample( - id=f"stock-{index}", material_identity=material, label=material, role="stock" + id=f"stock-{index}", + material_identity=design.identity if design is not None else material, + label=(design.name if design is not None else None) or material, + role="stock", + design=design.identity if design is not None else None, ) protocol.add_sample(sample, at=well_at(reagents, index), is_input=True) stock_ids[material] = sample.id for reaction in layout.reactions: + design = designs[reaction.product_key] protocol.add_sample( Sample( id=f"product-{reaction.destination}", material_identity=reaction.product_key, - label=uri_name(reaction.product_key), + design=design.identity, + label=design.name or uri_name(reaction.product_key), parent_ids=tuple( dict.fromkeys(stock_ids[material] for material, _, _ in reaction.additions) ), diff --git a/src/lab/experiments/cloning/stages/plating.py b/src/lab/experiments/cloning/stages/plating.py index b5c2b89..f16f140 100644 --- a/src/lab/experiments/cloning/stages/plating.py +++ b/src/lab/experiments/cloning/stages/plating.py @@ -7,12 +7,11 @@ this record keeps the same well and volume. """ -from collections.abc import Mapping, Sequence +from collections.abc import Sequence from dataclasses import dataclass, replace from decimal import Decimal from lab.experiments.cloning.addresses import microliters, well_at -from lab.experiments.cloning.types import PlatingRequest from lab.protocol import Plate, Protocol, Well from lab.samples import OutputManifest, Sample from lab.units import celsius, uL @@ -102,9 +101,8 @@ def record_plating( def build_plating( - bacterium_locations: PlatingRequest | Mapping[str, object], + inputs: OutputManifest, *, - inputs: OutputManifest | None = None, name: str = "Plating", volume_total_reaction: object = 20 * uL, volume_lb: object = 10000 * uL, @@ -115,28 +113,10 @@ def build_plating( number_dilutions: int = 2, max_colonies: int = 192, ) -> Protocol: - """Standalone plating protocol. Source wells are the mapping's keys, in order.""" - if isinstance(bacterium_locations, PlatingRequest): - request = bacterium_locations - if inputs is None: - raise ValueError("Plating requires a source manifest.") - if request.source_stage_id is not None and inputs.protocol_id != request.source_stage_id: - raise ValueError("The source stage id must match the input manifest.") - if set(request.sample_ids) != {sample.id for sample in inputs.samples}: - raise ValueError("Plating request samples must match the source manifest.") - name = request.id - samples = {sample.id: sample for sample in inputs.samples} - placements = {placement.sample_id: placement for placement in inputs.placements} - inputs = OutputManifest( - protocol_id=inputs.protocol_id, - samples=tuple(samples[sample_id] for sample_id in request.sample_ids), - placements=tuple(placements[sample_id] for sample_id in request.sample_ids), - ) - bacterium_locations = _bacterium_locations(inputs) - elif inputs is not None: - raise ValueError("Pass a PlatingRequest when supplying an input manifest.") + """Plate the source manifest's samples in their declared order.""" + bacterium_locations = _bacterium_locations(inputs) if not bacterium_locations: - raise ValueError("bacterium_locations must be a non-empty dictionary") + raise ValueError("Plating requires a nonempty source manifest") _volumes( volume_bacteria_transfer, volume_colony, @@ -186,22 +166,15 @@ def build_plating( number_dilutions=number_dilutions, ) source_samples: list[Sample] = [] - for index, (well, label) in enumerate(bacterium_locations.items()): - sample = Sample( + for index, well in enumerate(bacterium_locations): + source = inputs.samples[index] + sample = replace( + source, id=f"source-{index}", - material_identity=_label(label), - label=_label(label), - role="source", + parent_ids=(), + source_sample_id=source.id, + source_protocol_id=inputs.protocol_id, ) - if inputs is not None: - source = inputs.samples[index] - sample = replace( - source, - id=sample.id, - parent_ids=(), - source_sample_id=source.id, - source_protocol_id=inputs.protocol_id, - ) protocol.add_sample(sample, at=sources[str(well)], is_input=True) source_samples.append(sample) broth = Sample(id="broth", material_identity="liquid_broth", label="liquid_broth", role="broth") @@ -240,6 +213,7 @@ def build_plating( Sample( id=f"colony-{dilution_index}-{index}-{replicate}", material_identity=source.material_identity, + design=source.design, label=source.label, parent_ids=(dilution.id,), role="colony", diff --git a/src/lab/experiments/cloning/stages/transformation.py b/src/lab/experiments/cloning/stages/transformation.py index 0f10750..d6c8147 100644 --- a/src/lab/experiments/cloning/stages/transformation.py +++ b/src/lab/experiments/cloning/stages/transformation.py @@ -12,8 +12,10 @@ from decimal import Decimal from itertools import groupby +from sbol3 import Component + from lab.experiments.cloning.addresses import microliters, uri_name, well_at, well_name -from lab.experiments.cloning.types import TransformationRequest +from lab.experiments.cloning.types import Transformation from lab.protocol import Plate, Protocol from lab.samples import OutputManifest, Sample from lab.units import celsius, minutes, uL @@ -273,7 +275,7 @@ def record_transformation( def build_transformation( - transformation_data: TransformationRequest | Sequence[Mapping[str, object]], + transformations: Sequence[Transformation], plasmid_locations: Mapping[str, Sequence[str]] | None = None, *, inputs: OutputManifest | None = None, @@ -282,26 +284,19 @@ def build_transformation( **params: object, ) -> Protocol: """Standalone transformation. Set ``load_dna`` false when the DNA plate is already filled.""" + designs: dict[str, Component] = {} if inputs is not None: if plasmid_locations is not None: raise ValueError("Pass either an input manifest or plasmid_locations.") plasmid_locations = _plasmid_locations(inputs) - if isinstance(transformation_data, TransformationRequest): - if transformation_data.source_stage_id is not None and ( - inputs is None or inputs.protocol_id != transformation_data.source_stage_id - ): - raise ValueError("The source stage id must match the input manifest.") - name = transformation_data.id - transformation_data = [ - { - "Strain": transformation.strain.iri, - "Chassis": transformation.chassis.iri, - "Plasmids": [part.iri for part in transformation.plasmids], - } - for transformation in transformation_data.transformations - ] + for transformation in transformations: + for part in (transformation.strain, transformation.chassis, *transformation.plasmids): + label = uri_name(part.identity) + if label in designs and designs[label].identity != part.identity: + raise ValueError(f"Two part IRIs extract to the same name {label!r}.") + designs[label] = part layout = layout_transformation( - transformation_data, + [transformation._inputs() for transformation in transformations], plasmid_locations, **params, # type: ignore[arg-type] ) @@ -317,7 +312,7 @@ def build_transformation( protocol.load(dna[well], material, volume=volume * uL) for index, material, volume in (*layout.cell_stocks, *layout.media_stocks): protocol.load(well_at(tubes, index), material, volume=volume * uL) - _declare_samples(protocol, layout, dna, tubes, products, inputs) + _declare_samples(protocol, layout, dna, tubes, products, inputs, designs) record_transformation(protocol, layout, dna, tubes, products) return protocol @@ -339,6 +334,7 @@ def _declare_samples( tubes: Plate, products: Plate, inputs: OutputManifest | None, + designs: dict[str, Component], ) -> None: upstream = {} if inputs is None else {sample.id: sample for sample in inputs.samples} by_well = ( @@ -353,7 +349,14 @@ def _declare_samples( for well, material, _volume in layout.dna_stocks: if well in dna_ids: continue - sample = Sample(id=f"dna-{well}", material_identity=material, label=material, role="dna") + design = designs[material] + sample = Sample( + id=f"dna-{well}", + material_identity=design.identity, + label=design.name or material, + role="dna", + design=design.identity, + ) if inputs is not None: source = by_well[well] sample = replace( @@ -368,17 +371,27 @@ def _declare_samples( dna_ids[well] = sample.id for role, stocks in (("cells", layout.cell_stocks), ("media", layout.media_stocks)): for index, material, _volume in stocks: + chassis = ( + next((cell.chassis for cell in layout.cell_moves if cell.tube_index == index), None) + if role == "cells" + else None + ) + design = designs.get(chassis) if chassis is not None else None protocol.add_sample( Sample( id=f"{role}-{index}", - material_identity=f"{role}:{material}", - label=material, + material_identity=design.identity + if design is not None + else f"{role}:{material}", + design=design.identity if design is not None else None, + label=(design.name if design is not None else None) or material, role=role, ), at=well_at(tubes, index), is_input=True, ) for cell in layout.cell_moves: + design = designs[cell.strain] parents = [f"cells-{cell.tube_index}"] parents.extend( dna_ids[move.source_well] @@ -393,8 +406,9 @@ def _declare_samples( protocol.add_sample( Sample( id=f"reaction-{cell.destination}", - material_identity=cell.strain, - label=cell.strain, + material_identity=design.identity, + design=design.identity, + label=design.name or cell.strain, parent_ids=tuple(dict.fromkeys(parents)), role="reaction", contents=layout.contents[well_name(cell.destination)], diff --git a/src/lab/experiments/cloning/types.py b/src/lab/experiments/cloning/types.py index 9e97014..5350c30 100644 --- a/src/lab/experiments/cloning/types.py +++ b/src/lab/experiments/cloning/types.py @@ -3,98 +3,63 @@ from collections.abc import Sequence from dataclasses import dataclass -from lab.part import Part +from sbol3 import SBO_PROTEIN, Component -def _part(value: object, *, name: str) -> None: - if not isinstance(value, Part): - raise TypeError(f"{name} must be a Part.") +def _component(value: object, *, name: str) -> None: + if not isinstance(value, Component): + raise TypeError(f"{name} must be an SBOL Component.") -def _parts(values: object, *, name: str) -> tuple[Part, ...]: - if isinstance(values, (str, Part)) or not isinstance(values, Sequence): - raise TypeError(f"{name} must be a sequence of parts.") +def _components(values: object, *, name: str) -> tuple[Component, ...]: + if isinstance(values, (str, Component)) or not isinstance(values, Sequence): + raise TypeError(f"{name} must be a sequence of SBOL Components.") if not values: - raise ValueError(f"{name} must be a nonempty sequence of parts.") - if not all(isinstance(value, Part) for value in values): - raise TypeError(f"{name} must be parts.") + raise ValueError(f"{name} must be a nonempty sequence of SBOL Components.") + if not all(isinstance(value, Component) for value in values): + raise TypeError(f"{name} must be SBOL Components.") return tuple(values) -BSAI = Part("https://SBOL2Build.org/BsaI/1") +BSAI = Component("https://SBOL2Build.org/BsaI", SBO_PROTEIN) @dataclass(frozen=True, slots=True, kw_only=True) class Assembly: - id: str - product: Part - backbone: Part - parts: Sequence[Part] - restriction_enzyme: Part + product: Component + backbone: Component + parts: Sequence[Component] + restriction_enzyme: Component def __post_init__(self) -> None: - if not self.id: - raise ValueError("An assembly requires an id.") - _part(self.product, name="Product") - _part(self.backbone, name="Backbone") - _part(self.restriction_enzyme, name="Restriction enzyme") - object.__setattr__(self, "parts", _parts(self.parts, name="Parts")) + _component(self.product, name="Product") + _component(self.backbone, name="Backbone") + _component(self.restriction_enzyme, name="Restriction enzyme") + object.__setattr__(self, "parts", _components(self.parts, name="Parts")) - -@dataclass(frozen=True, slots=True, kw_only=True) -class AssemblyRequest: - id: str - assemblies: tuple[Assembly, ...] - - def __post_init__(self) -> None: - if not self.id or not self.assemblies: - raise ValueError("An assembly request requires an id and assemblies.") - if not isinstance(self.assemblies, tuple): - raise TypeError("AssemblyRequest.assemblies must be a tuple.") - if len({assembly.id for assembly in self.assemblies}) != len(self.assemblies): - raise ValueError("Assembly ids must be unique within a request.") + def _inputs(self) -> dict[str, object]: + return { + "Product": self.product.identity, + "Backbone": self.backbone.identity, + "PartsList": [part.identity for part in self.parts], + "Restriction Enzyme": self.restriction_enzyme.identity, + } @dataclass(frozen=True, slots=True, kw_only=True) class Transformation: - id: str - strain: Part - chassis: Part - plasmids: Sequence[Part] - - def __post_init__(self) -> None: - if not self.id: - raise ValueError("A transformation requires an id.") - _part(self.strain, name="Strain") - _part(self.chassis, name="Chassis") - object.__setattr__(self, "plasmids", _parts(self.plasmids, name="Plasmids")) - - -@dataclass(frozen=True, slots=True, kw_only=True) -class TransformationRequest: - id: str - transformations: tuple[Transformation, ...] - source_stage_id: str | None = None - - def __post_init__(self) -> None: - if not self.id or not isinstance(self.transformations, tuple) or not self.transformations: - raise ValueError( - "A transformation request requires an id and a nonempty transformation tuple." - ) - if len({transformation.id for transformation in self.transformations}) != len( - self.transformations - ): - raise ValueError("Transformation ids must be unique within a request.") - - -@dataclass(frozen=True, slots=True, kw_only=True) -class PlatingRequest: - id: str - sample_ids: tuple[str, ...] - source_stage_id: str | None = None + strain: Component + chassis: Component + plasmids: Sequence[Component] def __post_init__(self) -> None: - if not self.id or not isinstance(self.sample_ids, tuple) or not self.sample_ids: - raise ValueError("A plating request requires an id and a nonempty sample tuple.") - if len(set(self.sample_ids)) != len(self.sample_ids): - raise ValueError("Plating source sample ids must be unique.") + _component(self.strain, name="Strain") + _component(self.chassis, name="Chassis") + object.__setattr__(self, "plasmids", _components(self.plasmids, name="Plasmids")) + + def _inputs(self) -> dict[str, object]: + return { + "Strain": self.strain.identity, + "Chassis": self.chassis.identity, + "Plasmids": [part.identity for part in self.plasmids], + } diff --git a/src/lab/experiments/cloning/workflow.py b/src/lab/experiments/cloning/workflow.py index b1bf975..42152e5 100644 --- a/src/lab/experiments/cloning/workflow.py +++ b/src/lab/experiments/cloning/workflow.py @@ -4,7 +4,7 @@ pass the output manifest to the next stage. """ -from collections.abc import Mapping, Sequence +from collections.abc import Sequence from lab.experiments.cloning.addresses import well_at from lab.experiments.cloning.stages.assembly import layout_assembly, record_assembly @@ -13,13 +13,14 @@ layout_transformation, record_transformation, ) +from lab.experiments.cloning.types import Assembly, Transformation from lab.protocol import Protocol from lab.units import uL def golden_gate( - assemblies: Sequence[Mapping[str, object]], - strains: Sequence[Mapping[str, object]], + assemblies: Sequence[Assembly], + strains: Sequence[Transformation], ) -> Protocol: """Assemble, transform, and plate caller-supplied designs. Compile as manual.""" protocol = Protocol( @@ -44,12 +45,12 @@ def golden_gate( capacity=15000 * uL, dead_volume=0 * uL, ) - assembly = layout_assembly(assemblies) + assembly = layout_assembly([assembly._inputs() for assembly in assemblies]) locations = { key: [well_at(assembly_plate, index).name for index in indexes] for key, indexes in assembly.products.items() } - transformation = layout_transformation(list(strains), locations) + transformation = layout_transformation([strain._inputs() for strain in strains], locations) for index, material, volume in assembly.stocks: protocol.load(well_at(reagents, index), material, volume=volume * uL) for index, material, volume in (*transformation.cell_stocks, *transformation.media_stocks): diff --git a/src/lab/part.py b/src/lab/part.py deleted file mode 100644 index 8249f8b..0000000 --- a/src/lab/part.py +++ /dev/null @@ -1,20 +0,0 @@ -"""An SBOL part identity.""" - -from dataclasses import dataclass -from urllib.parse import urlsplit - - -@dataclass(frozen=True, slots=True) -class Part: - """An SBOL part identity.""" - - iri: str - - def __post_init__(self) -> None: - parsed = urlsplit(self.iri) - if ( - parsed.scheme not in {"http", "https"} - or not parsed.netloc - or not parsed.path.strip("/") - ): - raise ValueError(f"Part IRI must be an absolute http(s) URI, got {self.iri!r}.") diff --git a/src/lab/samples.py b/src/lab/samples.py index 04c8a76..5fa2626 100644 --- a/src/lab/samples.py +++ b/src/lab/samples.py @@ -2,6 +2,8 @@ from dataclasses import dataclass +from lab._identifiers import require_iri + @dataclass(frozen=True) class Location: @@ -26,8 +28,16 @@ class Sample: source_protocol_id: str | None = None contents: tuple[str, ...] = () dilution: int | None = None + design: str | None = None + implementation: str | None = None def __post_init__(self) -> None: + if self.design is not None: + require_iri(self.design) + if self.material_identity != self.design: + raise ValueError("Sample material identity must match its design reference") + if self.implementation is not None: + require_iri(self.implementation) if not all( isinstance(value, str) and value.strip() for value in (self.id, self.material_identity, self.label, self.role) @@ -91,6 +101,8 @@ def to_dict(self) -> dict[str, object]: { "sample_id": sample.id, "material_identity": sample.material_identity, + "design": sample.design, + "implementation": sample.implementation, "label": sample.label, "parent_sample_ids": list(sample.parent_ids), "replicate": sample.replicate, diff --git a/tests/test_ot2_equivalence.py b/tests/test_ot2_equivalence.py index 0a4c831..0a36f9a 100644 --- a/tests/test_ot2_equivalence.py +++ b/tests/test_ot2_equivalence.py @@ -9,6 +9,7 @@ from pathlib import Path import pytest +import sbol3 import lab from examples.transformation import ( @@ -23,14 +24,19 @@ TRANSFORMATIONS, ) from lab.experiments.cloning import ( - TransformationRequest, + Assembly, + Transformation, assembly_deck, build_assembly, build_plating, build_transformation, + layout_assembly, plating_deck, + record_assembly, transformation_deck, ) +from lab.experiments.cloning.addresses import well_at +from lab.samples import Location, OutputManifest, Sample, SamplePlacement from lab.targets import LiquidHandler from lab.units import magnitude @@ -159,7 +165,18 @@ def test_sbol_assembly_matches_pudu_transfers_profiles_and_wells(tmp_path): "Restriction Enzyme": "https://SBOL2Build.org/BsaI/1", } ] - protocol = build_assembly(assemblies, name="SBOL loop assembly") + entry = assemblies[0] + recipe = Assembly( + product=sbol3.Component(entry["Product"].removesuffix("/1"), sbol3.SBO_DNA), + backbone=sbol3.Component(entry["Backbone"].removesuffix("/1"), sbol3.SBO_DNA), + parts=[ + sbol3.Component(uri.removesuffix("/1"), sbol3.SBO_DNA) for uri in entry["PartsList"] + ], + restriction_enzyme=sbol3.Component( + entry["Restriction Enzyme"].removesuffix("/1"), sbol3.SBO_PROTEIN + ), + ) + protocol = build_assembly((recipe,), name="SBOL loop assembly") ours = _log(_lab_source(protocol, assembly_deck()), tmp_path) source = _protocol( "assemblies = " @@ -175,7 +192,9 @@ def run(protocol: protocol_api.ProtocolContext): pudu = _log(source, tmp_path) assert _transfers(ours) == _transfers(pudu) assert _profiles(ours) == _profiles(pudu) - _assert_product_locations(protocol, _handoff(pudu)) + _assert_product_locations( + protocol, {uri.removesuffix("/1"): wells for uri, wells in _handoff(pudu).items()} + ) @pytest.mark.integration @@ -202,7 +221,13 @@ def run(protocol: protocol_api.ProtocolContext): ], ) def test_other_assembly_formats_match_pudu(tmp_path, assemblies, factory): - protocol = build_assembly(assemblies, name=factory) + layout = layout_assembly(assemblies) + protocol = lab.Protocol(factory) + reagents = protocol.plate("reagents", shape=(4, 6), capacity=1500 * lab.uL) + products = protocol.plate("products", capacity=100 * lab.uL) + for index, material, volume in layout.stocks: + protocol.load(well_at(reagents, index), material, volume=volume * lab.uL) + outputs = record_assembly(protocol, layout, reagents, products) ours = _log(_lab_source(protocol, assembly_deck()), tmp_path) source = _protocol( "assemblies = " @@ -218,7 +243,7 @@ def run(protocol: protocol_api.ProtocolContext): pudu = _log(source, tmp_path) assert _transfers(ours) == _transfers(pudu) assert _profiles(ours) == _profiles(pudu) - _assert_product_locations(protocol, _handoff(pudu)) + assert {key: [well.name for well in wells] for key, wells in outputs.items()} == _handoff(pudu) @pytest.mark.integration @@ -232,7 +257,17 @@ def test_heat_shock_matches_pudu_transfers_and_well_labels(tmp_path): } ] locations = {"https://SBOL2Build.org/composite_plasmid_1/1": ["A1"]} - protocol = build_transformation(strains, locations, name="Heat-shock transformation") + entry = strains[0] + recipe = Transformation( + strain=sbol3.Component(entry["Strain"].removesuffix("/1"), sbol3.SBO_FUNCTIONAL_ENTITY), + chassis=sbol3.Component(entry["Chassis"].removesuffix("/1"), sbol3.SBO_FUNCTIONAL_ENTITY), + plasmids=[ + sbol3.Component(uri.removesuffix("/1"), sbol3.SBO_DNA) for uri in entry["Plasmids"] + ], + ) + protocol = build_transformation( + (recipe,), {uri.removesuffix("/1"): wells for uri, wells in locations.items()} + ) ours = _log(_lab_source(protocol, transformation_deck()), tmp_path) source = _protocol( "strains = " @@ -262,14 +297,20 @@ def run(protocol: protocol_api.ProtocolContext): @requires_robots def test_triplicate_transformation_matches_pudu_transfers_and_well_labels(tmp_path): """Two DNAs, three replicates, 20 µL of cells from one 1 mL aliquot.""" + # PUDU names materials from SBOL2 versioned identities. strains = [ - {"Strain": STRAIN_1.iri, "Chassis": DH5ALPHA.iri, "Plasmids": [DNA_1.iri]}, - {"Strain": STRAIN_2.iri, "Chassis": DH5ALPHA.iri, "Plasmids": [DNA_2.iri]}, + { + "Strain": strain.identity + "/1", + "Chassis": DH5ALPHA.identity + "/1", + "Plasmids": [dna.identity + "/1"], + } + for strain, dna in ((STRAIN_1, DNA_1), (STRAIN_2, DNA_2)) ] # Recovery media is grouped by the 300 µL tip rack this OT-2 protocol loads. tiprack = "opentrons_96_tiprack_300ul" protocol = build_transformation( - TransformationRequest(id="transformation", transformations=TRANSFORMATIONS), + TRANSFORMATIONS, + name="transformation", replicates=REPLICATES, transfer_volume_competent_cell=CELLS_PER_REACTION, tube_volume_competent_cell=CELL_ALIQUOT, @@ -305,7 +346,22 @@ def run(protocol: protocol_api.ProtocolContext): @requires_robots def test_plating_matches_pudu_transfers(tmp_path): bacteria = {"A1": ["composite_strain_1", "Competent_Cell_DH5alpha"], "B1": "composite_strain_1"} - protocol = build_plating(bacteria, name="Plating", replicates=1, number_dilutions=2) + inputs = OutputManifest( + protocol_id="source", + samples=tuple( + Sample( + id=well, + material_identity=well, + label=well, + contents=tuple(value) if isinstance(value, list) else (value,), + ) + for well, value in bacteria.items() + ), + placements=tuple( + SamplePlacement(sample_id=well, location=Location("source", well)) for well in bacteria + ), + ) + protocol = build_plating(inputs, name="Plating", replicates=1, number_dilutions=2) ours = _log(_lab_source(protocol, plating_deck()), tmp_path) source = _protocol( "bacteria = " diff --git a/tests/test_sbol_annotations.py b/tests/test_sbol_annotations.py new file mode 100644 index 0000000..0cf294f --- /dev/null +++ b/tests/test_sbol_annotations.py @@ -0,0 +1,107 @@ +from dataclasses import replace + +import pytest +import sbol3 + +import lab +from examples.cloning import ASSEMBLIES, STRAINS +from examples.cloning import designs as cloning_designs +from examples.sbol_provenance import inputs +from examples.transformation import designs as transformation_designs +from lab.experiments.cloning import ( + Assembly, + Transformation, + build_assembly, + build_plating, + build_transformation, +) +from lab.samples import Sample + + +def test_native_sbol_provenance_and_compiled_annotations(tmp_path): + designs, protocol = inputs() + assert not designs.validate().errors + design = designs.find("https://example.org/aliquot/design") + material = sbol3.Implementation("https://example.org/aliquot/stock", built=design) + designs.add(material) + sample = replace(protocol.snapshot().samples[0], implementation=material.identity) + assert sample.implementation == material.identity + result = lab.compile(protocol, to=None) + output = result.manifest.to_dict()["outputs"][0] + assert output["design"] == design.identity + assert output["implementation"] is None + assert output["parent_sample_ids"] == ["source"] + + designs.write(str(tmp_path / "designs.ttl"), sbol3.TURTLE) + restored = sbol3.Document() + restored.read(str(tmp_path / "designs.ttl")) + assert not restored.validate().errors + assert list(restored.find(design.identity).generated_by) == list(design.generated_by) + activity = restored.find(design.generated_by[0]) + assert activity.usage[0].entity == design.sequences[0] + assert activity.association[0].agent == "https://example.org/aliquot/author" + assert restored.find(sample.implementation).built == design.identity + + plan = result.plan_json + design.name = "Edited design metadata" + material.built = "https://example.org/another_design" + protocol.wait(1 * lab.seconds) + assert result.plan_json == plan + assert result.manifest.samples[0].design == design.identity + + +def test_cloning_uses_imported_sbol_designs_across_stage_manifests(tmp_path): + cloning_designs.write(str(tmp_path / "designs.ttl"), sbol3.TURTLE) + designs = sbol3.Document() + designs.read(str(tmp_path / "designs.ttl")) + assert not designs.validate().errors + original = ASSEMBLIES[0] + recipe = Assembly( + product=designs.find(original.product.identity), + backbone=designs.find(original.backbone.identity), + parts=[designs.find(part.identity) for part in original.parts], + restriction_enzyme=designs.find(original.restriction_enzyme.identity), + ) + recipe.product.name = "Imported SBOL product name" + protocol = build_assembly((recipe,)) + recipe.product.name = "Edited after building the protocol" + assembly = lab.compile(protocol, to=None) + assert assembly.manifest.samples[0].label == "Imported SBOL product name" + assert assembly.manifest.samples[0].design == ASSEMBLIES[0].product.identity + assert any(s.design == ASSEMBLIES[0].backbone.identity for s in assembly.protocol.samples) + strain = STRAINS[0] + transformation_recipe = Transformation( + strain=designs.find(strain.strain.identity), + chassis=designs.find(strain.chassis.identity), + plasmids=[recipe.product], + ) + protocol = build_transformation((transformation_recipe,), inputs=assembly.manifest) + transformation = lab.compile(protocol, to=None) + assert all(s.design == STRAINS[0].strain.identity for s in transformation.manifest.samples) + imported = [s for s in transformation.protocol.samples if s.source_sample_id] + assert imported[0].design == ASSEMBLIES[0].product.identity + plated = lab.compile( + build_plating(transformation.manifest), + to=None, + ) + assert all(s.design == STRAINS[0].strain.identity for s in plated.manifest.samples) + assert all(s.implementation is None for s in plated.manifest.samples) + for result in (assembly, transformation, plated): + assert all(designs.find(s.design) is not None for s in result.manifest.samples) + + +@pytest.mark.parametrize("designs", [cloning_designs, transformation_designs]) +def test_experiment_examples_use_valid_native_sbol_designs(designs): + assert not designs.validate().errors + + +@pytest.mark.parametrize("field", ["design", "implementation"]) +@pytest.mark.parametrize("identity", ["relative", "https://", "https://example.org/has space", 42]) +def test_sample_annotations_require_absolute_identity_strings(field, identity): + with pytest.raises(ValueError, match="absolute IRI"): + Sample(id="s", label="DNA", material_identity="DNA", **{field: identity}) + + +def test_sample_design_must_match_material_identity(): + with pytest.raises(ValueError, match="match its design"): + Sample(id="s", label="DNA", material_identity="DNA", design="https://example.org/design") diff --git a/tests/test_stages.py b/tests/test_stages.py index 38bba47..593f22b 100644 --- a/tests/test_stages.py +++ b/tests/test_stages.py @@ -3,6 +3,7 @@ from decimal import Decimal import pytest +import sbol3 import lab from examples.cloning import ASSEMBLIES as CLONING_ASSEMBLIES @@ -10,12 +11,11 @@ from lab.experiments.cloning import ( BSAI, Assembly, - AssemblyRequest, - PlatingRequest, Transformation, - TransformationRequest, assembly_deck, build_assembly, + build_plating, + build_transformation, golden_gate, layout_assembly, layout_transformation, @@ -25,7 +25,6 @@ ) from lab.experiments.cloning.addresses import well_name from lab.model import Mix, Transfer -from lab.part import Part from lab.samples import Location from lab.targets import Labware, LiquidHandler, Manual from lab.targets.lower import lower_deck @@ -89,7 +88,7 @@ def test_plating_stays_on_one_plate_until_a_half_is_full(): def test_compiled_requests_link_stages_through_the_snapshot(): assembled = lab.compile( - AssemblyRequest(id="sbol-loop-assembly", assemblies=CLONING_ASSEMBLIES), + build_assembly(CLONING_ASSEMBLIES, name="sbol-loop-assembly"), Manual(), ) outputs = assembled.manifest @@ -97,22 +96,16 @@ def test_compiled_requests_link_stages_through_the_snapshot(): assert [ locations[sample.id] for sample in outputs.samples - if sample.material_identity == "https://SBOL2Build.org/composite_plasmid_1/1" + if sample.material_identity == CLONING_ASSEMBLIES[0].product.identity ] == [Location("products", "A1")] assert any(isinstance(step, Transfer) for step in assembled.protocol.steps) transformed = lab.compile( - TransformationRequest(id="heat-shock", transformations=CLONING_STRAINS), + build_transformation(CLONING_STRAINS, inputs=assembled.manifest, name="heat-shock"), Manual(), - inputs=assembled.manifest, ) plated = lab.compile( - PlatingRequest( - id="plating", - sample_ids=tuple(sample.id for sample in transformed.manifest.samples), - source_stage_id=transformed.manifest.protocol_id, - ), + build_plating(transformed.manifest), Manual(), - inputs=transformed.manifest, ) assert plated.manifest.samples assert {type(step) for step in plated.protocol.steps} >= {Transfer, Mix} @@ -130,85 +123,72 @@ def test_compiled_requests_link_stages_through_the_snapshot(): def test_transformation_uses_caller_defined_materials(): transformation = Transformation( - id="custom-transformation", - strain=Part("https://example.org/custom-strain/1"), - chassis=Part("https://example.org/custom-cells/1"), - plasmids=[Part("https://example.org/custom-plasmid/1")], + strain=sbol3.Component("https://example.org/custom_strain", sbol3.SBO_DNA), + chassis=sbol3.Component("https://example.org/custom_cells", sbol3.SBO_DNA), + plasmids=[sbol3.Component("https://example.org/custom_plasmid", sbol3.SBO_DNA)], ) - compiled = lab.compile(transformation, Manual()) - assert compiled.protocol.name == transformation.id - assert compiled.manifest.protocol_id == transformation.id - assert {sample.material_identity for sample in compiled.manifest.samples} == {"custom-strain"} + compiled = lab.compile( + build_transformation((transformation,), name="custom-transformation"), Manual() + ) + assert compiled.protocol.name == "custom-transformation" + assert compiled.manifest.protocol_id == "custom-transformation" + assert {sample.material_identity for sample in compiled.manifest.samples} == { + transformation.strain.identity + } assert { sample.material_identity for sample in compiled.protocol.samples if sample.role == "dna" - } == {"custom-plasmid"} + } == {transformation.plasmids[0].identity} assert all( - sample.contents[:3] == ("custom-strain", "Competent_Cell_custom-cells", "custom-plasmid") + sample.contents[:3] == ("custom_strain", "Competent_Cell_custom_cells", "custom_plasmid") for sample in compiled.manifest.samples ) def test_assembly_accepts_unversioned_part_iris(): - product = Part("https://example.org/design/product") - insert = Part("https://example.org/parts/reporter") + product = sbol3.Component("https://example.org/design/product", sbol3.SBO_DNA) + insert = sbol3.Component("https://example.org/parts/reporter", sbol3.SBO_DNA) assembly = Assembly( - id="example-assembly", product=product, - backbone=Part("https://example.org/parts/backbone"), + backbone=sbol3.Component("https://example.org/parts/backbone", sbol3.SBO_DNA), parts=[insert], restriction_enzyme=BSAI, ) assert assembly.parts == (insert,) - compiled = lab.compile(assembly, Manual()) - assert compiled.protocol.name == assembly.id + compiled = lab.compile(build_assembly((assembly,), name="example-assembly"), Manual()) + assert compiled.protocol.name == "example-assembly" outputs = compiled.manifest locations = {placement.sample_id: placement.location for placement in outputs.placements} assert [ locations[sample.id] for sample in outputs.samples - if sample.material_identity == product.iri + if sample.material_identity == product.identity ] == [Location("products", "A1")] assert any(sample.label == "Restriction Enzyme BsaI" for sample in compiled.protocol.samples) -def test_one_assembly_matches_its_request_and_rejects_inputs(): - assembly = CLONING_ASSEMBLIES[0] - direct = lab.compile(assembly, Manual()) - built = lab.compile( - build_assembly(AssemblyRequest(id=assembly.id, assemblies=(assembly,))), - Manual(), - ) - assert direct.protocol.name == assembly.id - assert direct.digest == built.digest - with pytest.raises(TypeError, match="transformation or plating"): - lab.compile(assembly, Manual(), inputs=direct.manifest) - with pytest.raises(TypeError, match="transformation or plating"): - lab.compile(direct.protocol, Manual(), inputs=direct.manifest) - - -def test_part_iri_and_part_sequence_are_checked(): - assert Part("https://sbolcanvas.org/GFP/1").iri == "https://sbolcanvas.org/GFP/1" - with pytest.raises(ValueError, match="Part IRI"): - Part("GFP") - with pytest.raises(ValueError, match="Part IRI"): - Part("ATGCTAA") - plasmid = Part("https://SBOL2Build.org/composite_plasmid_1/1") +def test_compiler_accepts_protocols_and_keeps_the_snapshot_internal(): + protocol = build_assembly(CLONING_ASSEMBLIES) + compiled = lab.compile(protocol, Manual()) + assert compiled.protocol == protocol.snapshot() + for value in (CLONING_ASSEMBLIES[0], compiled.protocol): + with pytest.raises(TypeError, match="Compile a Protocol"): + lab.compile(value, Manual()) + with pytest.raises(TypeError, match="inputs"): + lab.compile(protocol, Manual(), inputs=compiled.manifest) + + +def test_cloning_recipes_require_native_sbol_components(): + plasmid = sbol3.Component("https://example.org/plasmid", sbol3.SBO_DNA) with pytest.raises(TypeError, match="Parts"): Assembly( - id="assembly", - product=plasmid, - backbone=plasmid, - parts="https://sbolcanvas.org/GFP/1", - restriction_enzyme=BSAI, + product=plasmid, backbone=plasmid, parts=[plasmid.identity], restriction_enzyme=BSAI ) - strain = Part("https://example.org/custom-strain/1") + with pytest.raises(TypeError, match="Strain"): + Transformation(strain=plasmid.identity, chassis=plasmid, plasmids=[plasmid]) with pytest.raises(TypeError, match="Plasmids"): - Transformation( - id="transformation", - strain=strain, - chassis=strain, - plasmids="https://example.org/custom-plasmid/1", - ) + Transformation(strain=plasmid, chassis=plasmid, plasmids="https://example.org/plasmid") + with pytest.raises(ValueError, match="nonempty"): + Transformation(strain=plasmid, chassis=plasmid, plasmids=[]) @pytest.mark.parametrize("designs", [{}, {"assemblies": ASSEMBLIES}, {"strains": STRAINS}]) @@ -218,7 +198,7 @@ def test_chained_plan_requires_both_design_inputs(designs): def test_compiling_a_deck_requires_a_liquid_handler(): - protocol = golden_gate(ASSEMBLIES, STRAINS) + protocol = golden_gate(CLONING_ASSEMBLIES, CLONING_STRAINS) with pytest.raises(TypeError, match="deck="): lab.compile(protocol, assembly_deck()) with pytest.raises(TypeError, match="liquid_handler"): @@ -292,14 +272,14 @@ def test_ot2_lowering_uses_the_cloning_slots(): def test_compiler_rejects_unsupported_star_preset_equipment(): with pytest.raises(lab.CompileError, match="No STAR preset.*Lab DeckLayout"): lab.compile( - AssemblyRequest(id="sbol-loop-assembly", assemblies=CLONING_ASSEMBLIES), + build_assembly(CLONING_ASSEMBLIES, name="sbol-loop-assembly"), deck=assembly_deck(), liquid_handler=LiquidHandler.STAR, ) def test_chained_plan_compiles_and_conserves_volume(): - bundle = lab.compile(golden_gate(ASSEMBLIES, STRAINS), Manual()) + bundle = lab.compile(golden_gate(CLONING_ASSEMBLIES, CLONING_STRAINS), Manual()) initial = sum(fill.volume for resource in bundle.protocol.resources for fill in resource.fills) assert sum(volume for _location, volume in bundle.final_volumes) == initial transferred = [ diff --git a/uv.lock b/uv.lock index 7772733..50f150c 100644 --- a/uv.lock +++ b/uv.lock @@ -235,6 +235,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/5e/3be305568fe5f34448807976dc82fc151d76c3e0e03958f34770286278c1/flexparser-0.4-py3-none-any.whl", hash = "sha256:3738b456192dcb3e15620f324c447721023c0293f6af9955b481e91d00179846", size = 27625 }, ] +[[package]] +name = "html5lib" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/b6/b55c3f49042f1df3dcd422b7f224f939892ee94f22abcf503a9b7339eaf2/html5lib-1.1.tar.gz", hash = "sha256:b2e5b40261e20f354d198eae92afc10d750afb487ed5e50f9c4eaf07c184146f", size = 272215 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/dd/a834df6482147d48e225a49515aabc28974ad5a4ca3215c18a882565b028/html5lib-1.1-py2.py3-none-any.whl", hash = "sha256:0d78f8fde1c230e99fe37986a60526d7049ed4bf8a9fadbad5f00e22e58e041d", size = 112173 }, +] + [[package]] name = "id" version = "1.6.1" @@ -277,6 +290,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, ] +[[package]] +name = "isodate" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/7a/c0a56c7d56c7fa723988f122fa1f1ccf8c5c4ccc48efad0d214b49e5b1af/isodate-0.6.1.tar.gz", hash = "sha256:48c5881de7e8b0a0d648cb024c8062dc84e7b840ed81e864c7614fd3c127bde9", size = 28443 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/85/7882d311924cbcfc70b1890780763e36ff0b140c7e51c110fc59a532f087/isodate-0.6.1-py2.py3-none-any.whl", hash = "sha256:0751eece944162659049d35f4f549ed815792b38793f07cf73381c1c87cbed96", size = 41722 }, +] + [[package]] name = "jaraco-classes" version = "3.4.0" @@ -359,6 +384,7 @@ source = { editable = "." } dependencies = [ { name = "pint", version = "0.25.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "pint", version = "0.26.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sbol3" }, ] [package.optional-dependencies] @@ -384,6 +410,7 @@ requires-dist = [ { name = "opentrons", marker = "extra == 'opentrons'", specifier = "==8.8.2" }, { name = "pint", specifier = ">=0.24,<0.27" }, { name = "pylabrobot", marker = "extra == 'star'", specifier = "==0.2.1" }, + { name = "sbol3", specifier = "==1.2.0.post0" }, ] provides-extras = ["opentrons", "star"] @@ -588,6 +615,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/40/a6620ced846254d020e350cccdf19cd505628e8dca1b82b93cfcddb242f8/opentrons_shared_data-8.8.2-py3-none-any.whl", hash = "sha256:12d77eab3a41bfb4e5a947f7ca5062d40eb1fcffcf2a6d12a317af7ac249b6f3", size = 1169425 }, ] +[[package]] +name = "owlrl" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rdflib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/c7/208aece36279e4f1236e437119358786e39530ecc1719d4e1afeddba5288/owlrl-6.0.2.tar.gz", hash = "sha256:904e3310ff4df15101475776693d2427d1f8244ee9a6a9f9e13c3c57fae90b74", size = 57179 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/56/11fe63c2c317347f69be17e9ece1991e0ec6c2cdb8225c0baa5b96e283ed/owlrl-6.0.2-py3-none-any.whl", hash = "sha256:57eca06b221edbbc682376c8d42e2ddffc99f61e82c0da02e26735592f08bacc", size = 54516 }, +] + [[package]] name = "packaging" version = "26.3" @@ -660,6 +699,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, ] +[[package]] +name = "prettytable" +version = "3.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/74/ba08d81e668ccfe8658d7520a307e63c19862c08eb4ccb26f356c5239a7a/prettytable-3.18.0.tar.gz", hash = "sha256:439217116152244369caf3d9f1caf2f9fe29b03bd79e88d2928c8e718c95d680", size = 76373 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/be/2e6798ace5cc036f5d05d36b7b2fd85346f1a708c87060890b070d0ec607/prettytable-3.18.0-py3-none-any.whl", hash = "sha256:b3346e0e6f79180833aebaac088ae926340586cf6d7d991b9eb125b65f72313a", size = 37357 }, +] + [[package]] name = "pycparser" version = "3.0" @@ -777,6 +828,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/c0/4a1d8d6d6ec6d4fc1c1f25257c3a71be07b438698fb24eedf7c14bc14313/pylabrobot-0.2.1-py3-none-any.whl", hash = "sha256:8b7b58d1c31a7b19b9d6a11f10ba1303c6222b6a582a8a95f18c6abcf41408cd", size = 1174640 }, ] +[[package]] +name = "pyparsing" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/11/b213bebff182584360cb8d17c72c1677fec5c5c228de439e63bcf8ab1c8f/pyparsing-3.3.3.tar.gz", hash = "sha256:928ae7e20211f3b6f3915a72f06a0cfd29ab9d24279dd6346b6b1a7146397d36", size = 1050487 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl", hash = "sha256:ece8c00a69cf01b45d0b1dedabb469c90d8caf996d4fda40f147627a122849a4", size = 126420 }, +] + [[package]] name = "pyrsistent" version = "0.20.0" @@ -807,6 +867,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585 }, ] +[[package]] +name = "pyshacl" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "owlrl" }, + { name = "packaging" }, + { name = "prettytable" }, + { name = "rdflib", extra = ["html"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/55/370d2bae6eb2d6302caac1eba2c1ad365fbb43c4d4805d00367839b3089c/pyshacl-0.28.1.tar.gz", hash = "sha256:c98e1927541f0cbeb0ba27f3fbe585d638281d6e63f314462dcc32282b3c8353", size = 1399231 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/17/57f5e48a856b70715b86b228ed4211fa0447e38c32eec6fd7c30239d5ea9/pyshacl-0.28.1-py3-none-any.whl", hash = "sha256:345fe96fb2f38218c53a0f977329ed0aa1a54256526bf6de62243ac7ae823d6b", size = 1321044 }, +] + [[package]] name = "pytest" version = "9.1.1" @@ -836,6 +912,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930 }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] + [[package]] name = "python-dotenv" version = "1.2.3" @@ -863,6 +951,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756 }, ] +[[package]] +name = "rdflib" +version = "6.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "isodate" }, + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c8/28/4d1f27c5d73f58e567ca1a14a4eab7d7978a09c4e117687f9f6c216d3366/rdflib-6.3.2.tar.gz", hash = "sha256:72af591ff704f4caacea7ecc0c5a9056b8553e0489dd4f35a9bc52dbd41522e0", size = 4749592 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/92/d7fb1d7fb70c9f7003fa50b7a3880ebcb311cc3f8552b3595e7c8f75aeeb/rdflib-6.3.2-py3-none-any.whl", hash = "sha256:36b4e74a32aa1e4fa7b8719876fb192f19ecd45ff932ea5ebbd2e417a0247e63", size = 528122 }, +] + +[package.optional-dependencies] +html = [ + { name = "html5lib" }, +] + [[package]] name = "readme-renderer" version = "46.0" @@ -951,6 +1057,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316 }, ] +[[package]] +name = "sbol3" +version = "1.2.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyshacl" }, + { name = "python-dateutil" }, + { name = "rdflib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1b/b3/24fc213e588cb2bfc0ec2f99b1ec8366bdbe42ecb096528cb2cacad5eb5e/sbol3-1.2.0.post0.tar.gz", hash = "sha256:e0ddeaf44a29495da043287667275a663629862820acf5456bc71899c940dd8b", size = 86027 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/43/6b787610144ba23da2577549d3be0e74351767c84d514eb34232f473124a/sbol3-1.2.0.post0-py3-none-any.whl", hash = "sha256:742188cacff5829816c61a2f0e82c15357c01821362f66e863530b0fac1b442f", size = 73510 }, +] + [[package]] name = "secretstorage" version = "3.5.0" @@ -964,6 +1084,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554 }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + [[package]] name = "twine" version = "6.2.0" @@ -1014,6 +1143,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717 }, ] +[[package]] +name = "wcwidth" +version = "0.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/ac/3a943d2792c9bb368aaa8b50121c0f778460ba2d7fbdc0a0366201d9e761/wcwidth-0.9.1.tar.gz", hash = "sha256:5823209b0d43af322ce698c689380d7c15ca31fa8e6e3be8459f27031bef0af5", size = 952410 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/7c/130de33c0a7f6efecee28fef5711fc1f8d55ac33885a6b85f4f6689004ba/wcwidth-0.9.1-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:10b00ba23482e352f874d2e8135e7ace9da838646c7dd800566246bbd46125ff", size = 604219 }, + { url = "https://files.pythonhosted.org/packages/33/7c/c7bb03da54de7ac5a08753e186b963f91847b04ed8ae5be136a4d5f52b75/wcwidth-0.9.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:40d936d72c9bdc10df43f93a8be502bc5024b487259139f66a328722c07f34a9", size = 609038 }, + { url = "https://files.pythonhosted.org/packages/0b/71/f57effdf895c2d6d49333f1d6833a9f5438725c55a04ed3124a1c95bb3aa/wcwidth-0.9.1-cp310-abi3-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:69bb970cf5652b88cfdb9d3fdd1764fc15e5f8ad531643e7bb3e894cd969740e", size = 761464 }, + { url = "https://files.pythonhosted.org/packages/4f/16/15b03d8fb7a747d0d2cf4a33da7b1b3e092493c58780f6589d5b1e9e3b49/wcwidth-0.9.1-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b5da43d6967668982e44a52fb551967d293f86d26cd86036ac95bbdd34394ed9", size = 770483 }, + { url = "https://files.pythonhosted.org/packages/e2/a0/834886b30e3b885a5a96e8358f1b3917bc0bac43ae4178d77f0c210d079b/wcwidth-0.9.1-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:747fb724223f417a17541a95a17c1dac3a8ef9a0cf41684950f0eab191a35f65", size = 768443 }, + { url = "https://files.pythonhosted.org/packages/a2/ae/1b7597e0132ebc2c920632f39d8c1c61881345c9b480c0a95eb91e7460fa/wcwidth-0.9.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6e1272b7986cefe79783737e38bdb9eaae0682b333c7bd132024441193dd5ce7", size = 781171 }, + { url = "https://files.pythonhosted.org/packages/fb/bb/32149e3b481953f4ef35f38f166e2532886d33ae54ccdd71393f821dfad9/wcwidth-0.9.1-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:fe021c4d8de9d36c31a0cb41d0d2546dadd3b0708a301f1a0c66ce200851831f", size = 781576 }, + { url = "https://files.pythonhosted.org/packages/d6/72/1ee3dff67697bda93bc56ec9f7b3b89609cd64b9abb328512a2f22eb9e47/wcwidth-0.9.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:708158c082364af442f9983de7b6ec9ac0d2e1b825ada25f0911b1f138d55405", size = 783526 }, + { url = "https://files.pythonhosted.org/packages/ce/0a/e94f19a60f6127bcf3dfcf223ca05a37650d57b37b5ed623597c842ce7ca/wcwidth-0.9.1-cp310-abi3-win32.whl", hash = "sha256:356376852357b8fca71fe5415808ec421679e04b4a98eb7c9cb6a7984b911a05", size = 592136 }, + { url = "https://files.pythonhosted.org/packages/3c/ff/d884d2ec7dcdc86cda742a86c7160bc726dbccae76d7ae1f9ac833c26680/wcwidth-0.9.1-cp310-abi3-win_amd64.whl", hash = "sha256:991d1c8834f548e9c1f16432075ee84638e122312556bbf1ed595ea8fffc4673", size = 596738 }, + { url = "https://files.pythonhosted.org/packages/fa/93/16e30d617b937272a2310a6e3c1f9d8f94984e19ad4a96eb935628e3e6b6/wcwidth-0.9.1-cp310-abi3-win_arm64.whl", hash = "sha256:61bd7aef9cafb6cb77a37a169998d7928ce82a51522146d11f60db9e7d1cb43a", size = 594700 }, + { url = "https://files.pythonhosted.org/packages/96/a2/f06f2e0be4895e5943d358755b51968ace85774cf0a4cc42b4dafb793832/wcwidth-0.9.1-py3-none-any.whl", hash = "sha256:e0c3a1c45c5b9550c6919a4449e95f5b177f6e165786376981db8f1addae9b21", size = 300656 }, +] + +[[package]] +name = "webencodings" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/a0/8fd707bcb776a7be556bad06a2ea5fb9bd519df78ef8e26f70ccf0f38bff/webencodings-0.6.1.tar.gz", hash = "sha256:565f9ad031c702dae404e27a099e3e09186a3ab1b9520f06d215502b651fd910", size = 15001 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c6/040cbc72480d789a5f40d63fb484d3106554c4dfa2d2b70ad5022057750f/webencodings-0.6.1-py3-none-any.whl", hash = "sha256:7fab6269c8bf237c657876b52058ccb182e861518d1c695c1a9aaa8c1c105d5b", size = 8745 }, +] + [[package]] name = "websockets" version = "17.1"