From d11ac5b75c18eef9f0cf7d6210abd068bf14d548 Mon Sep 17 00:00:00 2001 From: Brian Bolt Date: Wed, 16 Oct 2024 07:22:53 -0700 Subject: [PATCH 1/6] DISCO-1604: Protocol ls thing, Salt class, Refactor logging configuration and add NullHandler to avoid errors --- acasclient/CmpdReg.py | 57 +++++++++++++++++++++++++-- acasclient/acasclient.py | 83 ++++++++++++++++++++++++++++++++++++++++ acasclient/ddict.py | 19 ++++++++- acasclient/experiment.py | 2 +- acasclient/lsthing.py | 18 +++++++-- acasclient/protocol.py | 68 ++++++++++++++++++++++++++++++++ acasclient/selfile.py | 5 ++- tests/test_lsthing.py | 38 ++++++++++++++++-- 8 files changed, 277 insertions(+), 13 deletions(-) create mode 100644 acasclient/protocol.py diff --git a/acasclient/CmpdReg.py b/acasclient/CmpdReg.py index f9bb7b5..7556d7b 100644 --- a/acasclient/CmpdReg.py +++ b/acasclient/CmpdReg.py @@ -4,11 +4,57 @@ import types import logging from .acasclient import client +from .lsthing import convert_json, camel_to_underscore logger = logging.getLogger(__name__) -logging.basicConfig(level=logging.INFO) + +class Salt(): + """Salt class.""" + def __init__(self, abbrev: str, name: str, mol_structure: str, id: int = None, **kwargs): + self.id = id + self.abbrev = abbrev + self.name = name + self.mol_structure = mol_structure + + def save(self, client: client) -> Salt: + """Save the salt to the server.""" + resp = client.create_salt(self.abbrev, self.name, self.mol_structure) + self.id = resp['id'] + return self + + @classmethod + def from_camel_dict(cls, data): + """Construct an AbstractModel object from a camelCase dict + + :param data: dict of attributes + :type data: dict + :return: instance of class AbstractModel + :rtype: AbstractModel + """ + snake_case_dict = convert_json(data, camel_to_underscore) + return cls.from_dict(snake_case_dict) + + @classmethod + def from_dict(cls, data): + """Construct an AbstractModel object from a dict + + :param data: dict of attributes + :type data: dict + :return: instance of class AbstractModel + :rtype: AbstractModel + """ + return cls(**data) + + def as_dict(self) -> dict: + return { + 'id': self.id, + 'abbrev': self.abbrev, + 'name': self.name, + 'mol_structure': self.mol_structure + } + class AdditionalScientistType(Enum): """Enum for additional scientist types.""" COMPOUND = ACASDDict('compound', 'scientist') @@ -62,7 +108,7 @@ def meta_lots_to_dict_array(meta_lots: list[dict]) -> list[dict]: return [meta_lot_to_dict(meta_lot) for meta_lot in meta_lots] -def meta_lot_to_dict(meta_lot: dict) -> dict: +def meta_lot_to_dict(meta_lot: dict, use_parent_mol=True) -> dict: """Converts a meta lot to a dictionary into a flat dictionary of specific fields.""" parent_common_names = [parent_alias["aliasName"] for parent_alias in meta_lot["lot"]["saltForm"]["parent"]["parentAliases"] if parent_alias["lsKind"] == "Common Name"] @@ -80,8 +126,13 @@ def meta_lot_to_dict(meta_lot: dict) -> dict: # If lot amount is filled in, then we need to fill in lot barcode with the lot corp name lot_barcode = meta_lot["lot"]["corpName"] if meta_lot["lot"]["amount"] is not None else None + if use_parent_mol: + mol = meta_lot["lot"]["parent"]["molStructure"] + else: + mol = meta_lot["lot"]['asDrawnStruct'] if meta_lot["lot"]['asDrawnStruct'] is not None else meta_lot["lot"]["parent"]["molStructure"] + return_dict = { - 'mol': meta_lot["lot"]['asDrawnStruct'] if meta_lot["lot"]['asDrawnStruct'] is not None else meta_lot["lot"]["parent"]["molStructure"], + 'mol': mol, "id": meta_lot["lot"]["id"], "name": meta_lot["lot"]["corpName"], "parent_common_name": '; '.join(parent_common_names) if len(parent_common_names) > 0 else None, diff --git a/acasclient/acasclient.py b/acasclient/acasclient.py index c9e6aac..738ecd2 100644 --- a/acasclient/acasclient.py +++ b/acasclient/acasclient.py @@ -960,7 +960,90 @@ def get_protocols_by_label(self, label): .format(self.url, label)) resp.raise_for_status() return resp.json() + + def get_protocol_by_code(self, protocol_code): + """Get a protocol from a protocol code + + Get a protocol given a protocol code + + Args: + protocol_code (str): A protocol code + + Returns: Returns a protocol object + """ + resp = self.session.get("{}/api/protocols/codename/{}" + .format(self.url, protocol_code)) + if resp.status_code == 500: + return None + resp.raise_for_status() + return resp.json() + + def get_all_protocols(self): + """Get all protocols + Get an array of all protocols + + Args: + None + + Returns: Returns an array of protocols + """ + resp = self.session.get("{}/api/protocolCodes" + .format(self.url)) + resp.raise_for_status() + protocols = resp.json() + # Remove any dupes using the id (BUG in ACAS) + return [dict(t) for t in {tuple(d.items()) for d in protocols}] + + def save_protocol(self, protocol): + """Save a protocol + + Save a protocol to ACAS + + Args: + protocol (dict): A protocol object + + Returns: Returns a protocol object + """ + if protocol.get("id") and protocol.get("codeName"): + resp_dict = self.update_protocol(protocol) + else: + resp_dict = self.create_protocol(protocol) + return resp_dict + + def update_protocol(self, protocol): + """Update a protocol + + Update a protocol in ACAS + + Args: + protocol (dict): A protocol object + + Returns: Returns a protocol object + """ + resp = self.session.put("{}/api/protocols/{}".format(self.url, protocol.get('id')), + headers={'Content-Type': "application/json"}, + data=json.dumps(protocol)) + resp.raise_for_status() + return resp.json() + + def create_protocol(self, protocol): + """Create a protocol + + Create a protocol in ACAS + + Args: + protocol (dict): A protocol object + + Returns: Returns a protocol object + """ + resp = self.session.post("{}/api/protocols".format(self.url), + headers={'Content-Type': "application/json"}, + data=json.dumps(protocol)) + resp.raise_for_status() + return resp.json() + + def get_experiments_by_protocol_code(self, protocol_code): """Get all experiments for a protocol from a protocol code diff --git a/acasclient/ddict.py b/acasclient/ddict.py index 248c700..08a42d7 100644 --- a/acasclient/ddict.py +++ b/acasclient/ddict.py @@ -4,8 +4,6 @@ import logging logger = logging.getLogger(__name__) -logging.basicConfig(level=logging.INFO) - class DDict(object): """The DDict class is meant as a generic interface for any implementation of a Data Dictionary that @@ -71,3 +69,20 @@ def update_valid_values(self, client): self.valid_values = [val_dict['code'] for val_dict in valid_codetables] if not self.valid_values: self.raise_empty_dict_error() + +class ACASAuthorDDict(DDict): + """DDict implementation for built-in ACAS DDicts """ + + CODE_ORIGIN = 'ACAS authors' + + def __init__(self, code_type, code_kind): + super(ACASAuthorDDict, self).__init__(code_type, code_kind, self.CODE_ORIGIN) + + def update_valid_values(self, client): + """Get the valid values for the DDict.""" + valid_authors = client.get_authors() + # Raise error if author does not match name + self.valid_values = [val_dict['code'] for val_dict in valid_authors] + if not self.valid_values: + self.raise_empty_dict_error() + diff --git a/acasclient/experiment.py b/acasclient/experiment.py index a2a6904..91b04cb 100644 --- a/acasclient/experiment.py +++ b/acasclient/experiment.py @@ -231,4 +231,4 @@ def scientist(self) -> str: value_kind="scientist") return data.get('codeValue') if data else None - as_dict = dict.__str__ \ No newline at end of file + as_dict = dict.__str__ diff --git a/acasclient/lsthing.py b/acasclient/lsthing.py index 263800b..a1def27 100644 --- a/acasclient/lsthing.py +++ b/acasclient/lsthing.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals from typing import Any, Dict -from .ddict import ACASDDict, ACASLsThingDDict, DDict +from .ddict import ACASDDict, ACASLsThingDDict, DDict, ACASAuthorDDict from .interactions import INTERACTION_VERBS_DICT, opposite from .validation import validation_result, ValidationResult @@ -18,12 +18,11 @@ from six import text_type as str logger = logging.getLogger(__name__) -logging.basicConfig(level=logging.INFO) - ROW_NUM_KEY = 'row number' ACAS_DDICT = ACASDDict.CODE_ORIGIN.upper() ACAS_LSTHING = ACASLsThingDDict.CODE_ORIGIN.upper() +ACAS_AUTHOR = ACASAuthorDDict.CODE_ORIGIN.upper() # JSON encoding / decoding @@ -850,7 +849,18 @@ def as_camel_dict(self): snake_case_dict = self.as_dict() camel_dict = convert_json(snake_case_dict, underscore_to_camel) return camel_dict + + def __deepcopy__(self, memo): + # Create a deep copy of the instance first + copied_obj = copy.copy(self) # Shallow copy of self + # Then deep copy the attributes individually + copied_obj.__dict__ = copy.deepcopy(self.__dict__, memo) + # Set the 'id' attribute to None + copied_obj.id = None + copied_obj.version = None + return copied_obj + def as_json(self, **kwargs): """Serialize instance into a JSON string with camelCase keys @@ -2136,6 +2146,8 @@ def _get_ddicts(self): ddict = ACASDDict(value.code_type, value.code_kind) elif value.code_origin.upper() == ACAS_LSTHING: ddict = ACASLsThingDDict(value.code_type, value.code_kind) + elif value.code_origin.upper() == ACAS_AUTHOR: + ddict = ACASAuthorDDict(value.code_type, value.code_kind) else: raise ValueError(f'Unsupported code_origin: {value.code_origin}') ddicts[(ddict.code_type, ddict.code_kind, ddict.code_origin.upper())] = ddict diff --git a/acasclient/protocol.py b/acasclient/protocol.py new file mode 100644 index 0000000..1d514db --- /dev/null +++ b/acasclient/protocol.py @@ -0,0 +1,68 @@ +from acasclient.lsthing import (CodeValue, SimpleLsThing, clob, ACAS_DDICT, ACAS_AUTHOR) + +from datetime import datetime + +# Constants +ACAS_LSTHING = 'ACAS LsThing' +META_DATA = 'protocol metadata' +LS_TYPE = 'default' +LS_KIND = 'default' +PREFERRED_LABEL = 'protocol name' +STATUS = 'protocol status' +NOTEBOOK_PAGE = 'notebook page' +PROJECT = 'project' +COMMENTS = 'comments' +ASSAY_TREE_RULE = 'assay tree rule' +PROTOCOL_STATUS = 'protocol status' +ASSAY_STAGE = 'assay stage' +SCIENTIST = 'scientist' +ASSAY_PRINCIPLE = 'assay principle' +CREATION_DATE = 'creation date' +PROTOCOL_DETAILS = 'protocol details' +NOTEBOOK = 'notebook' + + +class Protocol(SimpleLsThing): + ls_type = LS_TYPE + ls_kind = LS_KIND + preferred_label_kind = PREFERRED_LABEL + + def __init__(self, name=None, scientist=None, recorded_by=None, assay_principle=None, assay_stage='unassigned', creation_date=None, protocol_details=None, + notebook=None, comments=None, assay_tree_rule=None, protocol_status='created', notebook_page=None, project='unassigned', ls_thing=None): + names = {PREFERRED_LABEL: name} + + if not creation_date: + creation_date = datetime.now() + + metadata = { + META_DATA: { + ASSAY_PRINCIPLE: clob(assay_principle), + ASSAY_STAGE: CodeValue(assay_stage, 'assay', 'stage', ACAS_DDICT), + SCIENTIST: CodeValue(scientist, 'assay', 'scientist', ACAS_AUTHOR), + CREATION_DATE: creation_date, + PROTOCOL_DETAILS: clob(protocol_details), + NOTEBOOK: notebook, + COMMENTS: clob(comments), + ASSAY_TREE_RULE: assay_tree_rule, + PROTOCOL_STATUS: CodeValue(protocol_status, 'protocol', 'status', ACAS_DDICT), + NOTEBOOK_PAGE: notebook_page, + PROJECT: CodeValue(project, 'project', 'biology', ACAS_DDICT) + } + } + super(Protocol, self).__init__(ls_type=self.ls_type, ls_kind=self.ls_kind, names=names, recorded_by=recorded_by, + preferred_label_kind=self.preferred_label_kind, metadata=metadata, ls_thing=ls_thing) + + def save(self, client, skip_validation=False): + """Persist changes to the ACAS server. + + :param client: Authenticated instances of acasclient.client + :type client: acasclient.client + """ + # Run validation + if not skip_validation: + self.validate(client) + self._prepare_for_save(client) + # Persist + protocol_dict = self._ls_thing.as_camel_dict() + resp_dict = client.save_protocol(protocol_dict) + return resp_dict diff --git a/acasclient/selfile.py b/acasclient/selfile.py index d9775e0..7675d08 100644 --- a/acasclient/selfile.py +++ b/acasclient/selfile.py @@ -61,8 +61,11 @@ ################################################################################ # Logging ################################################################################ -logger = logging.getLogger(__name__).addHandler(logging.NullHandler()) +# Initialize the logger +logger = logging.getLogger(__name__) +# Add a NullHandler to avoid errors if no other handlers are configured +logger.addHandler(logging.NullHandler()) ################################################################################ # Functions diff --git a/tests/test_lsthing.py b/tests/test_lsthing.py index aca443e..d4b8712 100644 --- a/tests/test_lsthing.py +++ b/tests/test_lsthing.py @@ -10,22 +10,23 @@ from acasclient.ddict import ACASDDict, ACASLsThingDDict from acasclient.lsthing import (BlobValue, CodeValue, FileValue, LsThingValue, - SimpleLsThing, get_lsKind_to_lsvalue, datetime_to_ts) + SimpleLsThing, get_lsKind_to_lsvalue, datetime_to_ts, LsThing) from acasclient.validation import ValidationResult, get_validation_response +from acasclient.protocol import Protocol from tests.test_acasclient import BaseAcasClientTest logger = logging.getLogger(__name__) -logging.basicConfig(level=logging.INFO) # Constants from tests.project_thing import ( - PROJECT_METADATA, PROJECT, STATUS, PROJECT_STATUS,PROCEDURE_DOCUMENT,PDF_DOCUMENT, + PROJECT_METADATA, PROJECT, STATUS, PROJECT_STATUS, PROCEDURE_DOCUMENT, PDF_DOCUMENT, NAME_KEY, IS_RESTRICTED_KEY, STATUS_KEY, START_DATE_KEY, DESCRIPTION_KEY, PROJECT_NAME, START_DATE, PDF_DOCUMENT_KEY, PROCEDURE_DOCUMENT_KEY, PARENT_PROJECT_KEY, ACTIVE, INACTIVE, PROJECT_NUMBER_KEY, PROJECT_NUMBER, Project ) + FWD_ITX = 'relates to' BACK_ITX = 'is related to' @@ -1109,3 +1110,34 @@ def test_002_html_summary(self): self.assertNotIn('

