diff --git a/CHANGELOG.md b/CHANGELOG.md index 32c67ee..9d68d29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ All notable changes to this project will be documented in this file - replace naming of classes and methods using 'Workbench' to 'Bench' - replace create_sample, create_container long argument lists with new XXXPost objects +## 2.7.0 2026-07-03 + +- Added support for Inventory Instruments and Instrument Templates (beta RSpace 2.24 + feature): `create_instrument`, `get_instrument_by_id`, `list_instruments`, + `delete_instrument`, `restore_instrument`, `transfer_instrument_owner`, + `update_instrument_to_latest_template_version`, `get_instrument_revisions`, + `get_instrument_revision`, `create_instrument_template`, + `get_instrument_template_by_id`, `delete_instrument_template`, + `list_instrument_templates`, `restore_instrument_template`, + `transfer_instrument_template_owner`, `set_instrument_template_icon`, + `get_instrument_template_icon`, `get_instrument_template_version`, + `update_instrument_template_instruments`, and a new `InstrumentTemplateBuilder`. + `rename`, `set_image`, `duplicate` and `add_extra_fields` now also work on + Instruments and Instrument Templates. + ## 2.6.0 2025-02-25 - implementing pyfilesystem API methods for browsing RSpace Gallery and RSpace Inventory diff --git a/examples/create_instrument.py b/examples/create_instrument.py new file mode 100644 index 0000000..60b58d2 --- /dev/null +++ b/examples/create_instrument.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Creates an Instrument Template, and an Instrument based on it. +""" +import rspace_client +from rspace_client.inv import template_builder, sample_builder2 + +inv_api = rspace_client.utils.createInventoryClient() + +print("Creating an Instrument Template") + +it_json = ( + template_builder.InstrumentTemplateBuilder( + "Microscope template", "A template for the lab's microscopes" + ) + .string("Serial Number") + .number("Calibration") + .uri("Manual") + .build() +) +instrument_template = inv_api.create_instrument_template(it_json) +print( + f"Instrument Template with id {instrument_template['id']} was created with " + f"{len(instrument_template['fields'])} fields" +) + +print("Creating an Instrument with default field values") + +instrument = inv_api.create_instrument( + "My first microscope", instrument_template_id=instrument_template["id"] +) +print(f"Instrument with id {instrument['id']} was created") + +print("Creating an Instrument from the template, with field values set") + +ForInstrumentCreation = sample_builder2.FieldBuilderGenerator().generate_class( + instrument_template +) +instrument_fields = ForInstrumentCreation() +instrument_fields.serial_number = "SN-1234" +instrument_fields.calibration = 4.7 +instrument_fields.manual = "https://example.com/manual.pdf" + +instrument_with_fields = inv_api.create_instrument( + "My second microscope", + instrument_template_id=instrument_template["id"], + fields=instrument_fields.to_field_post(), +) +print( + f"Instrument with id {instrument_with_fields['id']} was created with " + f"{len(instrument_with_fields['fields'])} fields set" +) diff --git a/pyproject.toml b/pyproject.toml index 2cb92ad..0f848a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "rspace-client" -version = "2.6.2" +version = "2.7.0" description = "A client for calling RSpace ELN and Inventory APIs" license = "Apache-2.0" authors = ["Research Innovations Ltd "] @@ -25,6 +25,7 @@ python = "^3.9" requests = "^2.25.1" beautifulsoup4 = "^4.9.3" fs = "^2.4.16" +setuptools = "<82" [tool.poetry.group.dev.dependencies] python-dotenv = "^1.1.1" diff --git a/rspace_client/inv/inv.py b/rspace_client/inv/inv.py index a206a08..6267ec9 100644 --- a/rspace_client/inv/inv.py +++ b/rspace_client/inv/inv.py @@ -543,6 +543,8 @@ class ResultType(Enum): SUBSAMPLE = 2 TEMPLATE = 3 CONTAINER = 4 + INSTRUMENT = 5 + INSTRUMENT_TEMPLATE = 6 class Id: @@ -563,12 +565,16 @@ class Id: "SS": "SUBSAMPLE", "SA": "SAMPLE", "IT": "SAMPLE_TEMPLATE", + "IN": "INSTRUMENT", + "NT": "INSTRUMENT_TEMPLATE", } PREFIX_TO_API = { "IC": "containers", "SS": "subSamples", "SA": "samples", "IT": "sampleTemplates", + "IN": "instruments", + "NT": "instrumentTemplates", } @staticmethod @@ -836,6 +842,37 @@ def __init__( self.data["barcodes"] = [barcode.to_dict() for barcode in barcodes] +class InstrumentPost(ItemPost): + """ + Help define instrument data structures to create or modify instruments + """ + + def __init__( + self, + name: str, + tags: List[Tag] = [], + description: Optional[str] = None, + extra_fields: Optional[Sequence] = [], + instrument_template_id=None, + fields=None, + attachments=None, + barcodes: Optional[List[Barcode]] = None, + ): + super().__init__(name, "INSTRUMENT", tags, description, extra_fields) + ## converts arguments into JSON POST syntax + + if instrument_template_id is not None: + self.data["templateId"] = instrument_template_id + if fields is not None: + self.data["fields"] = fields + ## fail early + if attachments is not None: + if not isinstance(attachments, list): + raise ValueError("attachments must be a list of open files") + if barcodes is not None: + self.data["barcodes"] = [barcode.to_dict() for barcode in barcodes] + + class TargetLocation: """ Base class of target locations. It is recommended to use one of the subclasses @@ -1329,6 +1366,168 @@ def delete_sample(self, sample_id: Union[int, str]): id_to_delete = Id(sample_id) self.doDelete("samples", id_to_delete.as_id()) + def create_instrument( + self, + name: str, + tags: List[Tag] = [], + description: Optional[str] = None, + extra_fields: Optional[Sequence] = [], + instrument_template_id=None, + fields=None, + attachments=None, + barcodes: Optional[List[Barcode]] = None, + ) -> dict: + """ + Creates a new instrument with a mandatory name and optional attributes. + If no template id is specified, the default Instrument template will be used. + + Note that including files to attach to Attachment fields is not supported + by this method. + """ + toPost = InstrumentPost( + name, + tags, + description, + extra_fields, + instrument_template_id, + fields, + attachments, + barcodes, + ) + + instrument = self.retrieve_api_results( + "/instruments", request_type="POST", params=toPost.data + ) + if attachments is not None: + for file in attachments: + self.upload_attachment(instrument["globalId"], file) + ## get latest version + instrument = self.get_instrument_by_id(instrument["id"]) + return instrument + + def get_instrument_by_id(self, instrument_id: Union[str, int]) -> dict: + """ + Gets a full instrument information by id or global id + Parameters + ---------- + id : Union[int, str] + An integer ID e.g 1234 or a global ID e.g. IN1234 + Returns + ------- + dict + A full description of one instrument + """ + i_id = Id(instrument_id) + return self.retrieve_api_results(f"/instruments/{i_id.as_id()}") + + def list_instruments( + self, pagination: Pagination = Pagination(), search_filter: SearchFilter = None + ) -> dict: + """ + Parameters + ---------- + pagination : Pagination, optional + The default is Pagination(). + Returns + ------- + Paginated Search result. Use 'next' and 'prev' links to navigate + """ + return self._do_simple_list("instruments", pagination, search_filter) + + def delete_instrument(self, instrument_id: Union[int, str]): + """ + Marks an instrument as deleted, so it won't appear in Inventory UI and + default listings. + Parameters + ---------- + instrument_id : Union[int, str] + A integer id, or a string id or global ID. + + Returns + ------- + None. + """ + id_to_delete = Id(instrument_id) + self.doDelete("instruments", id_to_delete.as_id()) + + def restore_instrument(self, instrument_id: Union[int, str]) -> dict: + """ + Restores a previously deleted instrument. + Parameters + ---------- + instrument_id : Union[int, str] + The id of the deleted instrument to restore. + Returns + ------- + dict + The updated instrument. + """ + id_to_restore = Id(instrument_id) + return self.retrieve_api_results( + f"/instruments/{id_to_restore.as_id()}/restore", + request_type="PUT", + ) + + def transfer_instrument_owner(self, instrument_id: Union[int, str], new_owner: str): + """ + Transfers the instrument to the new owner + Parameters + ---------- + instrument_id : Union[int, str] + The id of the instrument to transfer + new_owner : str + The username of the new owner + + Returns + ------- + dict + The updated instrument. + """ + i_id = Id(instrument_id) + return self._do_transfer_owner("instruments", i_id, new_owner) + + def update_instrument_to_latest_template_version( + self, instrument_id: Union[int, str] + ) -> dict: + """ + If the Instrument Template used to create this instrument has been updated + since, applies those changes to this instrument (e.g. adds newly added + template fields, renames existing fields). No-op if already on the latest + template version. + Parameters + ---------- + instrument_id : Union[int, str] + The id of the instrument to update. + Returns + ------- + dict + The updated instrument. + """ + i_id = Id(instrument_id) + return self.retrieve_api_results( + f"/instruments/{i_id.as_id()}/actions/updateToLatestTemplateVersion", + request_type="POST", + ) + + def get_instrument_revisions(self, instrument_id: Union[int, str]) -> Sequence[dict]: + """ + Returns list of historical revisions saved for the Instrument, starting from + the earliest revision. + """ + i_id = Id(instrument_id) + return self.retrieve_api_results(f"/instruments/{i_id.as_id()}/revisions") + + def get_instrument_revision( + self, instrument_id: Union[int, str], revision_id: Union[int, str] + ) -> dict: + """ + Returns full details of a historical revision saved for the Instrument. + """ + i_id = Id(instrument_id) + return self.retrieve_api_results( + f"/instruments/{i_id.as_id()}/revisions/{revision_id}" + ) + def add_extra_fields(self, item_id: Union[str, dict], *ExtraField) -> dict: s_id = Id(item_id) endpoint = s_id.get_api_endpoint() @@ -2196,6 +2395,206 @@ def transfer_sample_template_owner( st_id = Id(sample_template_id) return self._do_transfer_owner("sampleTemplates", st_id, new_owner) + def create_instrument_template(self, instrument_template_post: dict) -> dict: + """ + Creates a new InstrumentTemplate. Use InstrumentTemplateBuilder to create the + template data structure required as instrument_template_post parameter. + + Parameters + ---------- + instrument_template_post : A Dict + A Dictionary of the InstrumentTemplate definition to post. + + Returns + ------- + Dict + The newly created template. + """ + return self.retrieve_api_results( + "/instrumentTemplates", request_type="POST", params=instrument_template_post + ) + + def get_instrument_template_by_id( + self, instrument_template_id: Union[str, int] + ) -> dict: + """ + Gets a full InstrumentTemplate information by id or global id + Parameters + ---------- + id : Union[int, str] + An integer ID e.g 1234 or a global ID e.g. NT1234 + Returns + ------- + dict + A full description of one instrument template + """ + it_id = Id(instrument_template_id) + return self.retrieve_api_results(f"/instrumentTemplates/{it_id.as_id()}") + + def delete_instrument_template( + self, instrument_template_id: Union[int, str] + ) -> None: + """ + Parameters + ---------- + instrument_template_id : Union[int, str] + A integer id, or a string id or global ID of the template to delete + + Returns + ------- + None. + + """ + id_to_delete = Id(instrument_template_id) + self.doDelete("instrumentTemplates", id_to_delete.as_id()) + + def set_instrument_template_icon( + self, instrument_template_id: Union[int, str], file + ): + """ + Parameters + ---------- + instrument_template_id : Union[int, str] + The ID of the template to add the icon too. + file : an open File + An icon or image to help identify the template in listings. + + Returns + ------- + The updated InstrumentTemplate, with an iconId set. + + """ + it_id = Id(instrument_template_id) + headers = self._get_headers() + response = requests.post( + f"{self._get_api_url()}/instrumentTemplates/{it_id.as_id()}/icon", + files={"file": file}, + headers=headers, + ) + return self._handle_response(response) + + def get_instrument_template_icon( + self, instrument_template_id: Union[int, str], icon_id: int, outfile + ): + """ + Downloads the Instrument Template's icon + + Parameters + ---------- + instrument_template_id : Union[int, str] + The id of the InstrumentTemplate. + icon_id : int + A numeric ID of the icon. + outfile : string + A path to a writable file to store the downloaded icon. + + Returns + ------- + void, no return value + """ + it_id = Id(instrument_template_id) + url_base = self._get_api_url() + return self.download_link_to_file( + f"{url_base}/instrumentTemplates/{it_id.as_id()}/icon/{icon_id}", outfile + ) + + def list_instrument_templates( + self, pagination: Pagination = Pagination(), search_filter: SearchFilter = None + ): + """ + Paginated listing of InstrumentTemplates, optionally filtering by username + (owner) or deletion status + + Parameters + ---------- + pagination : Pagination, optional + The default is Pagination(). + search_filter : SearchFilter, optional + The default is None. + + Returns + ------- + A standard SearchResult with 'totalHits' attribute and a list of 'templates' + with basic information about each template. + + """ + return self._do_simple_list("instrumentTemplates", pagination, search_filter) + + def restore_instrument_template( + self, instrument_template_id: Union[int, str] + ) -> dict: + """ + Restores a deleted instrument template so it will appear in the template + listings and be usable to create new instruments. + If the template is not in a deleted state, this action has no effect. + Parameters + ---------- + instrument_template_id : Union[int, str] + The id of the deleted instrument template to restore. + Returns + ------- + dict + The updated template. + """ + id_to_restore = Id(instrument_template_id) + return self.retrieve_api_results( + f"/instrumentTemplates/{id_to_restore.as_id()}/restore", + request_type="PUT", + ) + + def get_instrument_template_version( + self, instrument_template_id: Union[int, str], version: int + ) -> dict: + """ + Retrieves a particular historical version of an InstrumentTemplate. Each + InstrumentTemplate has a 'version' property starting at 1, incremented on + each content update. + """ + it_id = Id(instrument_template_id) + return self.retrieve_api_results( + f"/instrumentTemplates/{it_id.as_id()}/versions/{version}" + ) + + def transfer_instrument_template_owner( + self, instrument_template_id: Union[int, str], new_owner: str + ): + """ + Transfers the instrument template to the new owner + Parameters + ---------- + instrument_template_id : Union[int, str] + The id of the instrument template to transfer + new_owner : str + The username of the new owner + + Returns + ------- + dict + The updated instrument template. + """ + it_id = Id(instrument_template_id) + return self._do_transfer_owner("instrumentTemplates", it_id, new_owner) + + def update_instrument_template_instruments( + self, instrument_template_id: Union[int, str] + ) -> dict: + """ + Walks the current user's instruments that were created from this template at + an older version and applies the template's latest changes to each (e.g. add + or rename fields). If updating a particular instrument fails, it is skipped + and the process continues. + + Returns + ------- + dict + The list of updated instruments and any errors encountered. + """ + it_id = Id(instrument_template_id) + return self.retrieve_api_results( + f"/instrumentTemplates/{it_id.as_id()}/actions/updateInstrumentsToLatestTemplateVersion", + request_type="POST", + ) + def transfer_sample_owner(self, sample_id: Union[int, str], new_owner: str): """ Transfers the sample to the new owner diff --git a/rspace_client/inv/template_builder.py b/rspace_client/inv/template_builder.py index 0c4594b..691fbf5 100644 --- a/rspace_client/inv/template_builder.py +++ b/rspace_client/inv/template_builder.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -Define SampleTemplates using a Builder pattern +Define SampleTemplates and InstrumentTemplates using a Builder pattern """ from typing import Optional, Sequence, Union, List from urllib.parse import urlparse @@ -10,25 +10,16 @@ from rspace_client.inv.quantity_unit import QuantityUnit -class TemplateBuilder: +class FieldDefinitionBuilder: """ - Define a SampleTemplate prior to POSTing to RSpace. - A SampleTemplate only requires a name and a default unit to be defined. - The default unit is supplied as a String from a permitted list in class QuantityUnit. E.g. 'ml', 'g'. + Base class providing methods to define the fields of an Inventory template + (SampleTemplate or InstrumentTemplate) prior to POSTing to RSpace. """ numeric = Union[int, float] - def __init__(self, name, defaultUnit, description=None): - if not QuantityUnit.is_supported_unit(defaultUnit): - raise ValueError( - f"{defaultUnit} must be a label of a supported unit in QuantityUnit" - ) - self.name = name + def __init__(self): self.fields = [] - self.qu = QuantityUnit.of(defaultUnit) - if description is not None: - self.description = description def _set_name(self, name: str, f_type: str): if len(name) == 0: @@ -244,11 +235,50 @@ def uri(self, name: str, uri: str = None): def field_count(self): return len(self.fields) + def _fields(self): + return self.fields + + +class TemplateBuilder(FieldDefinitionBuilder): + """ + Define a SampleTemplate prior to POSTing to RSpace. + A SampleTemplate only requires a name and a default unit to be defined. + The default unit is supplied as a String from a permitted list in class QuantityUnit. E.g. 'ml', 'g'. + """ + + def __init__(self, name, defaultUnit, description=None): + super().__init__() + if not QuantityUnit.is_supported_unit(defaultUnit): + raise ValueError( + f"{defaultUnit} must be a label of a supported unit in QuantityUnit" + ) + self.name = name + self.qu = QuantityUnit.of(defaultUnit) + if description is not None: + self.description = description + def build(self) -> dict: d = {"name": self.name, "defaultUnitId": self.qu["id"], "fields": self.fields} if hasattr(self, "description"): d["description"] = self.description return d - def _fields(self): - return self.fields + +class InstrumentTemplateBuilder(FieldDefinitionBuilder): + """ + Define an InstrumentTemplate prior to POSTing to RSpace. + An InstrumentTemplate only requires a name to be defined; unlike a SampleTemplate + it has no default unit. + """ + + def __init__(self, name, description=None): + super().__init__() + self.name = name + if description is not None: + self.description = description + + def build(self) -> dict: + d = {"name": self.name, "fields": self.fields} + if hasattr(self, "description"): + d["description"] = self.description + return d diff --git a/rspace_client/tests/id_test.py b/rspace_client/tests/id_test.py index 280c888..4d03be4 100644 --- a/rspace_client/tests/id_test.py +++ b/rspace_client/tests/id_test.py @@ -72,3 +72,17 @@ def test_valid_id(self): Id.is_valid_id({"id": 123, "globalId": "BE123", "cType": "WORKBENCH"}) ) self.assertFalse(Id.is_valid_id("ndjfndskjf")) + + def test_id_from_instrument(self): + id_a = Id("IN1234") + self.assertEqual(1234, id_a.as_id()) + self.assertEqual("IN", id_a.prefix) + self.assertEqual("INSTRUMENT", id_a.get_type()) + self.assertEqual("instruments", id_a.get_api_endpoint()) + + def test_id_from_instrument_template(self): + id_a = Id("NT1234") + self.assertEqual(1234, id_a.as_id()) + self.assertEqual("NT", id_a.prefix) + self.assertEqual("INSTRUMENT_TEMPLATE", id_a.get_type()) + self.assertEqual("instrumentTemplates", id_a.get_api_endpoint()) diff --git a/rspace_client/tests/invapi_test.py b/rspace_client/tests/invapi_test.py index aefcd98..77392a6 100644 --- a/rspace_client/tests/invapi_test.py +++ b/rspace_client/tests/invapi_test.py @@ -836,6 +836,130 @@ def test_attach_file_to_attachment_field(self): with open(data_file, "rb") as f: self.invapi.upload_attachment(created_sample["fields"][0]["globalId"], f) + def test_create_instrument_template(self): + it_json = ( + template_builder.InstrumentTemplateBuilder("toTest") + .text("Notes") + .number("Calibration", 7) + .build() + ) + it = self.invapi.create_instrument_template(it_json) + self.assertTrue("id" in it) + self.assertEqual("toTest", it["name"]) + self.assertEqual(2, len(it["fields"])) + + def test_delete_restore_instrument_template(self): + it_json = ( + template_builder.InstrumentTemplateBuilder("toTest") + .text("Notes") + .build() + ) + it = self.invapi.create_instrument_template(it_json) + + self.invapi.delete_instrument_template(it["id"]) + restored = self.invapi.restore_instrument_template(it["id"]) + self.assertTrue("id" in restored) + self.assertEqual(1, len(restored["fields"])) + + def test_list_instrument_templates(self): + template_builder_json = template_builder.InstrumentTemplateBuilder( + base.random_string(5) + ).build() + self.invapi.create_instrument_template(template_builder_json) + results = self.invapi.list_instrument_templates() + self.assertTrue(results["totalHits"] > 0) + + def test_create_instrument(self): + instrument_name = base.random_string(5) + instrument_tags = inv.gen_tags([base.random_string(4)]) + ef1 = inv.ExtraField("f1", inv.ExtraFieldType.TEXT, "hello") + instrument = self.invapi.create_instrument( + name=instrument_name, + tags=instrument_tags, + extra_fields=[ef1], + ) + self.assertEqual(instrument_name, instrument["name"]) + self.assertEqual(1, len(instrument["extraFields"])) + + def test_get_single_instrument(self): + instrument = self.invapi.create_instrument(base.random_string(5)) + by_id = self.invapi.get_instrument_by_id(instrument["id"]) + self.assertEqual(instrument["globalId"], by_id["globalId"]) + by_global_id = self.invapi.get_instrument_by_id(instrument["globalId"]) + self.assertEqual(instrument["globalId"], by_global_id["globalId"]) + + def test_list_instruments(self): + self.invapi.create_instrument(base.random_string(5)) + results = self.invapi.list_instruments() + self.assertTrue(results["totalHits"] > 0) + + def test_delete_restore_instrument(self): + instrument = self.invapi.create_instrument(base.random_string(5)) + self.invapi.delete_instrument(instrument["id"]) + restored = self.invapi.restore_instrument(instrument["id"]) + self.assertEqual(instrument["globalId"], restored["globalId"]) + + def test_rename_instrument(self): + instrument = self.invapi.create_instrument(base.random_string(5)) + new_name = "renamed_" + instrument["name"] + updated = self.invapi.rename(instrument["globalId"], new_name) + self.assertEqual(new_name, updated["name"]) + + def test_set_image_instrument(self): + instrument = self.invapi.create_instrument(base.random_string(5)) + file = base.get_datafile("AntibodySample150.png") + with open(file, "rb") as f: + updated_instrument = self.invapi.set_image(instrument, f) + + links = updated_instrument["_links"] + for l in links: + if l["rel"] == "image": + self.assertFalse(l["link"].endswith("-1")) + + def test_duplicate_instrument(self): + instrument = self.invapi.create_instrument(base.random_string(5)) + duplicated = self.invapi.duplicate(instrument["globalId"], "duplicated name") + self.assertEqual("duplicated name", duplicated["name"]) + self.assertNotEqual(instrument["id"], duplicated["id"]) + + def test_create_instrument_from_template(self): + builder = template_builder.InstrumentTemplateBuilder("MyMicroscope") + it_json = ( + builder.string("Serial Number") + .number("Calibration") + .uri("Manual") + .build() + ) + it = self.invapi.create_instrument_template(it_json) + + ForInstrumentCreation = sample_builder2.FieldBuilderGenerator().generate_class( + it + ) + instrument_fields = ForInstrumentCreation() + instrument_fields.serial_number = "SN-1234" + instrument_fields.calibration = 4.7 + instrument_fields.manual = "https://myinstrument.supplier.com" + + created_instrument = self.invapi.create_instrument( + name="From MyMicroscope", + instrument_template_id=it["id"], + fields=instrument_fields.to_field_post(), + ) + + self.assertIsNotNone(created_instrument["id"]) + self.assertEqual("SN-1234", created_instrument["fields"][0]["content"]) + self.assertEqual(4.7, float(created_instrument["fields"][1]["content"])) + self.assertEqual( + "https://myinstrument.supplier.com", + created_instrument["fields"][2]["content"], + ) + + def test_add_extra_fields_instrument(self): + instrument = self.invapi.create_instrument(base.random_string(5)) + ef1 = inv.ExtraField("f1", inv.ExtraFieldType.TEXT, "hello") + updated = self.invapi.add_extra_fields(instrument["globalId"], ef1) + self.assertEqual(1, len(updated["extraFields"])) + def test_update_datacite_settings_enabled(self): """ Test updating DataCite settings when enabling DataCite diff --git a/rspace_client/tests/template_builder_test.py b/rspace_client/tests/template_builder_test.py index 2e41d0d..e3ed8b0 100755 --- a/rspace_client/tests/template_builder_test.py +++ b/rspace_client/tests/template_builder_test.py @@ -7,7 +7,7 @@ """ -from rspace_client.inv.template_builder import TemplateBuilder +from rspace_client.inv.template_builder import TemplateBuilder, InstrumentTemplateBuilder import unittest import datetime as dt @@ -145,3 +145,41 @@ def test_add_uri(self): def test_build(self): builder = TemplateBuilder("water sample", "ml").text("notes").number("pH", 7) to_post = builder.build() + + +class InstrumentTemplateBuilderTest(unittest.TestCase): + def test_build_minimal(self): + builder = InstrumentTemplateBuilder("Microscope template") + to_post = builder.build() + self.assertEqual("Microscope template", to_post["name"]) + self.assertEqual([], to_post["fields"]) + self.assertFalse("description" in to_post) + self.assertFalse("defaultUnitId" in to_post) + + def test_build_with_description(self): + builder = InstrumentTemplateBuilder( + "Microscope template", "A template for the lab's microscopes" + ) + to_post = builder.build() + self.assertEqual( + "A template for the lab's microscopes", to_post["description"] + ) + + def test_shares_field_definition_methods_with_template_builder(self): + builder = InstrumentTemplateBuilder("Microscope template") + builder.string("Serial Number").number("Calibration", 7).uri( + "Manual", "https://example.com/manual.pdf" + ) + self.assertEqual(3, builder.field_count()) + to_post = builder.build() + self.assertEqual( + "https://example.com/manual.pdf", to_post["fields"][2]["content"] + ) + + def test_name_required(self): + builder = InstrumentTemplateBuilder("Microscope template") + self.assertRaises( + ValueError, + builder.string, + "", + )