diff --git a/pyproject.toml b/pyproject.toml index c65f71b..e5383d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,6 @@ requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" - [project] name = "pyscicat" description = "a python API to communicate with the Scicat API" diff --git a/pyscicat/client.py b/pyscicat/client.py index e866bb5..a5f1dd1 100644 --- a/pyscicat/client.py +++ b/pyscicat/client.py @@ -5,11 +5,11 @@ import logging from datetime import datetime from pathlib import Path -from typing import Optional, Union, cast +from typing import Optional, Type, TypeVar, Union from urllib.parse import quote_plus import requests -from pydantic import BaseModel +from pydantic import BaseModel, TypeAdapter from pyscicat.model import ( Attachment, @@ -18,10 +18,14 @@ DatasetUpdateDto, DerivedDataset, Instrument, + InstrumentUpdateDto, OrigDatablock, Proposal, + ProposalUpdateDto, + PublishedDataObsoleteDto, RawDataset, Sample, + SampleUpdateDto, ) logger = logging.getLogger("pyscicat") @@ -150,6 +154,8 @@ def login(self): ) self._headers["Authorization"] = "Bearer {}".format(self._token) + T = TypeVar("T") + def _call_endpoint( self, cmd: str, @@ -157,7 +163,8 @@ def _call_endpoint( data: Optional[BaseModel] = None, operation: str = "", send_token_as_param: bool = True, - ) -> Optional[Union[dict, list[dict]]]: + model: Type[T] = dict, + ) -> Optional[T]: response = self._send_to_scicat( cmd=cmd, endpoint=endpoint, @@ -165,15 +172,41 @@ def _call_endpoint( send_token_as_param=send_token_as_param, ) - result = response.json() if len(response.content) > 0 else None if not response.ok: - raise ScicatCommError(f"Error in operation {operation}: {result}") - - logger.info( - "Operation '%s' successful%s", - operation, - f"pid={result['pid']}" if result and "pid" in result else "", - ) + if response.status_code == 404: + logger.info( + "Operation '%s' successful, returning None for 404 response", + operation, + ) + return None + raise ScicatCommError( + "Error in operation %s: Response code not 'ok'. Response text: %s" + % (operation, response.text) + ) + if len(response.content) == 0: + logger.info( + "Operation '%s' successful, returning None for empty response", + operation, + ) + return None + adapter = TypeAdapter(model) + result = adapter.validate_json(response.content.decode("utf-8")) + if not isinstance(result, list): + logger.info( + "Operation '%s' successful%s", + operation, + ( + f", pid={getattr(result, 'pid', 'unknown')}" + if hasattr(result, "pid") + else "" + ), + ) + else: + logger.info( + "Operation '%s' successful, returning list of %d items", + operation, + len(result), + ) return result def _call_endpoint_expecting_text( @@ -190,10 +223,12 @@ def _call_endpoint_expecting_text( data=data, send_token_as_param=send_token_as_param, ) - result = response.text if len(response.content) > 0 else None if not response.ok: - raise ScicatCommError(f"Error in operation {operation}: {result}") - logger.info("Operation {operation} successful: %s", result) + raise ScicatCommError( + f"Error in operation {operation}: Response code not 'ok'. Response test: {response.text}" + ) + result = response.text if len(response.content) > 0 else None + logger.info("Operation '{operation}' successful, response: %s", result) return result def datasets_create( @@ -221,14 +256,11 @@ def datasets_create( ScicatCommError Raises if a non-20x message is returned """ - result = cast( - Optional[dict], - self._call_endpoint( - cmd="post", - endpoint="Datasets", - data=dataset, - operation="datasets_create", - ), + result: Optional[dict] = self._call_endpoint( + cmd="post", + endpoint="Datasets", + data=dataset, + operation="datasets_create", ) assert result and "pid" in result and isinstance(result["pid"], str) return result["pid"] @@ -268,14 +300,11 @@ def datasets_update( ScicatCommError Raises if a non-20x message is returned """ - result = cast( - Optional[dict], - self._call_endpoint( - cmd="patch", - endpoint=f"Datasets/{quote_plus(pid)}", - data=dataset, - operation="datasets_update", - ), + result: Optional[dict] = self._call_endpoint( + cmd="patch", + endpoint=f"Datasets/{quote_plus(pid)}", + data=dataset, + operation="datasets_update", ) assert result and "pid" in result and isinstance(result["pid"], str) return result["pid"] @@ -312,16 +341,13 @@ def datasets_origdatablock_create( """ endpoint = f"Datasets/{quote_plus(dataset_id)}/origdatablocks" - result = cast( - Optional[OrigDatablock], - self._call_endpoint( - cmd="post", - endpoint=endpoint, - data=datablockDto, - operation="datasets_origdatablock_create", - ), + result: Optional[OrigDatablock] = self._call_endpoint( + cmd="post", + endpoint=endpoint, + data=datablockDto, + operation="datasets_origdatablock_create", + model=OrigDatablock, ) - assert result is not None return result @@ -357,14 +383,12 @@ def datasets_attachment_create( """ assert isinstance(attachment.datasetId, str) endpoint = f"{datasetType}/{quote_plus(attachment.datasetId)}/attachments" - result = cast( - Optional[Attachment], - self._call_endpoint( - cmd="post", - endpoint=endpoint, - data=attachment, - operation="datasets_attachment_create", - ), + result: Optional[Attachment] = self._call_endpoint( + cmd="post", + endpoint=endpoint, + data=attachment, + operation="datasets_attachment_create", + model=Attachment, ) assert result is not None return result @@ -398,21 +422,18 @@ def samples_create(self, sample: Sample) -> str: ScicatCommError Raises if a non-20x message is returned """ - result = cast( - Optional[dict], - self._call_endpoint( - cmd="post", - endpoint="Samples", - data=sample, - operation="samples_create", - ), + result: Optional[dict] = self._call_endpoint( + cmd="post", + endpoint="Samples", + data=sample, + operation="samples_create", ) assert result and "sampleId" in result and isinstance(result["sampleId"], str) return result["sampleId"] upload_sample = samples_create - def samples_update(self, sample: Sample, sampleId: Optional[str] = None) -> str: + def samples_update(self, sample: Sample) -> str: """Updates an existing sample Parameters @@ -436,24 +457,20 @@ def samples_update(self, sample: Sample, sampleId: Optional[str] = None) -> str: AssertionError Raises if no ID is provided """ - if sampleId is None: - assert sample.sampleId is not None, "sampleId should not be None" - sampleId = sample.sampleId - sample.sampleId = None - - result = cast( - Optional[dict], - self._call_endpoint( - cmd="patch", - endpoint=f"Samples/{quote_plus(sampleId)}", - data=sample, - operation="samples_update", - ), + assert sample.sampleId is not None, "sampleId should not be None" + sampleId = sample.sampleId + update = SampleUpdateDto(**sample.model_dump()) + + result: Optional[dict] = self._call_endpoint( + cmd="patch", + endpoint=f"Samples/{quote_plus(sampleId)}", + data=update, + operation="samples_update", ) assert result and "sampleId" in result and isinstance(result["sampleId"], str) return result["sampleId"] - def instruments_create(self, instrument: Instrument): + def instruments_create(self, instrument: Instrument) -> str: """ Create a new instrument. Note that in SciCat admin rights are required to upload instruments. @@ -476,14 +493,11 @@ def instruments_create(self, instrument: Instrument): ScicatCommError Raises if a non-20x message is returned """ - result = cast( - Optional[dict], - self._call_endpoint( - cmd="post", - endpoint="Instruments", - data=instrument, - operation="instruments_create", - ), + result: Optional[dict] = self._call_endpoint( + cmd="post", + endpoint="Instruments", + data=instrument, + operation="instruments_create", ) assert result and "pid" in result and isinstance(result["pid"], str) return result["pid"] @@ -521,20 +535,18 @@ def instruments_update( if pid is None: assert instrument.pid is not None, "pid should not be None" pid = instrument.pid - instrument.pid = None - result = cast( - Optional[dict], - self._call_endpoint( - cmd="patch", - endpoint=f"Instruments/{quote_plus(pid)}", - data=instrument, - operation="instruments_update", - ), + update = InstrumentUpdateDto(**instrument.model_dump()) + + result: Optional[dict] = self._call_endpoint( + cmd="patch", + endpoint=f"Instruments/{quote_plus(pid)}", + data=update, + operation="instruments_update", ) assert result and "pid" in result and isinstance(result["pid"], str) return result["pid"] - def proposals_create(self, proposal: Proposal): + def proposals_create(self, proposal: Proposal) -> str: """ Create a new proposal. Note that in SciCat admin rights are required to upload proposals. @@ -557,14 +569,11 @@ def proposals_create(self, proposal: Proposal): ScicatCommError Raises if a non-20x message is returned """ - result = cast( - Optional[dict], - self._call_endpoint( - cmd="post", - endpoint="Proposals", - data=proposal, - operation="proposals_create", - ), + result: Optional[dict] = self._call_endpoint( + cmd="post", + endpoint="Proposals", + data=proposal, + operation="proposals_create", ) assert ( result and "proposalId" in result and isinstance(result["proposalId"], str) @@ -603,17 +612,13 @@ def proposals_update( if proposalId is None: assert proposal.proposalId is not None, "proposalId should not be None" proposalId = proposal.proposalId - # TODO updates should allow partial proposals, where all fields are optional. See #58 - proposal.proposalId = None # type: ignore [assignment] - - result = cast( - Optional[dict], - self._call_endpoint( - cmd="patch", - endpoint=f"Proposals/{quote_plus(proposalId)}", - data=proposal, - operation="proposals_update", - ), + + update = ProposalUpdateDto(**proposal.model_dump()) + result: Optional[dict] = self._call_endpoint( + cmd="patch", + endpoint=f"Proposals/{quote_plus(proposalId)}", + data=update, + operation="proposals_update", ) assert ( result and "proposalId" in result and isinstance(result["proposalId"], str) @@ -626,7 +631,7 @@ def datasets_find( limit: int = 25, query_fields: Optional[dict] = None, order_by: Optional[str] = None, - ) -> Optional[list[dict]]: + ) -> list[Dataset]: """ Gets datasets using the fullQuery mechanism of SciCat. This is appropriate for cases where might want paging and cases where you want to perform @@ -661,14 +666,15 @@ def datasets_find( query = f"fields={query_field_str}&limits={limit_str}" - return cast( - Optional[list[dict]], - self._call_endpoint( - cmd="get", - endpoint=f"Datasets/fullquery?{query}", - operation="datasets_find", - ), + result: Optional[list[Dataset]] = self._call_endpoint( + cmd="get", + endpoint=f"Datasets/fullquery?{query}", + operation="datasets_find", + model=list[Dataset], ) + if result is None: + return [] + return result """ find a set of datasets according the full query provided @@ -684,7 +690,7 @@ def datasets_get_many( skip: Optional[int] = None, limit: Optional[int] = None, order_by: Optional[str] = None, - ) -> Optional[list[dict]]: + ) -> list[Dataset]: """ Gets datasets using the simple filter mechanism. You should favor this call when your search is not complex. @@ -733,12 +739,15 @@ def datasets_get_many( filter_str = json.dumps(filter) endpoint = f"Datasets?filter={filter_str}" - return cast( - Optional[list[dict]], - self._call_endpoint( - cmd="get", endpoint=endpoint, operation="datasets_get_many" - ), + result: Optional[list[Dataset]] = self._call_endpoint( + cmd="get", + endpoint=endpoint, + operation="datasets_get_many", + model=list[Dataset], ) + if result is None: + return [] + return result """ find a set of datasets according to the simple filter provided @@ -753,7 +762,7 @@ def samples_get_many( skip: Optional[int] = None, limit: Optional[int] = None, order_by: Optional[str] = None, - ) -> Optional[list[dict]]: + ) -> list[Sample]: """ Gets samples using the simple filter mechanism. This is appropriate when you do not require paging or text search, but @@ -794,14 +803,17 @@ def samples_get_many( limit_str = self._make_limits(skip, limit, order_by) endpoint = f'Samples?filter={{"where":{filter_field_str},"limits":{limit_str}}}' - return cast( - Optional[list[dict]], - self._call_endpoint( - cmd="get", endpoint=endpoint, operation="samples_get_many" - ), + result: Optional[list[Sample]] = self._call_endpoint( + cmd="get", + endpoint=endpoint, + operation="samples_get_many", + model=list[Sample], ) + if result is None: + return [] + return result - def published_data_get_many(self, filter=None) -> Optional[list[dict]]: + def published_data_get_many(self, filter=None) -> list[PublishedDataObsoleteDto]: """ retrieve all the published data using the simple filter mechanism. This is appropriate when you do not require paging or text search, but @@ -824,14 +836,15 @@ def published_data_get_many(self, filter=None) -> Optional[list[dict]]: endpoint = "PublishedData" + (f'?filter={{"where":{filter}}}' if filter else "") - return cast( - Optional[list[dict]], - self._call_endpoint( - cmd="get", - endpoint=endpoint, - operation="published_data_get_many", - ), + result: Optional[list[PublishedDataObsoleteDto]] = self._call_endpoint( + cmd="get", + endpoint=endpoint, + operation="published_data_get_many", + model=list[PublishedDataObsoleteDto], ) + if result is None: + return [] + return result """ find a set of published data according to the simple filter provided @@ -840,7 +853,7 @@ def published_data_get_many(self, filter=None) -> Optional[list[dict]]: get_published_data = published_data_get_many find_published_data = published_data_get_many - def datasets_get_one(self, pid: str) -> Optional[dict]: + def datasets_get_one(self, pid: str) -> Optional[Dataset]: """ Gets dataset with the pid provided. This function has been renamed. Provious name has been maintained for backward compatibility. @@ -851,18 +864,17 @@ def datasets_get_one(self, pid: str) -> Optional[dict]: pid : string pid of the dataset requested. """ - return cast( - Optional[dict], - self._call_endpoint( - cmd="get", - endpoint=f"Datasets/{quote_plus(pid)}", - operation="datasets_get_one", - ), + result: Optional[Dataset] = self._call_endpoint( + cmd="get", + endpoint=f"Datasets/{quote_plus(pid)}", + operation="datasets_get_one", + model=Dataset, ) + return result get_dataset_by_pid = datasets_get_one - def datasets_attachments_get_one(self, pid: str) -> Optional[list[dict]]: + def datasets_attachments_get_one(self, pid: str) -> list[Attachment]: """ Gets attachments for the dataset with the pid provided. @@ -871,16 +883,17 @@ def datasets_attachments_get_one(self, pid: str) -> Optional[list[dict]]: pid : string pid of the dataset requested. """ - return cast( - Optional[list[dict]], - self._call_endpoint( - cmd="get", - endpoint=f"Datasets/{quote_plus(pid)}/attachments", - operation="datasets_get_one", - ), + result: Optional[list[Attachment]] = self._call_endpoint( + cmd="get", + endpoint=f"Datasets/{quote_plus(pid)}/attachments", + operation="datasets_attachments_get_one", + model=list[Attachment], ) + if result is None: + return [] + return result - def datasets_externallinks_get_one(self, pid: str) -> Optional[list[dict]]: + def datasets_externallinks_get_one(self, pid: str) -> list[dict]: """ Gets external links for the dataset with the pid provided. @@ -889,18 +902,19 @@ def datasets_externallinks_get_one(self, pid: str) -> Optional[list[dict]]: pid : string pid of the dataset requested. """ - return cast( - Optional[list[dict]], - self._call_endpoint( - cmd="get", - endpoint=f"Datasets/{quote_plus(pid)}/externallinks", - operation="datasets_get_one", - ), + result: Optional[list[dict]] = self._call_endpoint( + cmd="get", + endpoint=f"Datasets/{quote_plus(pid)}/externallinks", + operation="datasets_externallinks_get_one", + model=list[dict], ) + if result is None: + return [] + return result def instruments_get_one( self, pid: Optional[str] = None, name: Optional[str] = None - ) -> Optional[dict]: + ) -> Optional[Instrument]: """ Get an instrument by pid or by name. If pid is provided it takes priority over name. @@ -929,19 +943,17 @@ def instruments_get_one( endpoint = f"Instruments/findOne?{query}" else: raise ValueError("You must specify instrument pid or name") - - return cast( - Optional[dict], - self._call_endpoint( - cmd="get", - endpoint=endpoint, - operation="instruments_get_one", - ), + result: Optional[Instrument] = self._call_endpoint( + cmd="get", + endpoint=endpoint, + operation="instruments_get_one", + model=Instrument, ) + return result get_instrument = instruments_get_one - def samples_get_one(self, pid: str) -> Optional[dict]: + def samples_get_one(self, pid: str) -> Optional[Sample]: """ Get a sample by pid. This function has been renamed. Previous name has been maintained for backward compatibility. @@ -958,18 +970,17 @@ def samples_get_one(self, pid: str) -> Optional[dict]: dict The sample with the requested pid """ - return cast( - Optional[dict], - self._call_endpoint( - cmd="get", - endpoint=f"Samples/{quote_plus(pid)}", - operation="samples_get_one", - ), + result: Optional[Sample] = self._call_endpoint( + cmd="get", + endpoint=f"Samples/{quote_plus(pid)}", + operation="samples_get_one", + model=Sample, ) + return result get_sample = samples_get_one - def proposals_get_one(self, pid: str) -> Optional[dict]: + def proposals_get_one(self, pid: str) -> Optional[Proposal]: """ Get proposal by pid. This function has been renamed. Previous name has been maintained for backward compatibility. @@ -985,14 +996,17 @@ def proposals_get_one(self, pid: str) -> Optional[dict]: dict The proposal with the requested pid """ - return cast( - Optional[dict], - self._call_endpoint(cmd="get", endpoint=f"Proposals/{quote_plus(pid)}"), + result: Optional[Proposal] = self._call_endpoint( + cmd="get", + endpoint=f"Proposals/{quote_plus(pid)}", + operation="proposals_get_one", + model=Proposal, ) + return result get_proposal = proposals_get_one - def datasets_origdatablocks_get_one(self, pid: str) -> Optional[dict]: + def datasets_origdatablocks_get_one(self, pid: str) -> list[OrigDatablock]: """ Get dataset orig datablocks by dataset pid. This function has been renamed. Previous name has been maintained for backward compatibility. @@ -1008,18 +1022,19 @@ def datasets_origdatablocks_get_one(self, pid: str) -> Optional[dict]: dict The orig_datablocks of the dataset with the requested pid """ - return cast( - Optional[dict], - self._call_endpoint( - cmd="get", - endpoint=f"Datasets/{quote_plus(pid)}/origdatablocks", - operation="datasets_origdatablocks_get_one", - ), + result: Optional[list[OrigDatablock]] = self._call_endpoint( + cmd="get", + endpoint=f"Datasets/{quote_plus(pid)}/origdatablocks", + operation="datasets_origdatablocks_get_one", + model=list[OrigDatablock], ) + if result is None: + return [] + return result get_dataset_origdatablocks = datasets_origdatablocks_get_one - def datasets_delete(self, pid: str) -> Optional[dict]: + def datasets_delete(self, pid: str) -> Optional[Dataset]: """ Delete dataset by pid This function has been renamed. Previous name has been maintained for backward compatibility. @@ -1032,20 +1047,79 @@ def datasets_delete(self, pid: str) -> Optional[dict]: Returns ------- - dict - response from SciCat backend + Dataset deleted or None """ - return cast( - Optional[dict], - self._call_endpoint( - cmd="delete", - endpoint=f"Datasets/{quote_plus(pid)}", - operation="datasets_delete", - ), + result: Optional[Dataset] = self._call_endpoint( + cmd="delete", + endpoint=f"Datasets/{quote_plus(pid)}", + operation="datasets_delete", + model=Dataset, ) + return result delete_dataset = datasets_delete + def samples_delete(self, pid: str) -> Optional[Sample]: + """ + Delete Sample by pid + + Parameters + ---------- + pid : str + The pid of the Sample to be deleted + + Returns + ------- + Sample deleted or None + """ + result: Optional[Sample] = self._call_endpoint( + cmd="delete", + endpoint=f"Samples/{quote_plus(pid)}", + operation="samples_delete", + model=Sample, + ) + return result + + def instruments_delete(self, pid: str) -> Optional[str]: + """ + Delete Instrument by pid + + Parameters + ---------- + pid : str + The pid of the Instrument to be deleted + + Returns + ------- + "{}" or None + """ + result: Optional[object] = self._call_endpoint_expecting_text( + cmd="delete", + endpoint=f"Instruments/{quote_plus(pid)}", + operation="instruments_delete", + ) + return result + + def proposals_delete(self, pid: str) -> Optional[str]: + """ + Delete Instrument by pid + + Parameters + ---------- + pid : str + The pid of the Proposal to be deleted + + Returns + ------- + None + """ + result = self._call_endpoint_expecting_text( + cmd="delete", + endpoint=f"Proposals/{quote_plus(pid)}", + operation="proposals_delete", + ) + return result + def admin_elasticsearch_createindex(self, index: str = "dataset") -> Optional[str]: """ Create an Elasticsearch index by name @@ -1126,15 +1200,13 @@ def admin_elasticsearch_getindex(self, index: str) -> Optional[dict]: dict response from SciCat backend """ - return cast( - Optional[dict], - self._call_endpoint( - cmd="get", - endpoint=f"elastic-search/get-index?index={index}", - operation="admin_elasticsearch_getindex", - send_token_as_param=False, # This endpoint will fail if given access_token as a parameter. - ), + result: Optional[dict] = self._call_endpoint( + cmd="get", + endpoint=f"elastic-search/get-index?index={index}", + operation="admin_elasticsearch_getindex", + send_token_as_param=False, # This endpoint will fail if given access_token as a parameter. ) + return result def admin_elasticsearch_updateindex(self, index: str = "dataset") -> Optional[dict]: """ @@ -1150,15 +1222,13 @@ def admin_elasticsearch_updateindex(self, index: str = "dataset") -> Optional[di dict response from SciCat backend """ - return cast( - Optional[dict], - self._call_endpoint( - cmd="post", - endpoint=f"elastic-search/update-index?index={index}", - operation="admin_elasticsearch_updateindex", - send_token_as_param=False, # This endpoint will fail if given access_token as a parameter. - ), + result: Optional[dict] = self._call_endpoint( + cmd="post", + endpoint=f"elastic-search/update-index?index={index}", + operation="admin_elasticsearch_updateindex", + send_token_as_param=False, # This endpoint will fail if given access_token as a parameter. ) + return result def get_file_size(pathobj: Path): diff --git a/pyscicat/model.py b/pyscicat/model.py index 9a8b8b9..7e0869e 100644 --- a/pyscicat/model.py +++ b/pyscicat/model.py @@ -41,25 +41,44 @@ class User(BaseModel): id: str -class Proposal(Ownable): +class ProposalCommon(BaseModel): """ - Defines the purpose of an experiment and links an experiment to principal investigator and main proposer + The common fields of Proposal and its operations """ - proposalId: str pi_email: Optional[str] = None pi_firstname: Optional[str] = None pi_lastname: Optional[str] = None - email: str firstname: Optional[str] = None lastname: Optional[str] = None - title: Optional[str] = None # required in next backend version + title: Optional[str] = None abstract: Optional[str] = None startTime: Optional[str] = None endTime: Optional[str] = None MeasurementPeriodList: Optional[List[dict]] = ( None # may need updating with the measurement period model ) + metadata: Optional[dict] = None + parentProposalId: Optional[str] = None + type: Optional[str] = None + instrumentIds: Optional[List[str]] = None + + +class ProposalUpdateDto(ProposalCommon): + """ + A proposal in the form sent to the update APIs, where almost everything is optional + """ + + email: Optional[str] = None + + +class Proposal(Ownable, ProposalCommon): + """ + Defines the purpose of an experiment and links an experiment to principal investigator and main proposer + """ + + proposalId: str + email: str class Sample(Ownable): @@ -68,15 +87,28 @@ class Sample(Ownable): Raw datasets should be linked to such sample definitions. """ - sampleId: Optional[str] = None + description: Optional[str] = None + isPublished: bool = False owner: Optional[str] = None + parentSampleId: Optional[str] = None + proposalId: Optional[str] = None + sampleCharacteristics: Optional[dict] = None + sampleId: Optional[str] = None + type: Optional[str] = None + + +class SampleUpdateDto(BaseModel): + """ + A dataset in the form sent to the update APIs, where almost everything is optional + """ + description: Optional[str] = None + isPublished: Optional[bool] = None + owner: Optional[str] = None + parentSampleId: Optional[str] = None + proposalId: Optional[str] = None sampleCharacteristics: Optional[dict] = None - isPublished: bool = False - datasetsId: Optional[str] = None - datasetId: Optional[str] = None - rawDatasetId: Optional[str] = None - derivedDatasetId: Optional[str] = None + type: Optional[str] = None class Job(MongoQueryable): @@ -106,9 +138,18 @@ class Instrument(MongoQueryable): """ pid: Optional[str] = None + customMetadata: Optional[dict] = None name: str uniqueName: str + + +class InstrumentUpdateDto(BaseModel): + """ + Instrument class, most of this is flexibly definable in customMetadata + """ + customMetadata: Optional[dict] = None + name: str class RelationshipClass(BaseModel): @@ -125,20 +166,20 @@ class DatasetLifeCycleClass(BaseModel): Describes the lifecycle of a dataset """ - archivable: Optional[str] = None + archivable: Optional[bool] = None archiveRetentionTime: Optional[str] = None # datetime archiveReturnMessage: Optional[dict] = None archiveStatusMessage: Optional[str] = None dateOfDiskPurging: Optional[str] = None # datetime dateOfPublishing: Optional[str] = None # datetime exportedTo: Optional[str] = None - isOnCentralDisk: Optional[str] = None - publishable: Optional[str] = None + isOnCentralDisk: Optional[bool] = None + publishable: Optional[bool] = None publishedOn: Optional[str] = None # datetime - retrievable: Optional[str] = None + retrievable: Optional[bool] = None retrieveReturnMessage: Optional[dict] = None retrieveStatusMessage: Optional[str] = None - retrieveIntegrityCheck: Optional[str] = None + retrieveIntegrityCheck: Optional[bool] = None storageLocation: Optional[str] = None @@ -194,8 +235,8 @@ class Dataset(DatasetCommon): inputDatasets: Optional[List[str]] = None owner: Optional[str] = None principalInvestigators: Optional[List[str]] = None - proposalIds: Optional[List[str]] = None - sampleIds: Optional[List[str]] = None + proposalIds: Optional[List[Optional[str]]] = None + sampleIds: Optional[List[Optional[str]]] = None scientificMetadataSchema: Optional[str] = None type: DatasetType usedSoftware: Optional[List[str]] = None @@ -243,6 +284,7 @@ class DatasetUpdateDto(DatasetCommon): endTime: Optional[str] = None # datetime inputDatasets: Optional[List[str]] = None owner: Optional[str] = None + ownerGroup: Optional[str] = None principalInvestigator: Optional[str] = None proposalId: Optional[str] = None sampleId: Optional[str] = None @@ -260,7 +302,7 @@ class DataFile(MongoQueryable): path: str size: int - time: Optional[str] = None + time: str # datetime chk: Optional[str] = None uid: Optional[str] = None gid: Optional[str] = None @@ -315,29 +357,51 @@ class Attachment(Ownable): caption: str -class PublishedData: +class PublishedDataCommon: """ - Published Data with registered DOI + The common fields of Published Data and its operations """ - doi: str - affiliation: str - creator: List[str] - publisher: str - publicationYear: int - title: str - url: Optional[str] = None abstract: str + createdAt: str + creator: List[str] dataDescription: str - resourceType: str - numberOfFiles: Optional[int] = None - sizeOfArchive: Optional[int] = None + doi: str pidArray: List[str] - authors: List[str] + publicationYear: int + publisher: str registeredTime: str + resourceType: str status: str thumbnail: Optional[str] = None + title: str + updatedAt: str + url: Optional[str] = None + + +class PublishedData(PublishedDataCommon): + """ + Published Data with registered DOI + """ + + affiliation: str + authors: List[str] createdBy: str + numberOfFiles: Optional[int] = None + sizeOfArchive: Optional[int] = None updatedBy: str - createdAt: str - updatedAt: str + + +class PublishedDataObsoleteDto(PublishedDataCommon): + """ + Published Data DTO as used in the obsolete published data API + """ + + _id: str + affiliation: Optional[str] = None + authors: Optional[List[str]] = None + downloadLink: Optional[str] = None + numberOfFiles: Optional[int] = None + relatedPublications: Optional[List[str]] = None + scicatUser: Optional[str] = None + sizeOfArchive: Optional[int] = None diff --git a/tests/test_pyscicat/test_client.py b/tests/test_pyscicat/test_client.py index 29caa7e..3d34bb1 100644 --- a/tests/test_pyscicat/test_client.py +++ b/tests/test_pyscicat/test_client.py @@ -1,12 +1,10 @@ from datetime import datetime from pathlib import Path -import pytest import requests_mock from pyscicat.client import ( ScicatClient, - ScicatCommError, encode_thumbnail, from_credentials, from_token, @@ -194,25 +192,6 @@ def test_get_nonexistent_dataset(): assert client.datasets_get_one("74") is None -def test_get_dataset_bad_url(): - with requests_mock.Mocker() as mock_request: - mock_request.get( - "http://localhost:3000/api/v100/datasets/53", - status_code=404, - reason="Not Found", - json={ - "error": { - "statusCode": 404, - "name": "Error", - "message": "Cannot GET /api/v100/Datasets/53", - } - }, - ) - client = from_token(base_url="http://localhost:3000/api/v100", token="a_token") - with pytest.raises(ScicatCommError): - client.datasets_get_one("53") - - def test_initializers(): with requests_mock.Mocker() as mock_request: add_mock_requests(mock_request) diff --git a/tests/tests_integration/tests_integration.py b/tests/tests_integration/tests_integration.py index 3234841..6ee2611 100644 --- a/tests/tests_integration/tests_integration.py +++ b/tests/tests_integration/tests_integration.py @@ -64,7 +64,7 @@ def test_get_dataset(): datasets = sci_clie.get_datasets({"ownerGroup": "ingestor"}) assert datasets is not None for dataset in datasets: - assert dataset["ownerGroup"] == "ingestor" + assert dataset.ownerGroup == "ingestor" def test_update_dataset(): @@ -77,7 +77,8 @@ def test_update_dataset(): datasets = sci_clie.get_datasets({}) assert datasets is not None - pid = datasets[0]["pid"] + pid = datasets[0].pid + assert pid is not None payload = DatasetUpdateDto( size=142, owner="slartibartfast",