Warnings:', html) self.assertIn('

Summary

', html) self.assertIn(f"
  • {SUMM_1}
  • ", html) + +class TestProtocol(BaseAcasClientTest): + + def test_001_create_protocol(self): + """ + Test creating a protocol with a few different types of values. + """ + # Create a protocol + name = str(uuid.uuid4()) + scientist = self.client.username + recorded_by = scientist + protocol = Protocol(name=name, recorded_by=recorded_by, scientist=scientist) + protocol.save(self.client) + assert protocol.code_name + + # Get the protocol by code name + protocol_dict = self.client.get_protocol_by_code(protocol.code_name) + + # Verify the protocol has code_name and recorded_by is correct + assert protocol_dict['codeName'] == protocol.code_name + assert protocol_dict['recordedBy'] == recorded_by + + # Turn the protocol back into a Protocol + updated_protocol = Protocol(ls_thing=LsThing.from_camel_dict(data=protocol_dict)) + updated_protocol.save(self.client) + + # Make sure the put worked by checking the version + protocol_dict['version'] + 1 == updated_protocol._ls_thing.version + + + \ No newline at end of file From 4f843f3dcb731a3ba89e49044b3e70ad710b13ce Mon Sep 17 00:00:00 2001 From: Brian Bolt Date: Tue, 22 Oct 2024 13:45:27 -0700 Subject: [PATCH 2/6] Fix description of get all protocols return --- acasclient/acasclient.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/acasclient/acasclient.py b/acasclient/acasclient.py index 738ecd2..fab1be2 100644 --- a/acasclient/acasclient.py +++ b/acasclient/acasclient.py @@ -979,14 +979,23 @@ def get_protocol_by_code(self, protocol_code): return resp.json() def get_all_protocols(self): - """Get all protocols + """Get all protocol code stubs Get an array of all protocols Args: None - Returns: Returns an array of protocols + list of dict: Returns an array of protocol stubs in the format: + [ + { + 'id': int, + 'code': str, + 'name': str, + 'ignored': str + }, + ... + ] """ resp = self.session.get("{}/api/protocolCodes" .format(self.url)) From e14fd07557843e2bdd8c5abdc01c61cf35dc020c Mon Sep 17 00:00:00 2001 From: Brian Bolt Date: Tue, 22 Oct 2024 13:47:51 -0700 Subject: [PATCH 3/6] clear doc string on ls thing deepcopy --- acasclient/lsthing.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/acasclient/lsthing.py b/acasclient/lsthing.py index a1def27..2f6e435 100644 --- a/acasclient/lsthing.py +++ b/acasclient/lsthing.py @@ -851,6 +851,19 @@ def as_camel_dict(self): return camel_dict def __deepcopy__(self, memo): + """Create a deep copy of the instance. + + The 'id' and 'version' fields are nulled out to ensure that the copied object + is treated as a new instance rather than a duplicate of the original. This is + particularly useful when the copied object needs to be saved as a new record + in a database or used in a context where unique identifiers are required. + + Args: + memo (dict): A dictionary of objects already copied during the current copying pass. + + Returns: + object: A deep copy of the instance with 'id' and 'version' fields set to None. + """ # Create a deep copy of the instance first copied_obj = copy.copy(self) # Shallow copy of self # Then deep copy the attributes individually From d963c981e445140c6cea7b63b33a86ce4c28a82a Mon Sep 17 00:00:00 2001 From: Brian Bolt Date: Tue, 22 Oct 2024 14:43:28 -0700 Subject: [PATCH 4/6] Fix tests --- acasclient/protocol.py | 6 +++-- tests/test_lsthing.py | 56 ++++++++++++++++++++---------------------- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/acasclient/protocol.py b/acasclient/protocol.py index 1d514db..9cf5942 100644 --- a/acasclient/protocol.py +++ b/acasclient/protocol.py @@ -1,4 +1,4 @@ -from acasclient.lsthing import (CodeValue, SimpleLsThing, clob, ACAS_DDICT, ACAS_AUTHOR) +from acasclient.lsthing import (CodeValue, SimpleLsThing, LsThing, clob, ACAS_DDICT, ACAS_AUTHOR) from datetime import datetime @@ -65,4 +65,6 @@ def save(self, client, skip_validation=False): # Persist protocol_dict = self._ls_thing.as_camel_dict() resp_dict = client.save_protocol(protocol_dict) - return resp_dict + self._ls_thing = LsThing.from_camel_dict(resp_dict) + self._cleanup_after_save() + return self diff --git a/tests/test_lsthing.py b/tests/test_lsthing.py index d4b8712..41ef003 100644 --- a/tests/test_lsthing.py +++ b/tests/test_lsthing.py @@ -1111,33 +1111,31 @@ def test_002_html_summary(self): self.assertIn('

    Summary

    ', html) self.assertIn(f"
  • {SUMM_1}
  • ", html) + class TestProtocol(BaseAcasClientTest): - - def test_001_create_protocol(self): - """ - Test creating a protocol with a few different types of values. - """ - # Create a protocol - name = str(uuid.uuid4()) - scientist = self.client.username - recorded_by = scientist - protocol = Protocol(name=name, recorded_by=recorded_by, scientist=scientist) - protocol.save(self.client) - assert protocol.code_name - - # Get the protocol by code name - protocol_dict = self.client.get_protocol_by_code(protocol.code_name) - - # Verify the protocol has code_name and recorded_by is correct - assert protocol_dict['codeName'] == protocol.code_name - assert protocol_dict['recordedBy'] == recorded_by - - # Turn the protocol back into a Protocol - updated_protocol = Protocol(ls_thing=LsThing.from_camel_dict(data=protocol_dict)) - updated_protocol.save(self.client) - - # Make sure the put worked by checking the version - protocol_dict['version'] + 1 == updated_protocol._ls_thing.version - - - \ No newline at end of file + + def test_001_create_protocol(self): + """ + Test creating a protocol with a few different types of values. + """ + # Create a protocol + name = str(uuid.uuid4()) + scientist = self.client.username + recorded_by = scientist + protocol = Protocol(name=name, recorded_by=recorded_by, scientist=scientist) + protocol.save(self.client) + assert protocol.code_name + + # Get the protocol by code name + protocol_dict = self.client.get_protocol_by_code(protocol.code_name) + + # Verify the protocol has code_name and recorded_by is correct + assert protocol_dict['codeName'] == protocol.code_name + assert protocol_dict['recordedBy'] == recorded_by + + # Turn the protocol back into a Protocol + updated_protocol = Protocol(ls_thing=LsThing.from_camel_dict(data=protocol_dict)) + updated_protocol.save(self.client) + + # Make sure the put worked by checking the version + protocol_dict['version'] + 1 == updated_protocol._ls_thing.version From 112b088ac1662243fcc38c8d14675661f8eba29e Mon Sep 17 00:00:00 2001 From: Brian Bolt Date: Tue, 22 Oct 2024 15:32:55 -0700 Subject: [PATCH 5/6] Change function name --- acasclient/acasclient.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/acasclient/acasclient.py b/acasclient/acasclient.py index fab1be2..62a5597 100644 --- a/acasclient/acasclient.py +++ b/acasclient/acasclient.py @@ -978,8 +978,8 @@ def get_protocol_by_code(self, protocol_code): resp.raise_for_status() return resp.json() - def get_all_protocols(self): - """Get all protocol code stubs + def get_all_protocol_stubs(self): + """Get all protocol stubs Get an array of all protocols From 692d1bbe46337a7881b3bae6d8046c0d4a641051 Mon Sep 17 00:00:00 2001 From: Brian Bolt Date: Wed, 23 Oct 2024 08:37:05 -0700 Subject: [PATCH 6/6] Remove unused constant --- acasclient/protocol.py | 1 - 1 file changed, 1 deletion(-) diff --git a/acasclient/protocol.py b/acasclient/protocol.py index 9cf5942..97e7dc3 100644 --- a/acasclient/protocol.py +++ b/acasclient/protocol.py @@ -3,7 +3,6 @@ from datetime import datetime # Constants -ACAS_LSTHING = 'ACAS LsThing' META_DATA = 'protocol metadata' LS_TYPE = 'default' LS_KIND = 'default'