├── rspace_client ├── tests │ ├── __init__.py │ ├── data │ │ ├── tree │ │ │ ├── .dotfile.txt │ │ │ ├── subdir │ │ │ │ ├── b.png │ │ │ │ ├── a.txt │ │ │ │ └── c.xml │ │ │ └── subdir2 │ │ │ │ ├── x.txt │ │ │ │ ├── y.csv │ │ │ │ └── z.html │ │ ├── freezer.jpg │ │ ├── fish_method.doc │ │ ├── antibodySample150.png │ │ ├── sample_template_by_id.json │ │ ├── calculation_table.html │ │ ├── sample_by_id.json │ │ └── subsample_by_id.json │ ├── temperature_test.py │ ├── quantity_unit_test.py │ ├── base_test.py │ ├── inv_lom_test.py │ ├── field_content_test.py │ ├── validator_test.py │ ├── grid_placement_test.py │ ├── id_test.py │ ├── grid_container_test.py │ ├── elnapi_test.py │ ├── inv_attachment_fs_test.py │ ├── template_builder_test.py │ └── eln_fs_test.py ├── .spyproject │ └── config │ │ └── workspace.ini ├── __init__.py ├── fs_utils.py ├── eln │ ├── dcs.py │ ├── advanced_query_builder.py │ ├── field_content.py │ ├── fs.py │ └── filetree_importer.py ├── utils.py ├── validators.py ├── inv │ ├── quantity_unit.py │ ├── attachment_fs.py │ ├── sample_builder2.py │ └── template_builder.py ├── notebook_sync │ └── links.md └── client_base.py ├── examples ├── rspace_client ├── resources │ ├── fish_method.doc │ └── 2017-05-10_1670091041_CNVts.csv ├── status.py ├── import_word_file.py ├── search_documents_by_form.py ├── export_records.py ├── get_activity.py ├── paging_through_results.py ├── download_attachments.py ├── create_sample.py ├── import_directory.py ├── create_folder_and_notebook.py ├── paging_through_users.py ├── share_documents.py ├── create_document.py ├── create_form.py └── freezer.py ├── .env.example ├── pytest.ini ├── environment.yaml ├── SECURITY.md ├── CONTRIBUTING.md ├── release.sh ├── jupyter_notebooks ├── proteins.csv ├── samples_to_lom.ipynb └── rspace-demo-kaggle-v11.ipynb ├── pyproject.toml ├── .github └── workflows │ └── codeql-and-tests.yml ├── DEVELOPING.md ├── .gitignore ├── CHANGELOG.md └── LICENSE /rspace_client/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/rspace_client: -------------------------------------------------------------------------------- 1 | ../rspace_client -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | RSPACE_URL= 2 | RSPACE_API_KEY= -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = tests -------------------------------------------------------------------------------- /rspace_client/tests/data/tree/.dotfile.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /rspace_client/tests/data/tree/subdir/b.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /rspace_client/tests/data/tree/subdir2/x.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /rspace_client/tests/data/tree/subdir2/y.csv: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /rspace_client/tests/data/tree/subdir2/z.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /rspace_client/tests/data/tree/subdir/a.txt: -------------------------------------------------------------------------------- 1 | some text 2 | -------------------------------------------------------------------------------- /rspace_client/tests/data/tree/subdir/c.xml: -------------------------------------------------------------------------------- 1 | some text 2 | -------------------------------------------------------------------------------- /environment.yaml: -------------------------------------------------------------------------------- 1 | name: rspace-client 2 | channels: 3 | - default 4 | - conda-forge 5 | 6 | dependencies: 7 | - python=3.7 8 | -------------------------------------------------------------------------------- /examples/resources/fish_method.doc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rspace-os/rspace-client-python/HEAD/examples/resources/fish_method.doc -------------------------------------------------------------------------------- /rspace_client/tests/data/freezer.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rspace-os/rspace-client-python/HEAD/rspace_client/tests/data/freezer.jpg -------------------------------------------------------------------------------- /rspace_client/tests/data/fish_method.doc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rspace-os/rspace-client-python/HEAD/rspace_client/tests/data/fish_method.doc -------------------------------------------------------------------------------- /rspace_client/tests/data/antibodySample150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rspace-os/rspace-client-python/HEAD/rspace_client/tests/data/antibodySample150.png -------------------------------------------------------------------------------- /rspace_client/.spyproject/config/workspace.ini: -------------------------------------------------------------------------------- 1 | [workspace] 2 | restore_data_on_startup = True 3 | save_data_on_exit = True 4 | save_history = True 5 | save_non_project_files = False 6 | project_type = empty-project-type 7 | recent_files = ['inv/inv.py', '../examples/freezer.py', 'tests/elnapi_test.py', 'eln/eln.py'] 8 | 9 | [main] 10 | version = 0.2.0 11 | recent_files = [] 12 | 13 | -------------------------------------------------------------------------------- /examples/status.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from __future__ import print_function 4 | 5 | import rspace_client 6 | 7 | client = rspace_client.utils.createELNClient() 8 | 9 | status_response = client.get_status() 10 | print("RSpace API server status:", status_response["message"]) 11 | try: 12 | print("RSpace API server version:", status_response["rspaceVersion"]) 13 | except KeyError: 14 | # RSpace API version is returned only since 1.42 15 | pass 16 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Use this section to tell people about which versions of your project are 6 | currently being supported with security updates. 7 | 8 | | Version | Supported | 9 | | ------- | ------------------ | 10 | | 2.2.x | :white_check_mark: | 11 | | < 2.2 | :x: | 12 | 13 | ## Reporting a Vulnerability 14 | 15 | Please create a new issue outlining: 16 | - the nature of the vulnerability 17 | - its likely impact 18 | -------------------------------------------------------------------------------- /rspace_client/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | RSpace API client for interacting with RSpace ELN and Inventory 3 | """ 4 | from .eln.eln import ELNClient 5 | from .inv.inv import InventoryClient 6 | from .eln.advanced_query_builder import AdvancedQueryBuilder 7 | from .utils import createELNClient 8 | from .eln.field_content import FieldContent 9 | 10 | __all__ = [ 11 | "ELNClient", 12 | "InventoryClient", 13 | "AdvancedQueryBuilder", 14 | "createELNClient", 15 | "FieldContent", 16 | "notebook_sync" 17 | ] 18 | -------------------------------------------------------------------------------- /rspace_client/fs_utils.py: -------------------------------------------------------------------------------- 1 | # This script contains utility functions for working with pyfilesystems. 2 | 3 | def path_to_id(path): 4 | """ 5 | A path is a slash-delimited string of Global Ids. The last element is 6 | the global id of the file or folder. This function extract just the id 7 | of the file or folder, which will be a string-encoding of a number. 8 | """ 9 | global_id = path 10 | if '/' in path: 11 | global_id = path.split('/')[-1] 12 | return global_id[2:] 13 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to rspace-client-python 2 | 3 | First of all thanks for taking the time to read this document. 4 | 5 | We welcome any positive suggestions for improvements, bugfixes or documentation. 6 | 7 | For minor typos / formatting / small documentation improvements please just submit a 8 | pull request. 9 | 10 | For suggestions for new functionality, bug reports or substantive refactorings, 11 | please raise a [Github Issue](https://guides.github.com/features/issues/) 12 | 13 | # Current and previous contributors 14 | 15 | * Karolis Greblikas 16 | * Richard Adams 17 | * Juozas Norkus 18 | * Vaida Plankyte 19 | 20 | 21 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | 4 | old=$1 5 | new=$2 6 | if [[ -z "$old" || -z "$new" ]] ; then 7 | echo "Usage: $0 oldversion newversion" 8 | exit 1 9 | fi 10 | reldate=$(date +%Y-%m-%d) 11 | echo "Updating changelog" 12 | sed -ibk -e "s/Unreleased/$new $reldate/" CHANGELOG.md 13 | 14 | echo "updating README and pyproject.toml" 15 | sed -ibk -e "s/==$old/==$new/" README.md 16 | sed -ibk -e "s/\"$old\"/\"$new\"/" pyproject.toml 17 | 18 | black rspace_client 19 | echo "committing and tagging" 20 | git ci -am "Release $new" 21 | git tag -m"$new" -a "v$new" 22 | 23 | echo "now run poetry shell, poetry build && poetry publish and push to Github" 24 | -------------------------------------------------------------------------------- /rspace_client/eln/dcs.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Created on Thu Nov 4 21:25:03 2021 5 | 6 | @author: richard 7 | """ 8 | import enum 9 | 10 | 11 | class DocumentCreationStrategy(enum.Enum): 12 | """ 13 | Enum of choices of how to create summary documents containing links to uploaded 14 | directories of files. Choices are: 15 | 16 | - DOC_PER_FILE : Create a summary doc for every file uploaded 17 | - SUMMARY_DOC: Create a single ddocument with links to every uploaded file 18 | - DOC_PER_SUBFOLDER: Create a summary document for every subfolder of files 19 | """ 20 | 21 | DOC_PER_FILE = 1 22 | 23 | DOC_PER_SUBFOLDER = 2 24 | 25 | SUMMARY_DOC = 3 26 | -------------------------------------------------------------------------------- /examples/import_word_file.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from __future__ import print_function 4 | import rspace_client 5 | import os 6 | 7 | # Parse command line parameters 8 | client = rspace_client.utils.createELNClient() 9 | folder_id = client.create_folder("Word Import Folder")["id"] 10 | print( 11 | "Importing an example word file in 'resources' folder : {}".format( 12 | "fish_method.doc" 13 | ) 14 | ) 15 | with open(os.sep.join(["resources", "fish_method.doc"]), "rb") as f: 16 | rspaceDoc = client.import_word(f, folder_id) 17 | print( 18 | 'File "{}" was imported to folder {} as {} ({})'.format( 19 | f.name, folder_id, rspaceDoc["name"], rspaceDoc["globalId"] 20 | ) 21 | ) 22 | -------------------------------------------------------------------------------- /rspace_client/utils.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import rspace_client.eln.eln as eln 3 | import rspace_client.inv.inv as inv 4 | 5 | 6 | def _parse_args(): 7 | parser = argparse.ArgumentParser() 8 | parser.add_argument( 9 | "server", 10 | help="RSpace server URL (for example, https://community.researchspace.com)", 11 | type=str, 12 | ) 13 | parser.add_argument( 14 | "apiKey", help="RSpace API key can be found on 'My Profile'", type=str 15 | ) 16 | args = parser.parse_args() 17 | return args 18 | 19 | 20 | def createELNClient(): 21 | """ 22 | Parses command line arguments: server, apiKey 23 | """ 24 | args = _parse_args() 25 | client = eln.ELNClient(args.server, args.apiKey) 26 | return client 27 | 28 | 29 | def createInventoryClient(): 30 | """ 31 | Parses command line arguments: server, apiKey 32 | """ 33 | args = _parse_args() 34 | client = inv.InventoryClient(args.server, args.apiKey) 35 | return client 36 | -------------------------------------------------------------------------------- /examples/search_documents_by_form.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from __future__ import print_function 4 | 5 | import json 6 | import rspace_client 7 | import sys 8 | 9 | client = rspace_client.utils.createELNClient() 10 | 11 | print("Form ID to search for (for example, FM123)?") 12 | form_id = sys.stdin.readline().strip() 13 | 14 | advanced_query = json.dumps( 15 | {"operator": "and", "terms": [{"query": form_id, "queryType": "form"}]} 16 | ) 17 | 18 | # Alternatively, the same advanced query can be constructed 19 | 20 | advanced_query = ( 21 | rspace_client.AdvancedQueryBuilder(operator="and") 22 | .add_term(form_id, rspace_client.AdvancedQueryBuilder.QueryType.FORM) 23 | .get_advanced_query() 24 | ) 25 | 26 | response = client.get_documents_advanced_query(advanced_query) 27 | 28 | print("Found answers:") 29 | for document in response["documents"]: 30 | print("Answer name:", document["name"]) 31 | document_response = client.get_document_csv(document["id"]) 32 | print(document_response) 33 | -------------------------------------------------------------------------------- /examples/export_records.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | import rspace_client 5 | 6 | # Parse command line parameters 7 | # Parse command line parameters 8 | client = rspace_client.utils.createELNClient() 9 | 10 | 11 | # Export 1 or more records in XML format 12 | # print('Export archive was downloaded to:', client.download_export('xml', 'user', file_path='/tmp')) 13 | print("Comma-separated list of Item IDs to export (for example, 123,456)?") 14 | item_id = sys.stdin.readline().strip() 15 | item_ids = item_id.split(",") 16 | 17 | print("Include previous versions (y/n/)? (RSpace 1.69.51 or later)") 18 | include_previous_versions = sys.stdin.readline().strip() 19 | includePrev = False 20 | if include_previous_versions.lower() == "y": 21 | includePrev = True 22 | 23 | download_file = "selection" + item_ids[0] + ".zip" 24 | print("Exporting..checking progress in 30s") 25 | client.download_export_selection( 26 | file_path=download_file, 27 | include_revision_history=includePrev, 28 | export_format="xml", 29 | item_ids=[item_id], 30 | ) 31 | print("Done- downloaded to {}".format(download_file)) 32 | -------------------------------------------------------------------------------- /examples/get_activity.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from __future__ import print_function 4 | import rspace_client 5 | import sys 6 | from datetime import date, timedelta 7 | 8 | # Parse command line parameters 9 | # Parse command line parameters 10 | client = rspace_client.utils.createELNClient() 11 | 12 | # Get all activity related to documents being created or modified last week 13 | date_from = date.today() - timedelta(days=7) 14 | response = client.get_activity( 15 | date_from=date_from, domains=["RECORD"], actions=["CREATE", "WRITE"] 16 | ) 17 | 18 | print( 19 | "All activity related to documents being created or modified from {} to now:".format( 20 | date_from.isoformat() 21 | ) 22 | ) 23 | for activity in response["activities"]: 24 | print(activity) 25 | 26 | # Get all activity for a document 27 | print("Document ID to get all activity for (for example, SD123456)?") 28 | document_id = sys.stdin.readline().strip() 29 | response = client.get_activity(global_id=document_id) 30 | 31 | print("Activities for document {}:".format(document_id)) 32 | for activity in response["activities"]: 33 | print(activity) 34 | -------------------------------------------------------------------------------- /examples/paging_through_results.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from __future__ import print_function 4 | import rspace_client 5 | 6 | 7 | def print_document_names(response): 8 | print("Documents in response:") 9 | for document in response["documents"]: 10 | print(document["name"], document["id"], document["lastModified"]) 11 | 12 | 13 | # Parse command line parameters 14 | client = rspace_client.utils.createELNClient() 15 | 16 | # Simple search 17 | response = client.get_documents() 18 | print_document_names(response) 19 | 20 | # Creation date (documents created between 2017-01-01 and 2017-12-01 21 | advanced_query = ( 22 | rspace_client.AdvancedQueryBuilder() 23 | .add_term( 24 | "2017-01-01;2017-12-01", rspace_client.AdvancedQueryBuilder.QueryType.CREATED 25 | ) 26 | .get_advanced_query() 27 | ) 28 | 29 | response = client.get_documents_advanced_query(advanced_query) 30 | print_document_names(response) 31 | while client.link_exists(response, "next"): 32 | print("Retrieving next page...") 33 | response = client.get_link_contents(response, "next") 34 | print_document_names(response) 35 | -------------------------------------------------------------------------------- /jupyter_notebooks/proteins.csv: -------------------------------------------------------------------------------- 1 | protein 2 | P12235 3 | P12235 4 | O00555 5 | O00555 6 | Q96F25 7 | Q8TD30 8 | Q01484 9 | Q01484 10 | Q01484 11 | Q01484 12 | P41181 13 | Q6ZSZ5 14 | P50993 15 | P50993 16 | P30049 17 | P30049 18 | Q9H165 19 | Q9H165 20 | Q9H165 21 | Q9H165 22 | Q9H165 23 | Q9H165 24 | Q9H165 25 | Q9H165 26 | Q9H165 27 | Q13873 28 | Q96C12 29 | Q96C12 30 | Q96C12 31 | Q96C12 32 | Q96C12 33 | Q96C12 34 | Q96C12 35 | Q96C12 36 | Q96C12 37 | Q96C12 38 | Q96C12 39 | Q8N100 40 | P53701 41 | P53701 42 | Q92985 43 | O60260 44 | P28062 45 | Q99836 46 | Q99836 47 | P00533 48 | P40763 49 | P40763 50 | P40763 51 | P40763 52 | P40763 53 | P15884 54 | P15884 55 | P15884 56 | P15884 57 | P01138 58 | P04629 59 | P04629 60 | P49768 61 | P49810 62 | Q05586 63 | Q05586 64 | Q05586 65 | Q05586 66 | Q9ULV1 67 | Q9ULV1 68 | Q9ULV1 69 | O14653 70 | Q13093 71 | Q13093 72 | B2RXF5 73 | P16471 74 | P23945 75 | P38484 76 | Q53EZ4 77 | P48165 78 | P61266 79 | Q63ZY3 80 | Q63ZY3 81 | Q13426 82 | O15259 83 | P63252 84 | P63252 85 | P63252 86 | P63252 87 | P63252 88 | P78527 89 | Q9HBA0 90 | Q9HBE5 91 | Q9HBE5 92 | O60313 93 | O60313 94 | O60313 95 | O60313 96 | O60313 97 | -------------------------------------------------------------------------------- /rspace_client/tests/temperature_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Created on Mon Oct 4 19:10:42 2021 5 | 6 | @author: richard 7 | """ 8 | import unittest 9 | from rspace_client.inv.inv import StorageTemperature, TemperatureUnit 10 | 11 | 12 | class TemperatureUnitTest(unittest.TestCase): 13 | def test_temp_str(self): 14 | temp = StorageTemperature(23, TemperatureUnit.CELSIUS) 15 | self.assertEqual("23 TemperatureUnit.CELSIUS", str(temp)) 16 | 17 | def test_temp_repr(self): 18 | temp = StorageTemperature(23, TemperatureUnit.CELSIUS) 19 | self.assertEqual( 20 | "StorageTemperature (23, )", repr(temp) 21 | ) 22 | 23 | def test_temp_eq(self): 24 | temp = StorageTemperature(23, TemperatureUnit.CELSIUS) 25 | temp2 = StorageTemperature(23, TemperatureUnit.CELSIUS) 26 | self.assertEqual(temp, temp2) 27 | o = StorageTemperature(23, TemperatureUnit.KELVIN) 28 | self.assertNotEqual(temp, o) 29 | o = StorageTemperature(24, TemperatureUnit.CELSIUS) 30 | self.assertNotEqual(temp, o) 31 | -------------------------------------------------------------------------------- /examples/download_attachments.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from __future__ import print_function 4 | import rspace_client 5 | import sys 6 | 7 | # Parse command line parameters 8 | # Parse command line parameters 9 | client = rspace_client.utils.createELNClient() 10 | 11 | print("Document ID to search for (for example, numeric id 123 or global ID SD123)?") 12 | document_id = sys.stdin.readline().strip() 13 | 14 | try: 15 | response = client.get_document(doc_id=document_id) 16 | 17 | file_found = False 18 | for field in response["fields"]: 19 | for file in field["files"]: 20 | download_metadata_link = client.get_link_contents(file, "self") 21 | filename = "/tmp/" + download_metadata_link["name"] 22 | print("Downloading to file", filename) 23 | client.download_link_to_file( 24 | client.get_link(download_metadata_link, "enclosure"), filename 25 | ) 26 | file_found = True 27 | 28 | if not file_found: 29 | print("There are no attached files.") 30 | except ValueError as e: 31 | print(e) 32 | except rspace_client.Client.ApiError as e: 33 | print(e) 34 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "rspace-client" 3 | version = "2.6.2" 4 | description = "A client for calling RSpace ELN and Inventory APIs" 5 | license = "Apache-2.0" 6 | authors = ["Research Innovations Ltd "] 7 | maintainers = [ 8 | "Matthias Kowalski ", 9 | "Robert Lamacraft " 10 | ] 11 | readme = "README.md" 12 | repository = "https://github.com/rspace-os/rspace-client-python" 13 | documentation = "https://github.com/rspace-os/rspace-client-python" 14 | keywords = ["RSpace", "ResearchSpace", "ELN", "Inventory"] 15 | classifiers = [ 16 | "Development Status :: 4 - Beta", 17 | "Intended Audience :: Developers", 18 | "Intended Audience :: Science/Research", 19 | "Topic :: Software Development :: Libraries", 20 | ] 21 | exclude = ["examples/*", "rspace_client/tests/*"] 22 | 23 | [tool.poetry.dependencies] 24 | python = "^3.9" 25 | requests = "^2.25.1" 26 | beautifulsoup4 = "^4.9.3" 27 | fs = "^2.4.16" 28 | 29 | [tool.poetry.group.dev.dependencies] 30 | python-dotenv = "^1.1.1" 31 | black = "^21.6b0" 32 | pytest = "^6.2.4" 33 | 34 | [build-system] 35 | requires = ["poetry-core>=1.0.0"] 36 | build-backend = "poetry.core.masonry.api" 37 | -------------------------------------------------------------------------------- /examples/create_sample.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Created on Mon Oct 18 20:23:16 2021 5 | 6 | @author: richard 7 | """ 8 | import rspace_client 9 | from rspace_client.inv.inv import Barcode, BarcodeFormat 10 | 11 | inv_api = rspace_client.utils.createInventoryClient() 12 | 13 | print("Creating a sample") 14 | 15 | sample = inv_api.create_sample("My first sample") 16 | print( 17 | f"Sample with id {sample['id']} was created with {len(sample['subSamples'])} subsamples" 18 | ) 19 | 20 | 21 | print("Creating a sample with barcodes") 22 | 23 | barcodes = [ 24 | Barcode( 25 | data="https://researchspace.com", 26 | format=BarcodeFormat.BARCODE, 27 | description="ResearchSpace" 28 | ), 29 | Barcode( 30 | data="https://www.wikipedia.org/", 31 | format=BarcodeFormat.BARCODE, 32 | description="Wikipedia" 33 | ) 34 | ] 35 | 36 | # Convert to dicts 37 | barcode_dicts = [barcode.to_dict() for barcode in barcodes] 38 | sample_with_barcode = inv_api.create_sample("sampleWithBarcodes", barcodes=barcodes) 39 | 40 | print( 41 | f"Sample with id {sample_with_barcode['id']} was created with " 42 | f"{len(sample_with_barcode['subSamples'])} subsamples and " 43 | f"{len(sample_with_barcode.get('barcodes', []))} barcodes" 44 | ) -------------------------------------------------------------------------------- /examples/import_directory.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys, pprint 4 | import rspace_client 5 | from rspace_client.eln import filetree_importer 6 | from rspace_client.eln.dcs import DocumentCreationStrategy as DCS 7 | 8 | # Parse command line parameters 9 | # Parse command line parameters 10 | client = rspace_client.utils.createELNClient() 11 | 12 | 13 | # Import a directory of files 14 | print( 15 | """ 16 | This script recursively uploads a directory of files from your computer, then 17 | creates RSpace documents with links to the files. You can choose from one of three 18 | strategies as to how the documents are created. 19 | """ 20 | ) 21 | print("Please input an absolute path to a directory of files to upload") 22 | path = sys.stdin.readline().strip() 23 | filetree_importer.assert_is_readable_dir(path) 24 | 25 | print("Please choose 1,2 or 3 for how summary documents should be made:") 26 | print("One document per file: 1") 27 | print("One summary document per subfolder: 2") 28 | print("One summary document for all files: 3") 29 | 30 | dcs = int(sys.stdin.readline().strip()) 31 | 32 | print("uploading files....") 33 | result = client.import_tree(path, doc_creation=DCS(dcs)) 34 | print( 35 | "Results show success/failure and a mapping of the files to an RSpace folders and documents" 36 | ) 37 | pprint.pp(result) 38 | -------------------------------------------------------------------------------- /.github/workflows/codeql-and-tests.yml: -------------------------------------------------------------------------------- 1 | name: CodeQL and Unit Test 2 | 3 | on: 4 | pull_request: 5 | branches: [ master ] 6 | workflow_dispatch: # allow manual trigger 7 | 8 | jobs: 9 | analyze: 10 | name: CodeQL Analyze 11 | runs-on: ubuntu-latest 12 | permissions: 13 | actions: read 14 | contents: read 15 | security-events: write 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | language: [ 'python' ] 20 | 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v3 24 | 25 | - name: Initialize CodeQL 26 | uses: github/codeql-action/init@v3 27 | with: 28 | languages: ${{ matrix.language }} 29 | 30 | - name: Autobuild 31 | uses: github/codeql-action/autobuild@v3 32 | 33 | - name: Perform CodeQL Analysis 34 | uses: github/codeql-action/analyze@v3 35 | 36 | test: 37 | name: Unit Test 38 | runs-on: ubuntu-latest 39 | needs: analyze 40 | 41 | steps: 42 | - name: Checkout repository 43 | uses: actions/checkout@v3 44 | 45 | - name: Set up Poetry 46 | run: | 47 | curl -sSL https://install.python-poetry.org | python3 - 48 | echo 'export PATH="$HOME/.local/bin:$PATH"' >> $GITHUB_ENV 49 | 50 | - name: Install dependencies 51 | run: poetry install 52 | 53 | - name: Run Unit Test 54 | run: poetry run pytest rspace_client/tests 55 | -------------------------------------------------------------------------------- /rspace_client/tests/quantity_unit_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Created on Mon Oct 4 19:10:42 2021 5 | 6 | @author: richard 7 | """ 8 | import unittest 9 | from rspace_client.inv.quantity_unit import QuantityUnit 10 | from rspace_client.inv.inv import Quantity 11 | 12 | 13 | class QuantityUnitTest(unittest.TestCase): 14 | def test_list_all(self): 15 | self.assertEqual(17, len(QuantityUnit.unit_labels())) 16 | 17 | def test_quantity_exists(self): 18 | self.assertTrue(QuantityUnit.is_supported_unit("ml")) 19 | self.assertFalse(QuantityUnit.is_supported_unit("W")) 20 | 21 | def test_of(self): 22 | self.assertEqual(17, QuantityUnit.of("g/l")["id"]) 23 | self.assertRaises(ValueError, QuantityUnit.of, "W") 24 | 25 | def test_str(self): 26 | qu = QuantityUnit.of("ml") 27 | amount = Quantity(23, qu) 28 | self.assertEqual("23 ml", str(amount)) 29 | 30 | def test_repr(self): 31 | qu = QuantityUnit.of("ml") 32 | amount = Quantity(23, qu) 33 | self.assertEqual("Quantity (23, 'ml')", repr(amount)) 34 | 35 | def test_qu_eq(self): 36 | qu = QuantityUnit.of("ml") 37 | amount = Quantity(23, qu) 38 | amount2 = Quantity(23, qu) 39 | self.assertEqual(amount, amount2) 40 | 41 | amount3 = Quantity(23.0001, qu) 42 | amount4 = Quantity(23.0002, qu) 43 | self.assertEqual(amount3, amount4) 44 | -------------------------------------------------------------------------------- /DEVELOPING.md: -------------------------------------------------------------------------------- 1 | ## Development 2 | 3 | Python 3.7 or later is required. We aim to support only active versions of Python. 4 | 5 | ### Setup 6 | 7 | Create a virtual environment with python 3.7 installed. If you use `conda`, you can do this 8 | 9 | ``` 10 | conda env create -f environment.yaml 11 | conda activate rspace-client 12 | ``` 13 | 14 | We use `poetry` for dependency management. [Install Poetry] (https://python-poetry.org/docs/#installation) 15 | 16 | From this directory, run 17 | 18 | `poetry install` 19 | 20 | to install all project dependencies into your virtual environment. 21 | 22 | ### Running tests 23 | 24 | Tests are a mixture of plain unit tests and integration tests making calls to an RSpace server. 25 | To run all tests, set these environment variables,replacing with your own values 26 | 27 | ``` 28 | bash> export RSPACE_URL=https:/ 29 | bash> export RSPACE_API_KEY=abcdefgh... 30 | ``` 31 | 32 | If these aren't set, integration tests will be skipped. 33 | 34 | Tests can be invoked: 35 | 36 | ``` 37 | poetry run pytest rspace_client/tests 38 | ``` 39 | 40 | They should be run with a new RSpace account that does not belong to any groups. 41 | 42 | ### Writing Tests 43 | 44 | All top-level methods for use by client code should be unit-tested. 45 | 46 | RSpace can be run on Docker on a developer machine, providing access to a sandbox environment. 47 | 48 | 49 | ### Making a release 50 | 51 | - Get a clean test run 52 | - Update version in README.md and pyproject.toml 53 | - update changelog to include the new version and required RSpace version 54 | - tag with syntax like 'v2.2.0' 55 | - Build and submit to Pypi using `poetry publish` 56 | -------------------------------------------------------------------------------- /rspace_client/tests/base_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Created on Sat Oct 2 21:55:26 2021 5 | 6 | @author: richard 7 | """ 8 | import unittest 9 | import os 10 | 11 | import string 12 | import random 13 | import pytest 14 | 15 | from dotenv import load_dotenv 16 | load_dotenv() 17 | 18 | RSPACE_URL_ENV = "RSPACE_URL" 19 | RSPACE_APIKEY_ENV = "RSPACE_API_KEY" 20 | 21 | 22 | def get_datafile(file_or_dir_name: str) -> str: 23 | """ 24 | Gets full file path to a file in the 'data' test folder 25 | """ 26 | return os.path.join(os.path.dirname(__file__), "data", file_or_dir_name) 27 | 28 | 29 | def get_any_datafile(): 30 | return get_datafile("fish_method.doc") 31 | 32 | 33 | def random_string(length=10): 34 | """ 35 | Creates random lowercase string 36 | """ 37 | letters = string.ascii_lowercase 38 | return "".join(random.choice(letters) for i in range(length)) 39 | 40 | 41 | def random_string_gen(length=10): 42 | while True: 43 | yield random_string(length) 44 | 45 | 46 | class BaseApiTest(unittest.TestCase): 47 | def assertClientCredentials(self): 48 | if os.getenv(RSPACE_URL_ENV) is not None: 49 | self.rspace_url = os.getenv(RSPACE_URL_ENV) 50 | if os.getenv(RSPACE_APIKEY_ENV) is not None: 51 | self.rspace_apikey = os.getenv(RSPACE_APIKEY_ENV) 52 | 53 | if ( 54 | not hasattr(self, "rspace_url") 55 | or not hasattr(self, "rspace_apikey") 56 | or len(self.rspace_url) == 0 57 | or len(self.rspace_apikey) == 0 58 | ): 59 | pytest.skip("Skipping API test as URL/Key are not defined") 60 | -------------------------------------------------------------------------------- /rspace_client/tests/inv_lom_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Created on Tue Oct 19 21:41:03 2021 5 | 6 | @author: richard 7 | """ 8 | import rspace_client.tests.base_test as base 9 | from rspace_client.inv import inv 10 | from rspace_client.eln import eln 11 | 12 | 13 | class LomApiTest(base.BaseApiTest): 14 | def setUp(self): 15 | """ 16 | Skips tests if aPI client credentials are missing 17 | """ 18 | self.assertClientCredentials() 19 | self.invapi = inv.InventoryClient(self.rspace_url, self.rspace_apikey) 20 | self.elnapi = eln.ELNClient(self.rspace_url, self.rspace_apikey) 21 | 22 | def test_create_lom(self): 23 | doc = self.elnapi.create_document("with_lom") 24 | field_id = doc["fields"][0]["id"] 25 | item_to_add = self.invapi.create_sample("s1") 26 | lom = self.invapi.create_list_of_materials( 27 | field_id, 28 | "lom1", 29 | item_to_add, 30 | item_to_add["subSamples"][0], 31 | description="description", 32 | ) 33 | self.assertEqual("lom1", lom["name"]) 34 | self.assertEqual("description", lom["description"]) 35 | self.assertEqual(2, len(lom["materials"])) 36 | created_id = lom["id"] 37 | lom_for_doc = self.invapi.get_list_of_materials_for_document(doc["globalId"]) 38 | self.assertEqual(created_id, lom_for_doc[0]["id"]) 39 | 40 | lom_for_field = self.invapi.get_list_of_materials_for_field(field_id) 41 | self.assertEqual(created_id, lom_for_field[0]["id"]) 42 | 43 | lom_by_id = self.invapi.get_list_of_materials(lom["id"]) 44 | self.assertEqual(created_id, lom_by_id["id"]) 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | poetry.lock 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | env/ 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *,cover 48 | .hypothesis/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # IPython Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | .project 77 | .pydevproject 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | venv/ 87 | ENV/ 88 | 89 | # Spyder project settings 90 | .spyderproject 91 | 92 | # Rope project settings 93 | .ropeproject 94 | .idea/ 95 | .vscode/ 96 | #*.log 97 | examples/deletedUserslog.txt 98 | .spyproject/ 99 | .spyproject/ 100 | rspace_client/.spyproject/ 101 | rspace_client/.spyproject/config/workspace.ini 102 | rspace_client/.spyproject/config/workspace.ini 103 | 104 | # mac os 105 | .DS_Store 106 | -------------------------------------------------------------------------------- /examples/create_folder_and_notebook.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from __future__ import print_function 4 | import rspace_client 5 | 6 | 7 | def print_forms(response): 8 | for form in response["forms"]: 9 | print(form["globalId"], form["name"]) 10 | 11 | 12 | # Parse command line parameters 13 | client = rspace_client.utils.createELNClient() 14 | 15 | 16 | print("Creating a folder:") 17 | response = client.create_folder("Testing Folder") 18 | print("Folder has been created:", response["globalId"], response["name"]) 19 | 20 | print("Creating a notebook in the folder:") 21 | response = client.create_folder( 22 | "Testing Notebook", parent_folder_id=response["globalId"], notebook=True 23 | ) 24 | print("Notebook has been created:", response["globalId"], response["name"]) 25 | 26 | print("Getting information about the parent folder:") 27 | response = client.get_folder(response["parentFolderId"]) 28 | print(response["globalId"], response["name"]) 29 | 30 | print("Creating a folder to delete:") 31 | response = client.create_folder("Testing Folder to delete") 32 | folder_id = response["globalId"] 33 | print( 34 | 'Folder id [{}] with name "{}" has been created:'.format( 35 | folder_id, response["name"] 36 | ) 37 | ) 38 | print("Deleting folder") 39 | response = client.delete_folder(response["globalId"]) 40 | print("deleted folder {}".format(folder_id)) 41 | 42 | print("Listing all contents of home folder") 43 | response = client.list_folder_tree() 44 | print("Found {} items ".format(response["totalHits"])) 45 | for item in response["records"]: 46 | print("{}, (id = {}), type = {}".format(item["name"], item["id"], item["type"])) 47 | 48 | print("Listing only documents and notebooks...") 49 | response = client.list_folder_tree(None, ["document", "notebook"]) 50 | print("Found {} items ".format(response["totalHits"])) 51 | for item in response["records"]: 52 | print("{}, (id = {}), type = {}".format(item["name"], item["id"], item["type"])) 53 | -------------------------------------------------------------------------------- /rspace_client/tests/field_content_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Created on Mon Jun 7 08:38:55 2021 5 | 6 | @author: richard 7 | """ 8 | 9 | 10 | import rspace_client.eln as cli 11 | import rspace_client.tests.base_test as base_test 12 | import unittest 13 | 14 | 15 | class TestBasicFunction(unittest.TestCase): 16 | def setUp(self): 17 | dataDir = base_test.get_datafile("calculation_table.html") 18 | with open(dataDir) as f: 19 | text = f.read() 20 | field = cli.field_content.FieldContent(text) 21 | self.field = field 22 | 23 | def test_read_table(self): 24 | self.assertEqual(1, len(self.field.get_datatables())) 25 | 26 | def test_parse_table(self): 27 | array2d = self.field.get_datatables()[0] 28 | self.assertEqual(12, len(array2d)) 29 | ## all rows have same number of columns 30 | self.assertEqual(1, len(set((len(row) for row in array2d)))) 31 | 32 | EXPECTED_COL_COUNT = 4 33 | self.assertEqual(EXPECTED_COL_COUNT, len(array2d[0])) 34 | 35 | def test_filter_table(self): 36 | matching_tables = self.field.get_datatables(search_term="Nov") 37 | self.assertEqual(1, len(matching_tables)) 38 | 39 | non_matching_tables = self.field.get_datatables(search_term="XXX") 40 | self.assertEqual(0, len(non_matching_tables)) 41 | 42 | def test_include_empty_cols(self): 43 | tables = self.field.get_datatables(ignore_empty_columns=False) 44 | self.assertEqual(12, len(tables[0][0])) 45 | 46 | def test_include_empty_rows(self): 47 | tables = self.field.get_datatables(ignore_empty_rows=False) 48 | self.assertEqual(13, len(tables[0])) 49 | 50 | def test_get_text(self): 51 | htmlStr = "
Some text

in a para

after para
" 52 | field = cli.field_content.FieldContent(htmlStr) 53 | self.assertEqual("Some text in a para after para", field.get_text()) 54 | -------------------------------------------------------------------------------- /rspace_client/eln/advanced_query_builder.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | import json 3 | 4 | 5 | class AdvancedQueryBuilder: 6 | """ 7 | AdvancedQueryBuilder helps to build an advanced query for /documents API endpoint. 8 | """ 9 | 10 | class QueryType(Enum): 11 | """ 12 | Lists all query types available in /documents API endpoint. More information on 13 | https://community.researchspace.com/public/apiDocs (or your own instance's /public/apiDocs). 14 | """ 15 | 16 | GLOBAL = "global" 17 | FULL_TEXT = "fullText" 18 | TAG = "tag" 19 | NAME = "name" 20 | CREATED = "created" 21 | LAST_MODIFIED = "lastModified" 22 | FORM = "form" 23 | ATTACHMENT = "attachment" 24 | 25 | def __init__(self, operator="and"): 26 | """ 27 | :param operator: either 'and' or 'or' 28 | """ 29 | self.operand = operator 30 | self.terms = [] 31 | 32 | def add_term(self, query, query_type): 33 | """ 34 | Adds an additional search term to the query. 35 | :param query: query depending on the query_type can be either a text, date or Global ID 36 | :param query_type: query type from the QueryType enum 37 | :return: self 38 | """ 39 | if not isinstance(query_type, AdvancedQueryBuilder.QueryType): 40 | raise TypeError( 41 | "query_type must be an instance of QueryType (for example, QueryType.GLOBAL)" 42 | ) 43 | self.terms.append({"query": query, "queryType": query_type.value}) 44 | return self 45 | 46 | def get_advanced_query(self): 47 | """ 48 | Builds an advanced query. 49 | :return: JSON representation of the built advanced query 50 | """ 51 | return json.dumps({"operator": self.operand, "terms": self.terms}) 52 | 53 | def __str__(self): 54 | """ 55 | :return: JSON representation of the built advanced query 56 | """ 57 | return self.get_advanced_query() 58 | -------------------------------------------------------------------------------- /rspace_client/tests/validator_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Created on Mon Oct 4 19:10:42 2021 5 | 6 | @author: richard 7 | """ 8 | import unittest 9 | import datetime as dt 10 | import rspace_client.validators as v 11 | 12 | 13 | class ValidatorTest(unittest.TestCase): 14 | def test_validate_string(self): 15 | validator = v.String() 16 | validator.validate("hello") 17 | validator.validate("") 18 | validator.validate("") 19 | self.assertRaises(TypeError, validator.validate, 22) 20 | self.assertRaises(TypeError, validator.validate, dict()) 21 | 22 | def test_validate_number(self): 23 | validator = v.Number() 24 | validator.validate(12) 25 | validator.validate(-3.4) 26 | validator.validate(3.2e10) 27 | self.assertRaises(TypeError, validator.validate, "Hello") 28 | self.assertRaises(TypeError, validator.validate, dict()) 29 | 30 | def test_validate_all_of(self): 31 | validator = v.AllOf(["a", "b", "c"]) 32 | validator.validate(["a"]) 33 | validator.validate(["a", "c"]) 34 | validator.validate(["a", "b", "c"]) 35 | validator.validate([]) # no choice is fine 36 | 37 | self.assertRaises(TypeError, validator.validate, 2.3) 38 | self.assertRaises(TypeError, validator.validate, {}) 39 | 40 | def test_validate_one_of(self): 41 | validator = v.OneOf(["a", "b", "c"]) 42 | validator.validate("a") 43 | self.assertRaises(TypeError, validator.validate, 2.3) 44 | self.assertRaises(TypeError, validator.validate, ["a"]) 45 | self.assertRaises(TypeError, validator.validate, []) 46 | self.assertRaises(TypeError, validator.validate, "x") 47 | 48 | def test_validate_date(self): 49 | validator = v.Date() 50 | dtime = dt.datetime(2011, 1, 22, 2, 30) 51 | validator.validate(dtime) 52 | validator.validate(dt.date(2011, 1, 22)) 53 | 54 | def test_validate_time(self): 55 | validator = v.Time() 56 | dtime = dt.datetime(2011, 1, 22, 2, 30) 57 | validator.validate(dtime) 58 | validator.validate(dt.time(11, 30)) 59 | 60 | def test_validate_uri(self): 61 | validator = v.URL() 62 | validator.validate("https://www.google.com") 63 | 64 | self.assertRaises(TypeError, validator.validate, "aa://www.goog[le.com:XXXX") 65 | -------------------------------------------------------------------------------- /rspace_client/tests/grid_placement_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Created on Mon Oct 4 19:10:42 2021 5 | 6 | @author: richard 7 | """ 8 | import unittest 9 | import rspace_client.inv.inv as inv 10 | 11 | 12 | def make_n_items_to_move(n: int, prefix="SS") -> list: 13 | return [f"{prefix}{i}" for i in range(n)] 14 | 15 | 16 | class GridPlacementUnitTest(unittest.TestCase): 17 | def test_by_row(self): 18 | ss = make_n_items_to_move(10) 19 | by_row = inv.ByRow(1, 1, 2, 3, *ss) 20 | self.assertEqual(1, by_row.column_index) 21 | self.assertEqual(10, len(by_row.items_to_move)) 22 | 23 | def test_by_column(self): 24 | ss = make_n_items_to_move(10) 25 | by_row = inv.ByColumn(1, 1, 2, 3, *ss) 26 | self.assertEqual(1, by_row.column_index) 27 | by_row_str = str(by_row) 28 | self.assertTrue("Items 10" in by_row_str) 29 | self.assertTrue("total_rows=3" in by_row_str) 30 | 31 | self.assertEqual(10, len(by_row.items_to_move)) 32 | 33 | def test_all_gt_1_validation(self): 34 | ss = make_n_items_to_move(10) 35 | self.assertRaises(ValueError, inv.ByColumn, 0, 1, 2, 3, *ss) 36 | self.assertRaises(ValueError, inv.ByColumn, 1, 0, 2, 3, *ss) 37 | self.assertRaises(ValueError, inv.ByColumn, 1, 1, 0, 3, *ss) 38 | self.assertRaises(ValueError, inv.ByColumn, 1, 1, 2, 0, *ss) 39 | 40 | def test_indices_fit_in_grid(self): 41 | ss = make_n_items_to_move(10) 42 | self.assertRaises(ValueError, inv.ByColumn, 4, 1, 3, 3, *ss) 43 | self.assertRaises(ValueError, inv.ByColumn, 2, 3, 3, 2, *ss) 44 | 45 | def test_ByLocation(self): 46 | coords = [inv.GridLocation(i + 1, i + 2) for i in range(10)] 47 | ss = make_n_items_to_move(10) 48 | by_location = inv.ByLocation(coords, *ss) 49 | self.assertEqual(10, len(by_location.items_to_move)) 50 | self.assertEqual(10, len(by_location.locations)) 51 | self.assertEqual(inv.FillingStrategy.EXACT, by_location.filling_strategy) 52 | 53 | def test_validate_movable_type(self): 54 | ss = make_n_items_to_move(10, "SA") # can't move samples, only subsamples 55 | self.assertRaises(ValueError, inv.ByColumn, 2, 3, 3, 2, *ss) 56 | 57 | def test_positive_grid_location(self): 58 | self.assertRaises(ValueError, inv.GridLocation, 0, 1) 59 | self.assertRaises(ValueError, inv.GridLocation, 1, 0) 60 | -------------------------------------------------------------------------------- /rspace_client/validators.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | from urllib.parse import urlparse 3 | 4 | 5 | class AbsValidator: 6 | def validate(self, item): 7 | pass 8 | 9 | def raise_type_error(self, value, expected_type: str): 10 | raise TypeError(f"Expected {value!r} to be {expected_type}") 11 | 12 | 13 | class Number(AbsValidator): 14 | def validate(self, value): 15 | if not isinstance(value, (int, float)): 16 | self.raise_type_error(value, "an int or float") 17 | 18 | 19 | class String(AbsValidator): 20 | def validate(self, value): 21 | if not isinstance(value, str): 22 | self.raise_type_error(value, "a string") 23 | 24 | 25 | class Date(AbsValidator): 26 | def validate(self, value): 27 | if not isinstance(value, dt.date) and not isinstance(value, dt.datetime): 28 | self.raise_type_error(value, "a datetime or date") 29 | 30 | 31 | class Time(AbsValidator): 32 | def validate(self, value): 33 | if not isinstance(value, dt.datetime) and not isinstance(value, dt.time): 34 | self.raise_type_error(value, "a datetime or date") 35 | 36 | 37 | class URL(AbsValidator): 38 | def validate(self, item): 39 | if isinstance(item, str): 40 | try: 41 | urlparse(item) 42 | except: 43 | raise TypeError("{type(item)} {item!r} could not be parsed") 44 | 45 | else: 46 | self.raise_type_error(item, "a URI string") 47 | 48 | 49 | class OneOf(AbsValidator): 50 | """ 51 | Validates that argument is one of a list of items passed into constructor 52 | """ 53 | 54 | def __init__(self, options): 55 | self.options = options 56 | 57 | def validate(self, value: str): 58 | if not isinstance(value, str) or not value in self.options: 59 | self.raise_type_error(value, f"to be one of [{', '.join(self.options)}]") 60 | 61 | 62 | class AllOf(AbsValidator): 63 | """ 64 | Validates that all items in the argument are in the list of items passed into constructor 65 | """ 66 | 67 | def __init__(self, options): 68 | self.options = options 69 | 70 | def validate(self, chosen): 71 | if not isinstance(chosen, list) or not all([c in self.options for c in chosen]): 72 | raise TypeError( 73 | f"Expected all chosen items {chosen!r} to be in [{', '.join(self.options)}]" 74 | ) # -*- coding: utf-8 -*- 75 | -------------------------------------------------------------------------------- /examples/paging_through_users.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from __future__ import print_function 4 | import rspace_client 5 | import time 6 | import re 7 | 8 | 9 | def print_users(response): 10 | print("Users in response:") 11 | with open("toDelete-8087.txt", "a+") as toDelete: 12 | for user in response["users"]: 13 | print(user["id"]) 14 | toDelete.write("{0}\n".format(user["id"])) 15 | toDelete.close() 16 | 17 | 18 | def delete_users(): 19 | failedIds = [] 20 | with open("log-delete.txt", "r+") as deletelog: 21 | lines = deletelog.readlines() 22 | for line in lines: 23 | if re.match("^FAILED", line): 24 | failedId = line.split(sep=" ")[1] 25 | failedIds.append(failedId.rstrip("\r\n")) 26 | print("Will ignore these userids that are known to fail: {}".format(failedIds)) 27 | 28 | with open("toDelete-8087.txt", "r") as toDelete, open( 29 | "log-delete.txt", "a+" 30 | ) as deletelog: 31 | lines = toDelete.readlines() 32 | for line in lines: 33 | userId = line.rstrip("\r\n") 34 | if userId in failedIds: 35 | print("Id {} has previously failed, skipping".format(userId)) 36 | continue 37 | print("Deleting user {}".format(userId)) 38 | try: 39 | resp = client.deleteTempUser(userId) 40 | if resp.status_code == 204: 41 | deletelog.write("Deleted {0}\n".format(userId)) 42 | deletelog.flush() 43 | print("Deleted {}\n".format(userId)) 44 | except: 45 | deletelog.write("FAILED {0}\n".format(userId)) 46 | deletelog.flush() 47 | print("FAILED {}\n".format(userId)) 48 | time.sleep(1) 49 | print("Finished") 50 | deletelog.close() 51 | 52 | 53 | # Parse command line parameters 54 | client = rspace_client.utils.createELNClient() 55 | 56 | # Simple search 57 | response = client.get_users( 58 | page_size=50, 59 | tempaccount_only=False, 60 | last_login_before="2022-01-01", 61 | created_before="2022-01-01", 62 | ) 63 | print_users(response) 64 | 65 | 66 | while client.link_exists(response, "next"): 67 | time.sleep(1) 68 | print("Retrieving next page...") 69 | response = client.get_link_contents(response, "next") 70 | print_users(response) 71 | 72 | delete_users() 73 | -------------------------------------------------------------------------------- /examples/share_documents.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | In order to use this example the account of your API key must belong to a group 4 | so that you can share items 5 | """ 6 | 7 | from __future__ import print_function 8 | import rspace_client 9 | 10 | 11 | def printSharedItemNames(response): 12 | names = [l["shareItemName"] for l in response["shares"]] 13 | print(",".join(names)) 14 | 15 | 16 | # Parse command line parameters 17 | client = rspace_client.utils.createELNClient() 18 | 19 | groups = client.get_groups() 20 | if len(groups) == 0: 21 | raise Exception("Cannot proceed as you are not a member of a group") 22 | 23 | print( 24 | "Sharing into the first group found - '{}' ({}) - shareFolder = {}".format( 25 | groups[0]["name"], groups[0]["id"], groups[0]["sharedFolderId"] 26 | ) 27 | ) 28 | 29 | # Creating a new Basic document in Api Inbox folder 30 | print("Creating a new document to share") 31 | new_document = client.create_document( 32 | name="Python API Example2 Basic Document", 33 | tags=["Python", "API", "example"], 34 | fields=[{"content": "Some example text"}], 35 | ) 36 | print( 37 | "New document was successfully created with global ID {}".format( 38 | new_document["globalId"] 39 | ) 40 | ) 41 | 42 | print("Creating subfolder in shared group folder...") 43 | newFolder = client.create_folder("SharedSubfolder", groups[0]["sharedFolderId"]) 44 | newFolderId = newFolder["id"] 45 | 46 | print( 47 | "Sharing document {} with group {} into folder {}".format( 48 | new_document["id"], groups[0]["name"], newFolderId 49 | ) 50 | ) 51 | shared = client.shareDocuments( 52 | [new_document["id"]], groups[0]["id"], sharedFolderId=newFolderId 53 | ) 54 | 55 | print("The shared resource ID is {}".format(shared["shareInfos"][0]["id"])) 56 | print( 57 | """You can see the shared item in RSpace webapp\ 58 | in Shared->Lab Groups->{}_SHARED 59 | """.format( 60 | groups[0]["name"] 61 | ) 62 | ) 63 | 64 | print("Listing shared items") 65 | sharedlist = client.get_shared_items() 66 | 67 | printSharedItemNames(sharedlist) 68 | while client.link_exists(sharedlist, "next"): 69 | print("Retrieving next page...") 70 | sharedlist = client.get_link_contents(shared, "next") 71 | printSharedItemNames(sharedlist) 72 | 73 | 74 | # print ("Unsharing....") 75 | # client.unshareItem(shared['shareInfos'][0]['id']) 76 | # print("Unshared doc id {} from group {}".format(new_document['id'], groups[0]['name'])) 77 | 78 | print("Finished") 79 | -------------------------------------------------------------------------------- /rspace_client/tests/id_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Created on Mon Oct 4 19:10:42 2021 5 | 6 | @author: richard 7 | """ 8 | import unittest 9 | from rspace_client.inv.inv import Id, ListContainer, Workbench 10 | 11 | 12 | class IdUnitTest(unittest.TestCase): 13 | def test_id(self): 14 | ida = Id(1234) 15 | self.assertEqual(1234, ida.as_id()) 16 | id2 = Id("SA1235") 17 | self.assertEqual(1235, id2.as_id()) 18 | self.assertEqual("SA", id2.prefix) 19 | id3 = Id("2234") 20 | self.assertEqual(2234, id3.as_id()) 21 | 22 | self.assertRaises(ValueError, Id, "!!!!") 23 | 24 | def test_id_from_dict(self): 25 | id_a = Id({"id": 1234, "globalId": "SA1234"}) 26 | self.assertEqual(1234, id_a.as_id()) 27 | self.assertEqual("SA", id_a.prefix) 28 | 29 | self.assertRaises(TypeError, Id, {"x_not_an_id": 23}) 30 | 31 | def test_id_from_container(self): 32 | minimal_container = {"id": 123, "globalId": "IC123", "cType": "LIST"} 33 | c = Id(ListContainer(minimal_container)) 34 | self.assertEqual(123, c.as_id()) 35 | self.assertTrue(c.is_container()) 36 | 37 | def test_id_from_workbench(self): 38 | workbench = {"id": 123, "globalId": "BE123", "cType": "WORKBENCH"} 39 | c = Id(Workbench(workbench)) 40 | self.assertEqual(123, c.as_id()) 41 | self.assertEqual("BE", c.prefix) 42 | self.assertTrue(c.is_bench()) 43 | 44 | def test_repr(self): 45 | id_a = Id("SA1234") 46 | self.assertEqual("Id('SA1234')", repr(id_a)) 47 | id_a = Id(1234) 48 | self.assertEqual("Id(1234)", repr(id_a)) 49 | 50 | def test_str(self): 51 | id_a = Id("SA1234") 52 | self.assertEqual("SA1234", str(id_a)) 53 | self.assertTrue(id_a.is_sample()) 54 | id_a = Id(1234) 55 | self.assertEqual("1234", str(id_a)) 56 | self.assertFalse(id_a.is_sample()) 57 | self.assertTrue(id_a.is_sample(maybe=True)) 58 | 59 | def test_id_equals(self): 60 | self.assertEqual(Id(1234), Id(1234)) 61 | self.assertEqual(Id("SA1234"), Id("SA1234")) 62 | 63 | self.assertNotEqual(Id(1234), Id(1235)) 64 | self.assertNotEqual(Id(1234), Id("SA1234")) 65 | self.assertNotEqual(Id("IT1234"), Id("SA1234")) 66 | self.assertNotEqual(Id("SA1234"), Id(1234)) 67 | 68 | def test_valid_id(self): 69 | self.assertTrue(Id.is_valid_id(1233)) 70 | self.assertTrue(Id.is_valid_id("SA1233")) 71 | self.assertTrue( 72 | Id.is_valid_id({"id": 123, "globalId": "BE123", "cType": "WORKBENCH"}) 73 | ) 74 | self.assertFalse(Id.is_valid_id("ndjfndskjf")) 75 | -------------------------------------------------------------------------------- /rspace_client/inv/quantity_unit.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Created on Sun Oct 3 11:11:05 2021 5 | Some content 6 | @author: richard 7 | """ 8 | 9 | 10 | class QuantityUnit: 11 | """ 12 | Static data from api/v1/units definitions 13 | """ 14 | 15 | data = [ 16 | {"id": 1, "label": "items", "category": "dimensionless", "description": ""}, 17 | {"id": 2, "label": "µl", "category": "volume", "description": ""}, 18 | {"id": 3, "label": "ml", "category": "volume", "description": ""}, 19 | {"id": 4, "label": "l", "category": "volume", "description": ""}, 20 | {"id": 5, "label": "µg", "category": "mass", "description": ""}, 21 | {"id": 6, "label": "mg", "category": "mass", "description": ""}, 22 | {"id": 7, "label": "g", "category": "mass", "description": ""}, 23 | {"id": 8, "label": "C", "category": "temperature", "description": ""}, 24 | {"id": 9, "label": "K", "category": "temperature", "description": ""}, 25 | {"id": 10, "label": "F", "category": "temperature", "description": ""}, 26 | {"id": 11, "label": "nmol/l", "category": "molarity", "description": ""}, 27 | {"id": 12, "label": "μmol/l", "category": "molarity", "description": ""}, 28 | {"id": 13, "label": "mmol/l", "category": "molarity", "description": ""}, 29 | {"id": 14, "label": "mol/l", "category": "molarity", "description": ""}, 30 | {"id": 15, "label": "µg/µl", "category": "concentration", "description": ""}, 31 | {"id": 16, "label": "mg/ml", "category": "concentration", "description": ""}, 32 | {"id": 17, "label": "g/l", "category": "concentration", "description": ""}, 33 | ] 34 | 35 | @staticmethod 36 | def of(label: str) -> dict: 37 | """ 38 | 39 | Parameters 40 | ---------- 41 | label : str 42 | String repersentation of a unit. 43 | 44 | Raises 45 | ------ 46 | ValueError 47 | if no unit definition exists for . 48 | 49 | Returns 50 | ------- 51 | dict 52 | information about the unit definition. 53 | """ 54 | units = QuantityUnit()._find_label(label) 55 | if len(units) == 0: 56 | raise ValueError(f"{label} not found in unit data") 57 | return units[0] 58 | 59 | @staticmethod 60 | def is_supported_unit(label: str) -> bool: 61 | return len(QuantityUnit._find_label(label)) > 0 62 | 63 | @staticmethod 64 | def _find_label(label): 65 | return [x for x in QuantityUnit.data if x["label"] == label] 66 | 67 | @staticmethod 68 | def unit_labels() -> tuple: 69 | return [x["label"] for x in QuantityUnit.data] 70 | -------------------------------------------------------------------------------- /examples/create_document.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from __future__ import print_function 4 | import os 5 | import rspace_client 6 | 7 | # Parse command line parameters 8 | client = rspace_client.utils.createELNClient() 9 | 10 | # Creating a new Basic document in Api Inbox folder 11 | new_document = client.create_document( 12 | name="Python API Example Basic Document", 13 | tags=["Python", "API", "example"], 14 | fields=[{"content": "Some example text"}], 15 | ) 16 | print( 17 | "New document was successfully created with global ID {}".format( 18 | new_document["globalId"] 19 | ) 20 | ) 21 | 22 | # Creating a document in a specific Workspace folder: 23 | folder = client.create_folder("subfolder") 24 | new_document2 = client.create_document( 25 | name="Basic Document in subfolder", 26 | parent_folder_id=folder["id"], 27 | fields=[{"content": "Some example text"}], 28 | ) 29 | print( 30 | "Created document id [{}] in folder id [{}]".format( 31 | new_document2["id"], folder["id"] 32 | ) 33 | ) 34 | 35 | 36 | # Uploading a file to the gallery 37 | with open(os.sep.join(["resources", "2017-05-10_1670091041_CNVts.csv"]), "rb") as f: 38 | new_file = client.upload_file(f, caption="some caption") 39 | print( 40 | 'File "{}" was uploaded as {} ({})'.format( 41 | f.name, new_file["name"], new_file["globalId"] 42 | ) 43 | ) 44 | 45 | with open( 46 | os.sep.join(["resources", "2017-05-10_1243111032_GT_DetailedTS_E.csv"]), "rb" 47 | ) as f2: 48 | print("updating file with a new version") 49 | updatedFile = client.update_file(f2, new_file["id"]) 50 | print( 51 | 'File "{}" has replaced "{}" and is now version {}'.format( 52 | updatedFile["name"], new_file["name"], updatedFile["version"] 53 | ) 54 | ) 55 | # Editing the document to link to the uploaded file 56 | updated_document = client.update_document( 57 | new_document["id"], 58 | fields=[ 59 | { 60 | "content": "Some example text. Link to the uploaded file: ".format( 61 | new_file["id"] 62 | ) 63 | } 64 | ], 65 | ) 66 | print("Document has been updated to link to the uploaded file.") 67 | 68 | # Creating a document to show deletion 69 | print("Creating a new document which will be deleted") 70 | new_document2 = client.create_document( 71 | name="Python API Document for deletion", 72 | tags=["Python", "API", "example"], 73 | fields=[{"content": "Some example text"}], 74 | ) 75 | deletedDoc = client.delete_document(new_document2["id"]) 76 | print("Document {} was deleted".format(new_document2["id"])) 77 | print( 78 | "You can see or restore the deleted document in web application in MyRSpace->Deleted Items" 79 | ) 80 | print("Finished") 81 | -------------------------------------------------------------------------------- /rspace_client/eln/field_content.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Created on Thu Jun 3 09:50:47 2021 5 | 6 | @author: richard 7 | """ 8 | 9 | from bs4 import BeautifulSoup 10 | import re 11 | 12 | 13 | class FieldContent: 14 | """ 15 | Encapsulates HTML content of text fields and provides methods to pull 16 | out features of interest such as tables, links etc 17 | """ 18 | 19 | def __init__(self, html_content): 20 | self.html = html_content 21 | self.soup = BeautifulSoup(self.html, "html.parser") 22 | 23 | def get_text(self): 24 | """ 25 | Gets the text of the field, stripped of all HTML tags 26 | Returns 27 | ------- 28 | str 29 | HTML-stripped content. 30 | 31 | """ 32 | return self.soup.get_text() 33 | 34 | def get_datatables( 35 | self, search_term=None, ignore_empty_rows=True, ignore_empty_columns=True 36 | ): 37 | """ 38 | Parses HTML content to identify and return CalculationTables as 2d arrays 39 | Parameters 40 | ---------- 41 | search_term : A query string, optional 42 | Use as a filter to return only data-tabes containing this string. The default is None. 43 | ignore_empty_rows : Boolean, optional 44 | The default is True. 45 | ignore_empty_columns : Boolean, optional 46 | The default is True. 47 | 48 | Returns 49 | ------- 50 | all_tables : A List of 2d arrays 51 | A possibly empty list of 2d arrays in format [row][column] 52 | containing the data-set values. Formulas are not included 53 | 54 | """ 55 | divs = self.soup.find_all("div", class_="rsCalcTableDiv") 56 | all_tables = [] 57 | for div in divs: 58 | if search_term is not None: 59 | if re.search(search_term, div.get_text(), re.IGNORECASE) is None: 60 | continue 61 | trs = div.find_all("tr") 62 | all_r_data = [] 63 | 64 | for tr in trs: 65 | r_data = [el.get_text().strip() for el in tr.find_all("td")] 66 | if ignore_empty_rows is False or any([len(x) > 0 for x in r_data]): 67 | all_r_data.append(r_data) 68 | 69 | if ignore_empty_columns and len(all_r_data) > 0: 70 | colsToRemove = [] 71 | ## find empty columns 72 | for i in range(len(all_r_data[0])): 73 | a_none = all(len(r[i]) == 0 for r in all_r_data) 74 | if a_none: 75 | colsToRemove.append(i) 76 | ## remove each column 77 | for j in range(len(colsToRemove)): 78 | for r in all_r_data: 79 | r.pop(colsToRemove[j] - j) 80 | 81 | all_tables.append(all_r_data) 82 | ## a list of 2d arrays. Each row has same number of columns 83 | return all_tables 84 | -------------------------------------------------------------------------------- /rspace_client/tests/grid_container_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Created on Mon Oct 4 19:10:42 2021 5 | 6 | @author: richard 7 | """ 8 | import unittest 9 | from rspace_client.inv.inv import GridContainer, Container, GridLocation 10 | from rspace_client.tests import base_test 11 | import json 12 | 13 | 14 | class GridContainerTest(unittest.TestCase): 15 | def setUp(self): 16 | ## a grid container with 7 rows and 3 columns, cotaining 10 subsamples. 17 | path = base_test.get_datafile("container_by_id.json") 18 | with open(path, "r") as container: 19 | container_json = json.load(container) 20 | self.container = GridContainer(container_json) 21 | 22 | def test_repr(self): 23 | self.assertEqual( 24 | "GridContainer id='IC131085', storesContainers=True, storesSubsamples=True, percent_full=47.62", 25 | str(self.container), 26 | ) 27 | 28 | def test_grid_container(self): 29 | self.assertEqual(3, self.container.column_count()) 30 | self.assertEqual(7, self.container.row_count()) 31 | self.assertEqual(21, self.container.capacity()) 32 | self.assertEqual(10, self.container.in_use()) 33 | self.assertEqual(11, self.container.free()) 34 | self.assertAlmostEqual(47.62, self.container.percent_full(), 2) 35 | 36 | def test_get_used_locations(self): 37 | used_locations = self.container.used_locations() 38 | self.assertTrue((2, 1) in used_locations) 39 | self.assertTrue((2, 7) in used_locations) 40 | self.assertFalse((1, 4) in used_locations) 41 | 42 | def test_get_free_locations(self): 43 | free_locations = self.container.free_locations() 44 | used_locations = self.container.used_locations() 45 | 46 | s1 = set(free_locations) 47 | s2 = set(used_locations) 48 | self.assertEqual(0, len(s1.intersection(s2))) 49 | self.assertEqual( 50 | self.container.capacity(), len(free_locations) + len(used_locations) 51 | ) 52 | 53 | def test_container_of(self): 54 | raw_json = self.container.data 55 | grid2 = Container.of(raw_json) 56 | self.assertTrue(grid2.is_grid()) 57 | self.assertFalse(grid2.is_list()) 58 | self.assertTrue(isinstance(grid2, GridContainer)) 59 | 60 | def test_storable_content_types(self): 61 | raw_json = self.container.data 62 | raw_json["canStoreContainers"] = False 63 | grid = Container.of(raw_json) 64 | self.assertTrue(grid.accept_subsamples()) 65 | self.assertFalse(grid.accept_containers()) 66 | 67 | def test_grid_location_repr(self): 68 | cell = GridLocation(3, 4) 69 | self.assertEqual("GridLocation(3, 4)", repr(cell)) 70 | 71 | def test_grid_location_equals(self): 72 | cell1 = GridLocation(3, 4) 73 | cell2 = GridLocation(3, 4) 74 | other = GridLocation(5, 7) 75 | 76 | self.assertEqual(cell1, cell2) 77 | self.assertNotEqual(cell1, other) 78 | -------------------------------------------------------------------------------- /examples/create_form.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from __future__ import print_function 4 | import rspace_client 5 | 6 | 7 | def print_forms(response): 8 | for form in response["forms"]: 9 | print(form["globalId"], form["name"]) 10 | 11 | 12 | def create_form(fields): 13 | response = client.create_form( 14 | "Test Form", tags=["testing", "example"], fields=fields 15 | ) 16 | return response 17 | 18 | 19 | # Parse command line parameters 20 | client = rspace_client.utils.createELNClient() 21 | 22 | print("Listing all forms:") 23 | response = client.get_forms() 24 | print_forms(response) 25 | # Get the remaining forms if the response is paginated 26 | while client.link_exists(response, "next"): 27 | response = client.get_link_contents(response, "next") 28 | print_forms(response) 29 | 30 | # Creates a new form 31 | print("Creating a new form...") 32 | fields = [ 33 | { 34 | "name": "A String Field", 35 | "type": "String", 36 | "defaultValue": "An optional default value", 37 | }, 38 | { 39 | "name": "text FormField Example", 40 | "type": "Text", 41 | "defaultValue": "Some placeholder text,can be simple HTML", 42 | }, 43 | { 44 | "name": "Number FormField Example", 45 | "type": "Number", 46 | "defaultValue": 23, 47 | "min": 0, 48 | }, 49 | { 50 | "name": "Choice FormField Example", 51 | "type": "Choice", 52 | "multipleChoice": True, 53 | "options": ["antibody1", "antibody2", "antibody3"], 54 | "defaultOptions": ["antibody2", "antibody3"], 55 | }, 56 | { 57 | "name": "Radio FormField Example", 58 | "type": "Radio", 59 | "options": ["antibody1", "antibody2", "antibody3"], 60 | "defaultOption": "antibody2", 61 | }, 62 | { 63 | "name": "date FormField Example", 64 | "type": "Date", 65 | "defaultValue": "2018-03-21", 66 | "min": "2018-02-21", 67 | "max": "2018-04-21", 68 | }, 69 | ] 70 | response = create_form(fields) 71 | print("Newly created form info:", response["globalId"], response["name"]) 72 | 73 | # Get form details (these are also returned when creating a new form) 74 | print("Getting details about a form:", response["globalId"]) 75 | response = client.get_form(response["globalId"]) 76 | print("Retrieved information about a form:", response["globalId"], response["name"]) 77 | print("Fields:") 78 | for field in response["fields"]: 79 | print(field["type"], field["name"]) 80 | 81 | # Publish the form 82 | print("Publishing form:", response["globalId"]) 83 | client.publish_form(response["globalId"]) 84 | 85 | # Share the form 86 | print("Sharing form:", response["globalId"]) 87 | client.share_form(response["globalId"]) 88 | 89 | # Unshare the form 90 | print("Unsharing form:", response["globalId"]) 91 | client.unshare_form(response["globalId"]) 92 | 93 | # Unpublish the form 94 | print("Unpublishing form:", response["globalId"]) 95 | client.unpublish_form(response["globalId"]) 96 | 97 | ## Creating a new form for deletion 98 | print("Creating a new form to show form deletion") 99 | response = create_form(fields) 100 | print("Newly created form info:", response["globalId"], response["name"]) 101 | 102 | print("Deleting the NEW form") 103 | deleted = client.delete_form(response["globalId"]) 104 | print("Form {} is now deleted".format(response["globalId"])) 105 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file 4 | 5 | ## Proposed breaking changes 6 | - replace naming of classes and methods using 'Workbench' to 'Bench' 7 | - replace create_sample, create_container long argument lists with new XXXPost objects 8 | 9 | ## 2.6.0 2025-02-25 10 | 11 | - implementing pyfilesystem API methods for browsing RSpace Gallery and RSpace Inventory 12 | 13 | ## 2.5.0 2022-06-12 14 | 15 | - Deprecated method `download_export()`. Use `export_and_download()` instead. 16 | - ELN: Add optional `progress_log` argument to `export_and_download()` 17 | 18 | ## 2.4.1 2022-03-19 19 | 20 | - ImageContainerPost can take a file object as well as a string path 21 | 22 | ## 2.4.0 2022-02-18 23 | 24 | ### Breaking change 25 | 26 | - renamed inv.uploadAttachment method to 'upload_attachment' 27 | 28 | ### Added 29 | 30 | - set_image sets a preview image for a sample,container or subsample 31 | - add_locations_to_image_container adds new marked locations to an image 32 | - delete_locations_from_image_container removes marked locations from an image 33 | 34 | 35 | ## 2.3.3 2022-02-11 36 | 37 | - Removed print statements from builk_create_sample 38 | 39 | ## 2.3.2 2022-02-09 40 | 41 | ### Fixed 42 | 43 | - #31 Extra fields ignored in bulk/ sample posts 44 | 45 | ## 2.3.1 2022-02-09 46 | 47 | ### Added 48 | 49 | - static method Id.is_valid_id() to check if an object can be parsed as an id 50 | - bulk 'createContainer' methods 51 | - methods 'error_results' and 'success_results' on BulkOperationResult 52 | - methods in Id: is_bench, is_sample 53 | - Classes to define new Containers: GridContainerPost, ListContainerPost 54 | - Classes to define where new items are placed: ListContainerTargetLocation, 55 | GridContainerTargetLocation, BenchTargetLocation, TopLevelTargetLocation 56 | - create ImageContainerPost, createInImageContainer,add_items_to_image_container 57 | - create ImageContainer class to wrap dicts of ImageContainers from the server. 58 | 59 | ### Changed 60 | - BREAKING: altered create_grid_container and create_list_container location arguments 61 | 62 | ## 2.3.0 2022-02-01 63 | - incorrectly built. Obsolete 64 | 65 | ## 2.2.2 2022-02-01 66 | 67 | ### Added 68 | 69 | - Example script 'freezer.py' to set up a -80 freezer for testing 70 | - 'bulk_create_sample' method to create many samples at once. 71 | 72 | ### Fixed 73 | - 'canStoreSamples' flag now set correctly when creating a grid container 74 | 75 | ## 2.2.1 2022-01-28 76 | 77 | ### Fixed 78 | 79 | - case-insensitive parsing of sample field types 80 | - enable definition of sample radio field with no selected option 81 | - handle variability in choice/radio field definition 82 | 83 | ## 2.2.0 2022-01-27 84 | 85 | ### Added 86 | - Upload directories of files into ELN via import_tree() - #17 87 | - Added str/repr implementations for inventory value objects - #23 88 | - Added eq methods for inventory value objects - #26 89 | 90 | ## 2.1.0 2022-01-22 91 | 92 | Requires RSpace 1.73 or later 93 | 94 | ### Added 95 | 96 | - Support for sample templates: create, get/set icon, delete/restore 97 | - Dynamically generated classes from template definitions 98 | - Transfer ownership of samples and templates 99 | - Create samples from a template, and set field content 100 | - export_selection to export specific items 101 | - optionally include revision history in exports 102 | 103 | ### Changed 104 | 105 | - create_sample now accepts sample template ID and an optional list of Fields with values. 106 | 107 | ### Deprecated 108 | 109 | ### Removed 110 | 111 | ### Fixed 112 | 113 | ### Security 114 | 115 | -------------------------------------------------------------------------------- /examples/resources/2017-05-10_1670091041_CNVts.csv: -------------------------------------------------------------------------------- 1 | Chip Run Info,C:\Users\FLUIDIGM.EP1-50079\Desktop\GnomeOperations_FLUIDIGM\2-GnomeData\2017_CNV\2017-05-10_1670091041_CNV_PT\ChipRun.bml,1670091041,qdPCR 37K (167x),,ROX,FAM-MGB : VIC-MGB,5/10/2017 16:20,0:01:03,EP1-50079,,,,,,,,,,,,, 2 | Application Version,4.1.2,,,,,,,,,,,,,,,,,,,,, 3 | Application Build,20140108.11,,,,,,,,,,,,,,,,,,,,, 4 | Export Type,Summary Table Results,,,,,,,,,,,,,,,,,,,,, 5 | Quality Threshold,0.65,,,,,,,,,,,,,,,,,,,,, 6 | Intensity Threshold Method,User (Panel),,,,,,,,,,,,,,,,,,,,, 7 | ,,,,,,,,,,,,,,,,,,,,,, 8 | ,,,,,,,,,,,,,,,,,,,,,, 9 | ,,,,,,,,,,,,,,,,,,,,,, 10 | Panel Summary Information,Panel Summary Information,Panel Summary Information,Panel Summary Information,Panel Summary Information,Panel Summary Information,Panel Summary Information,Panel Summary Information,Panel Summary Information,Panel Summary Information,Panel Summary Information,Panel Summary Information,Panel Summary Information,Panel Summary Information,Panel Summary Information,Panel Summary Information,Panel Summary Information,Panel Summary Information,Panel Summary Information,Panel Summary Information,Probe Type Groups,Probe Type Groups,Probe Type Groups 11 | Panel,Sample Information,Sample Information,Sample Information,FAM-MGB,FAM-MGB,FAM-MGB,FAM-MGB,FAM-MGB,FAM-MGB,FAM-MGB,FAM-MGB,VIC-MGB,VIC-MGB,VIC-MGB,VIC-MGB,VIC-MGB,VIC-MGB,VIC-MGB,VIC-MGB,All,All,RATIO 12 | ID,Name,Type,rConc.,Name,Type,Threshold,Count,nVolume,Est. Targets,Est.T-95L,Est.T-95U,Name,Type,Threshold,Count,nVolume,Est. Targets,Est.T-95L,Est.T-95U,Intersection,Union,Est. Ratio (F/V) 13 | 2,51150294_CPMH,Unknown,9,Hs00010001_cn,Test,0.394,615,1,1236,1132,1350,RnaseP,Test,0.776,570,1,1039,950,1135,456,729,1.19 14 | 4,108170423,Unknown,9,Hs00010001_cn,Test,0.373,696,1,1808,1652,1991,RnaseP,Test,0.725,670,1,1575,1441,1726,607,759,1.148 15 | 5,108170427,Unknown,9,Hs00010001_cn,Test,0.389,385,1,534,480,589,RnaseP,Test,0.731,368,1,501,449,553,181,572,1.066 16 | 8,51150300_CPMH,Unknown,9,Hs00010001_cn,Test,0.366,643,1,1390,1273,1520,RnaseP,Test,0.719,593,1,1134,1037,1238,495,741,1.226 17 | 14,51150311_CPMH,Unknown,9,Hs00010001_cn,Test,0.357,389,1,542,488,598,RnaseP,Test,0.699,387,1,538,484,593,197,579,1.007 18 | 16,108170424,Unknown,9,Hs00010001_cn,Test,0.362,602,1,1174,1075,1282,RnaseP,Test,0.725,613,1,1226,1123,1339,475,740,0.958 19 | 17,340160416,Unknown,9,Hs00010001_cn,Test,0.357,346,1,460,411,509,RnaseP,Test,0.71,529,1,896,817,979,242,633,0.513 20 | 19,226140142_CPMH,Unknown,9,Hs00010001_cn,Test,0.353,472,1,732,664,802,RnaseP,Test,0.734,437,1,646,585,709,257,652,1.133 21 | 20,262140170_CPMH,Unknown,9,Hs00010001_cn,Test,0.366,602,1,1174,1075,1282,RnaseP,Test,0.72,542,1,938,857,1025,428,716,1.252 22 | 25,322140256_CPMH,Unknown,9,Hs00010001_cn,Test,0.341,391,1,546,491,602,RnaseP,Test,0.718,367,1,499,447,551,191,567,1.094 23 | 26,51150302_CPMH,Unknown,9,Hs00010001_cn,Test,0.354,629,1,1309,1199,1431,RnaseP,Test,0.702,605,1,1188,1087,1297,494,740,1.102 24 | 28,108170425,Unknown,9,Hs00010001_cn,Test,0.363,696,1,1808,1652,1991,RnaseP,Test,0.686,685,1,1701,1555,1868,620,761,1.063 25 | 29,173160358,Unknown,9,Hs00010001_cn,Test,0.348,459,1,699,634,766,RnaseP,Test,0.696,405,1,575,519,633,243,621,1.216 26 | 31,297140235_CPMH,Unknown,9,Hs00010001_cn,Test,0.344,529,1,896,817,979,RnaseP,Test,0.719,415,1,597,539,656,278,666,1.501 27 | 37,226140150_CPMH,Unknown,9,Hs00010001_cn,Test,0.352,656,1,1474,1349,1613,RnaseP,Test,0.68,657,1,1481,1356,1620,562,751,0.995 28 | 39,108170422,Unknown,9,Hs00010001_cn,Test,0.418,522,1,873,796,955,RnaseP,Test,0.714,632,1,1326,1214,1449,424,730,0.658 29 | 40,108170426,Unknown,9,Hs00010001_cn,Test,0.383,486,1,769,699,842,RnaseP,Test,0.726,457,1,694,629,761,275,668,1.108 30 | 43,48150263_CPMH,Unknown,9,Hs00010001_cn,Test,0.362,456,1,691,627,758,RnaseP,Test,0.719,477,1,745,677,816,289,644,0.928 31 | 47,340160419,Unknown,9,Hs00010001_cn,Test,0.397,492,1,785,714,859,RnaseP,Test,0.748,438,1,648,587,712,273,657,1.211 32 | 48,123170435,Unknown,9,Hs00010001_cn,Test,0.418,514,1,849,773,928,RnaseP,Test,0.717,474,1,737,669,807,319,669,1.152 33 | -------------------------------------------------------------------------------- /rspace_client/tests/data/sample_template_by_id.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 1, 3 | "globalId": "IT1", 4 | "name": "Sample", 5 | "description": null, 6 | "created": "2025-02-17T09:33:35.000Z", 7 | "createdBy": "user1a", 8 | "lastModified": "2025-02-17T09:33:35.608Z", 9 | "modifiedBy": "user1a", 10 | "modifiedByFullName": "user user", 11 | "deleted": false, 12 | "deletedDate": null, 13 | "iconId": -1, 14 | "quantity": { 15 | "numericValue": 0, 16 | "unitId": 3 17 | }, 18 | "tags": [], 19 | "type": "SAMPLE_TEMPLATE", 20 | "attachments": [ 21 | { 22 | "id": 32772, 23 | "globalId": "IF32772", 24 | "name": "test.pdf", 25 | "parentGlobalId": "SS4", 26 | "mediaFileGlobalId": null, 27 | "type": "GENERAL", 28 | "contentMimeType": "application/pdf", 29 | "extension": "pdf", 30 | "size": 3190, 31 | "created": "2025-02-17T13:48:28.000Z", 32 | "createdBy": "user1a", 33 | "deleted": false, 34 | "_links": [ 35 | { 36 | "link": "http://localhost:8080/api/inventory/v1/files/32772", 37 | "rel": "self" 38 | }, 39 | { 40 | "link": "http://localhost:8080/api/inventory/v1/files/32772/file", 41 | "rel": "enclosure" 42 | } 43 | ] 44 | } 45 | ], 46 | "barcodes": [], 47 | "identifiers": [], 48 | "owner": { 49 | "id": -1, 50 | "username": "user1a", 51 | "email": "user@user.com", 52 | "firstName": "user", 53 | "lastName": "user", 54 | "homeFolderId": 124, 55 | "workbenchId": null, 56 | "hasPiRole": true, 57 | "hasSysAdminRole": false, 58 | "_links": [] 59 | }, 60 | "permittedActions": ["READ", "UPDATE", "CHANGE_OWNER"], 61 | "sharingMode": "OWNER_GROUPS", 62 | "sharedWith": [ 63 | { 64 | "group": { 65 | "id": 1, 66 | "globalId": "GP1", 67 | "name": "userGroup", 68 | "uniqueName": "userGroupKHqD", 69 | "_links": [] 70 | }, 71 | "shared": false, 72 | "itemOwnerGroup": true 73 | } 74 | ], 75 | "templateId": null, 76 | "templateVersion": null, 77 | "template": true, 78 | "revisionId": null, 79 | "version": 1, 80 | "historicalVersion": false, 81 | "subSampleAlias": { 82 | "alias": "aliquot", 83 | "plural": "aliquots" 84 | }, 85 | "defaultUnitId": 3, 86 | "subSamplesCount": 1, 87 | "storageTempMin": null, 88 | "storageTempMax": null, 89 | "sampleSource": "LAB_CREATED", 90 | "expiryDate": null, 91 | "fields": [], 92 | "extraFields": [], 93 | "subSamples": [ 94 | { 95 | "id": 1, 96 | "globalId": "SS1", 97 | "name": "Sample", 98 | "description": null, 99 | "created": "2025-02-17T09:33:35.000Z", 100 | "createdBy": "user1a", 101 | "lastModified": "2025-02-17T09:33:35.608Z", 102 | "modifiedBy": "user1a", 103 | "modifiedByFullName": null, 104 | "deleted": false, 105 | "deletedDate": null, 106 | "iconId": -1, 107 | "tags": [], 108 | "attachments": [], 109 | "barcodes": [], 110 | "identifiers": [], 111 | "owner": { 112 | "id": -1, 113 | "username": "user1a", 114 | "email": "user@user.com", 115 | "firstName": "user", 116 | "lastName": "user", 117 | "homeFolderId": 124, 118 | "workbenchId": null, 119 | "hasPiRole": true, 120 | "hasSysAdminRole": false, 121 | "_links": [] 122 | }, 123 | "permittedActions": [], 124 | "sharingMode": "OWNER_GROUPS", 125 | "quantity": { 126 | "numericValue": 1, 127 | "unitId": 3 128 | }, 129 | "type": "SUBSAMPLE", 130 | "parentContainers": [], 131 | "parentLocation": null, 132 | "lastNonWorkbenchParent": null, 133 | "lastMoveDate": null, 134 | "revisionId": null, 135 | "deletedOnSampleDeletion": false, 136 | "storedInContainer": false, 137 | "_links": [ 138 | { 139 | "link": "http://localhost:8080/api/inventory/v1/subSamples/1", 140 | "rel": "self" 141 | }, 142 | { 143 | "link": "http://localhost:8080/api/inventory/v1/sampleTemplates/1/icon/-1", 144 | "rel": "image" 145 | }, 146 | { 147 | "link": "http://localhost:8080/api/inventory/v1/sampleTemplates/1/icon/-1", 148 | "rel": "thumbnail" 149 | } 150 | ] 151 | } 152 | ], 153 | "_links": [ 154 | { 155 | "link": "http://localhost:8080/api/inventory/v1/sampleTemplates/1", 156 | "rel": "self" 157 | }, 158 | { 159 | "link": "http://localhost:8080/api/inventory/v1/sampleTemplates/1/icon/-1", 160 | "rel": "icon" 161 | }, 162 | { 163 | "link": "http://localhost:8080/api/inventory/v1/sampleTemplates/1/icon/-1", 164 | "rel": "thumbnail" 165 | }, 166 | { 167 | "link": "http://localhost:8080/api/inventory/v1/sampleTemplates/1/icon/-1", 168 | "rel": "image" 169 | } 170 | ], 171 | "canBeDeleted": true 172 | } 173 | -------------------------------------------------------------------------------- /rspace_client/tests/elnapi_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Created on Mon Jun 7 08:38:55 2021 5 | 6 | @author: richard 7 | """ 8 | import os, os.path 9 | 10 | import rspace_client.eln.eln as cli 11 | from rspace_client.eln.dcs import DocumentCreationStrategy 12 | 13 | 14 | from rspace_client.tests.base_test import BaseApiTest, random_string, get_datafile 15 | from rspace_client.client_base import Pagination 16 | 17 | 18 | class ELNClientAPIIntegrationTest(BaseApiTest): 19 | def setUp(self): 20 | self.assertClientCredentials() 21 | self.api = cli.ELNClient(self.rspace_url, self.rspace_apikey) 22 | 23 | def test_get_status(self): 24 | resp = self.api.get_status() 25 | self.assertEqual("OK", resp["message"]) 26 | 27 | def test_upload_downloadfile(self): 28 | file = get_datafile("fish_method.doc") 29 | try: 30 | with open(file, "rb") as to_upload: 31 | rs_file = self.api.upload_file(to_upload) 32 | rs_get = self.api.download_file(rs_file["id"], "out.doc") 33 | finally: 34 | os.remove(os.path.join(os.getcwd(), "out.doc")) 35 | 36 | def test_get_documents(self): 37 | resp = self.api.get_documents() 38 | self.assertTrue(resp["totalHits"] > 0) 39 | self.assertTrue(len(resp["documents"]) > 0) 40 | 41 | def test_stream_documents(self): 42 | doc_gen = self.api.stream_documents(pagination=Pagination(page_size=1)) 43 | d1 = next(doc_gen) 44 | d2 = next(doc_gen) 45 | self.assertNotEqual(d1["id"], d2["id"]) 46 | 47 | def test_get_documents_by_id(self): 48 | resp = self.api.get_documents() 49 | first_id = resp["documents"][0]["id"] 50 | doc = self.api.get_document(first_id) 51 | self.assertEqual(first_id, doc["id"]) 52 | 53 | def test_create_document(self): 54 | nameStr = random_string(10) 55 | tag_str = random_string(5) 56 | resp = self.api.create_document(name=nameStr, tags=tag_str) 57 | self.assertEqual(nameStr, resp["name"]) 58 | self.assertEqual(tag_str, resp["tags"]) 59 | 60 | def test_import_tree(self): 61 | tree_dir = get_datafile("tree") 62 | res = self.api.import_tree(tree_dir) 63 | self.assertEqual("OK", res["status"]) 64 | ## f, 2sf, and 3files in each sf 65 | self.assertEqual(9, len(res["path2Id"].keys())) 66 | 67 | def test_import_tree_include_dot_files(self): 68 | tree_dir = get_datafile("tree") 69 | res = self.api.import_tree(tree_dir, ignore_hidden_folders=False) 70 | self.assertEqual("OK", res["status"]) 71 | ## f, 2sf, and 3files in each sf + hidden 72 | self.assertTrue(len(res["path2Id"].keys()) >= 9) 73 | 74 | def test_import_tree_summary_doc_only(self): 75 | tree_dir = get_datafile("tree") 76 | res = self.api.import_tree( 77 | tree_dir, doc_creation=DocumentCreationStrategy.SUMMARY_DOC 78 | ) 79 | self.assertEqual("OK", res["status"]) 80 | ## original folder + summary doc 81 | self.assertEqual(2, len(res["path2Id"].keys())) 82 | 83 | def test_import_tree_summary_doc_per_subfolder(self): 84 | tree_dir = get_datafile("tree") 85 | res = self.api.import_tree( 86 | tree_dir, doc_creation=DocumentCreationStrategy.DOC_PER_SUBFOLDER 87 | ) 88 | self.assertEqual("OK", res["status"]) 89 | ## original folder + 2 sf + 2 summary docs 90 | self.assertEqual(5, len(res["path2Id"].keys())) 91 | 92 | def test_import_tree_into_subfolder(self): 93 | folder = self.api.create_folder("tree-root") 94 | tree_dir = get_datafile("tree") 95 | res = self.api.import_tree(tree_dir, parent_folder_id=folder["id"]) 96 | self.assertEqual("OK", res["status"]) 97 | ## f, 2sf, and 3files in each sf 98 | self.assertEqual(9, len(res["path2Id"].keys())) 99 | 100 | def test_export_all_work_with_log_file(self): 101 | file_path = "tmp-export.zip" 102 | log_file = "tmp-log.txt" 103 | try: 104 | self.api.export_and_download("html", "user", file_path, progress_log=log_file, wait_between_requests=5) 105 | self.assertTrue(os.path.getsize(log_file) > 0) 106 | self.assertTrue(os.path.getsize(file_path) > 0) 107 | except BaseException as e: 108 | self.fail("Unexpected exception" + str(e)) 109 | finally: 110 | os.remove(file_path) 111 | os.remove(log_file) 112 | 113 | def test_export_all_work_with_no_file(self): 114 | file_path = "tmp-export.zip" 115 | try: 116 | self.api.export_and_download("html", "user", file_path, wait_between_requests=5) 117 | self.assertTrue(os.path.getsize(file_path) > 0) 118 | except BaseException as e: 119 | self.fail("Unexpected exception" + str(e)) 120 | finally: 121 | os.remove(file_path) 122 | 123 | 124 | -------------------------------------------------------------------------------- /rspace_client/tests/data/calculation_table.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 |
 ABCDEFGHIJKL
1MonthMeanMaxMin        
2Jan6134        
3Feb816-1        
4Mar9112        
5Apr12164        
6May12229        
7June132112        
8Aug161910        
9Sep13269        
10Oct9146        
11Nov71312        
12Dev4102        
203 |
204 | -------------------------------------------------------------------------------- /rspace_client/eln/fs.py: -------------------------------------------------------------------------------- 1 | from fs.base import FS 2 | from rspace_client.eln import eln 3 | from typing import Optional, List, Text, BinaryIO, Mapping, Any 4 | from fs.info import Info 5 | from fs.permissions import Permissions 6 | from fs.subfs import SubFS 7 | from fs import errors 8 | from fs.mode import Mode 9 | from io import BytesIO 10 | from ..fs_utils import path_to_id 11 | 12 | def is_folder(path): 13 | return path.split('/')[-1][:2] == "GF" 14 | 15 | 16 | class GalleryInfo(Info): 17 | def __init__(self, obj, *args, **kwargs) -> None: 18 | super().__init__(obj, *args, **kwargs) 19 | self.globalId = obj['rspace']['globalId']; 20 | 21 | 22 | class GalleryFilesystem(FS): 23 | 24 | def __init__(self, server: str, api_key: str) -> None: 25 | super(GalleryFilesystem, self).__init__() 26 | self.eln_client = eln.ELNClient(server, api_key) 27 | self.gallery_id = next(file['id'] for file in self.eln_client.list_folder_tree()['records'] if file['name'] == 'Gallery') 28 | 29 | def getinfo(self, path, namespaces=None) -> Info: 30 | is_file = path.split('/')[-1][:2] == "GL" 31 | info = None 32 | if is_folder(path): 33 | info = self.eln_client.get_folder(path_to_id(path)) 34 | if is_file: 35 | info = self.eln_client.get_file_info(path_to_id(path)) 36 | if info is None: 37 | raise errors.ResourceNotFound(path) 38 | return GalleryInfo({ 39 | "basic": { 40 | "name": (is_folder(path) and "GF" or "GL") + path_to_id(path), 41 | "is_dir": is_folder(path), 42 | }, 43 | "details": { 44 | "size": info.get('size', 0), 45 | "type": is_folder(path) and "1" or "2", 46 | }, 47 | "rspace": info, 48 | }) 49 | 50 | def listdir(self, path: Text) -> List[Text]: 51 | id = path in [u'.', u'/', u'./'] and self.gallery_id or path_to_id(path) 52 | return [file['globalId'] for file in self.eln_client.list_folder_tree(id)['records']] 53 | 54 | def makedir(self, path: Text, permissions: Optional[Permissions] = None, recreate: bool = False) -> SubFS[FS]: 55 | new_folder_name = path.split('/')[-1] 56 | parent_id = path_to_id(path[:-(len(new_folder_name) + 1)]) 57 | new_id = self.eln_client.create_folder(new_folder_name, parent_id)['id'] 58 | return self.opendir("/GF" + str(new_id)) 59 | 60 | def openbin(self, path: Text, mode: Text = 'r', buffering: int = -1, **options) -> BinaryIO: 61 | """ 62 | This method is added for conformance with the FS interface, but in 63 | almost all circumstances you probably want to be using upload and 64 | download directly as they have more information available e.g. uploaded 65 | files will have the same name as the source file when called directly 66 | """ 67 | _mode = Mode(mode) 68 | if _mode.reading and _mode.writing: 69 | raise errors.Unsupported("read/write mode") 70 | if _mode.appending: 71 | raise errors.Unsupported("appending mode") 72 | if _mode.exclusive: 73 | raise errors.Unsupported("exclusive mode") 74 | if _mode.truncate: 75 | raise errors.Unsupported("truncate mode") 76 | 77 | if _mode.reading: 78 | file = BytesIO() 79 | self.download(path, file) 80 | file.seek(0) 81 | return file 82 | 83 | if _mode.writing: 84 | file = BytesIO() 85 | def upload_callback(): 86 | file.seek(0) 87 | self.upload(path, file) 88 | file.close = upload_callback 89 | return file 90 | 91 | raise errors.Unsupported("mode {!r}".format(_mode)) 92 | 93 | def remove(self, path: Text) -> None: 94 | raise NotImplementedError 95 | 96 | def removedir(self, path: Text, recursive: bool = False, force: bool = False) -> None: 97 | if path in [u'.', u'/', u'./']: 98 | raise errors.RemoveRootError() 99 | if (not is_folder(path)): 100 | raise errors.DirectoryExpected(path) 101 | if len(self.listdir(path)) > 0: 102 | raise errors.DirectoryNotEmpty(path) 103 | self.eln_client.delete_folder(path_to_id(path)) 104 | 105 | def setinfo(self, path: Text, info: Mapping[Text, Mapping[Text, object]]) -> None: 106 | raise NotImplementedError 107 | 108 | def download(self, path: Text, file: BinaryIO, chunk_size: Optional[int] = None, **options: Any) -> None: 109 | if chunk_size is not None: 110 | self.eln_client.download_file(path_to_id(path), file, chunk_size) 111 | else: 112 | self.eln_client.download_file(path_to_id(path), file) 113 | 114 | def upload(self, path: Text, file: BinaryIO, chunk_size: Optional[int] = None, **options: Any) -> None: 115 | """ 116 | :param path: Global Id of a folder in the appropriate gallery section or 117 | else if empty then the upload will be placed in the Api 118 | Imports folder of the relevant gallery section 119 | :param file: a binary file object to be uploaded 120 | """ 121 | self.eln_client.upload_file(file, path_to_id(path) if path else None) 122 | -------------------------------------------------------------------------------- /rspace_client/tests/inv_attachment_fs_test.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch, MagicMock, ANY 2 | import unittest 3 | from io import BytesIO 4 | from rspace_client.inv.attachment_fs import InventoryAttachmentFilesystem 5 | import json 6 | from rspace_client.tests import base_test 7 | 8 | def mock_requests_get(url, *args, **kwargs): 9 | mock_response = MagicMock() 10 | if url.endswith('/files/123'): 11 | mock_response.json.return_value = { 12 | 'id': 32768, 13 | 'globalId': 'IF32768', 14 | 'name': 'file.png', 15 | 'parentGlobalId': 'SS4', 16 | 'mediaFileGlobalId': None, 17 | 'type': 'GENERAL', 18 | 'contentMimeType': 'image/png', 19 | 'extension': 'png', 20 | 'size': 40721, 21 | 'created': '2025-02-17T11:25:03.000Z', 22 | 'createdBy': 'user1a', 23 | 'deleted': False, 24 | '_links': [ 25 | { 26 | 'link': 'https://example.com/api/inventory/v1/files/32768', 27 | 'rel': 'self' 28 | }, 29 | { 30 | 'link': 'https://example.com/api/inventory/v1/files/32768/file', 31 | 'rel': 'enclosure' 32 | } 33 | ] 34 | } 35 | elif url.endswith('/samples/123'): 36 | with open(base_test.get_datafile('sample_by_id.json')) as f: 37 | mock_response.json.return_value = json.load(f) 38 | elif url.endswith('/containers/123?includeContent=False'): 39 | with open(base_test.get_datafile('container_by_id.json')) as f: 40 | mock_response.json.return_value = json.load(f) 41 | elif url.endswith('/subSamples/123'): 42 | with open(base_test.get_datafile('subsample_by_id.json')) as f: 43 | mock_response.json.return_value = json.load(f) 44 | elif url.endswith('/sampleTemplates/123'): 45 | with open(base_test.get_datafile('sample_template_by_id.json')) as f: 46 | mock_response.json.return_value = json.load(f) 47 | else: 48 | mock_response.json.return_value = {} 49 | mock_response.headers = {'Content-Type': 'application/json'} 50 | return mock_response 51 | 52 | def mock_requests(url, *args, **kwargs): 53 | mock_response = MagicMock() 54 | mock_response.json.return_value = {} 55 | mock_response.headers = {'Content-Type': 'application/json'} 56 | return mock_response 57 | 58 | class InvAttachmentFilesystemTest(unittest.TestCase): 59 | 60 | @patch('requests.get', side_effect=mock_requests_get) 61 | def setUp(self, mock_get) -> None: 62 | super().setUp() 63 | self.fs = InventoryAttachmentFilesystem("https://example.com", "api_key") 64 | 65 | @patch('requests.get', side_effect=mock_requests_get) 66 | def test_get_info_folder(self, mock_get): 67 | folder_info = self.fs.getinfo("IF123") 68 | self.assertEqual("IF123", folder_info.raw["basic"]["name"]) 69 | self.assertFalse(folder_info.raw["basic"]["is_dir"]) 70 | self.assertEqual(40721, folder_info.raw["details"]["size"]) 71 | 72 | @patch('requests.request', side_effect=mock_requests) 73 | def test_remove(self, mock_request): 74 | self.fs.remove("IF123") 75 | mock_request.assert_called_with('DELETE', 'https://example.com/api/inventory/v1/files/123', json=None, headers=ANY) 76 | 77 | @patch('requests.get') 78 | def test_download(self, mock_get): 79 | mock_response = MagicMock() 80 | mock_response.iter_content = MagicMock(return_value=[b'chunk1', b'chunk2', b'chunk3']) 81 | mock_get.return_value = mock_response 82 | file_obj = BytesIO() 83 | self.fs.download('/IF123', file_obj) 84 | file_obj.seek(0) 85 | self.assertEqual(file_obj.read(), b'chunk1chunk2chunk3') 86 | mock_get.assert_called_once_with( 87 | 'https://example.com/api/inventory/v1/files/123/file', 88 | headers=ANY 89 | ) 90 | 91 | @patch('requests.post') 92 | def test_upload(self, mock_post): 93 | mock_response = MagicMock() 94 | mock_response.json.return_value = {'id': '456'} 95 | mock_post.return_value = mock_response 96 | file_obj = BytesIO(b'test file content') 97 | self.fs.upload('/SS123', file_obj) 98 | mock_post.assert_called_once_with( 99 | 'https://example.com/api/inventory/v1/files', 100 | data={'fileSettings': '{"parentGlobalId": "SS123"}'}, 101 | files={'file': file_obj}, 102 | headers=ANY 103 | ) 104 | 105 | @patch('requests.get', side_effect=mock_requests_get) 106 | def test_listdir_container(self, mock_get): 107 | result = self.fs.listdir('/IC123') 108 | self.assertEqual(result, ['IF32772']) 109 | 110 | @patch('requests.get', side_effect=mock_requests_get) 111 | def test_listdir_sample(self, mock_get): 112 | result = self.fs.listdir('/SA123') 113 | self.assertEqual(result, ['IF32772']) 114 | 115 | @patch('requests.get', side_effect=mock_requests_get) 116 | def test_listdir_subsample(self, mock_get): 117 | result = self.fs.listdir('/SS123') 118 | self.assertEqual(result, ['IF32772']) 119 | 120 | @patch('requests.get', side_effect=mock_requests_get) 121 | def test_listdir_sample_template(self, mock_get): 122 | result = self.fs.listdir('/IT123') 123 | self.assertEqual(result, ['IF32772']) 124 | -------------------------------------------------------------------------------- /rspace_client/eln/filetree_importer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Created on Thu Nov 4 20:56:36 2021 5 | 6 | @author: richard 7 | """ 8 | import os 9 | import re 10 | 11 | from rspace_client.eln.dcs import DocumentCreationStrategy as DCS 12 | 13 | 14 | def assert_is_readable_dir(data_dir): 15 | if not os.access(data_dir, os.R_OK): 16 | raise ValueError(f"{data_dir} is not readable") 17 | if not os.path.isdir(data_dir): 18 | raise ValueError(f"{data_dir} is not a directory") 19 | 20 | 21 | class TreeImporter: 22 | def __init__(self, eln_client): 23 | self.cli = eln_client 24 | 25 | def _create_file_linking_doc(self, content, parent_folder_id, name, path2Id): 26 | rs_doc = self.cli.create_document( 27 | name, 28 | parent_folder_id=parent_folder_id, 29 | fields=[{"content": content}], 30 | ) 31 | path2Id[name] = rs_doc["globalId"] 32 | 33 | def _generate_summary_content(self, rs_files: list) -> str: 34 | s = "" 35 | for o, r in rs_files: 36 | s = s + f"" 37 | s = s + "
Original file nameRSpace file
{o}
" 38 | return s 39 | 40 | def import_tree( 41 | self, 42 | data_dir: str, 43 | parent_folder_id: int = None, 44 | ignore_hidden_folders: bool = True, 45 | halt_on_error: bool = False, 46 | doc_creation=DCS.DOC_PER_FILE, 47 | ) -> dict: 48 | def _sanitize(path): 49 | return re.sub(r"/", "-", path) 50 | 51 | def _filter_dot_files(subdirList): 52 | for sf in subdirList: 53 | if os.path.basename(sf)[0] == ".": 54 | subdirList.remove(sf) 55 | 56 | assert_is_readable_dir(data_dir) 57 | path2Id = {} 58 | 59 | def _is_subfolder_tree_required(sf, doc_creation): 60 | return (sf not in path2Id.keys()) and ( 61 | (DCS.DOC_PER_FILE == doc_creation) 62 | or (DCS.DOC_PER_SUBFOLDER == doc_creation) 63 | ) 64 | 65 | # maintain mapping of local directory paths to RSpace folder Ids 66 | result = {} 67 | result["status"] = "FAILED" 68 | result["path2Id"] = path2Id 69 | ## replace any forward slashes (e.g in windows path names) 70 | 71 | folder = self.cli.create_folder( 72 | _sanitize(os.path.basename(data_dir)), parent_folder_id 73 | ) 74 | path2Id[data_dir] = folder["globalId"] 75 | all_rs_files = [] 76 | 77 | for dirName, subdirList, fileList in os.walk(data_dir): 78 | if ignore_hidden_folders: 79 | _filter_dot_files(subdirList) 80 | _filter_dot_files(fileList) 81 | for sf in subdirList: 82 | 83 | if _is_subfolder_tree_required(sf, doc_creation): 84 | rs_folder = self.cli.create_folder( 85 | _sanitize(os.path.basename(sf)), path2Id[dirName] 86 | ) 87 | sf_path = os.path.join(dirName, sf) 88 | path2Id[sf_path] = rs_folder["globalId"] 89 | rs_files_in_subdir = [] 90 | 91 | for f in fileList: 92 | try: 93 | with open(os.path.join(dirName, f), "rb") as reader: 94 | rs_file = self.cli.upload_file(reader) 95 | all_rs_files.append((f, rs_file)) 96 | rs_files_in_subdir.append((f, rs_file)) 97 | except IOError as x: 98 | if halt_on_error: 99 | self.cli.serr( 100 | f"{x} raised while opening {f} - halting on error" 101 | ) 102 | result["status"] = "HALTED_ON_ERROR" 103 | return result 104 | else: 105 | self.cli.serr(f"{x} raised while opening {f} - continuing") 106 | continue ## next file 107 | doc_name = os.path.splitext(f)[0] 108 | 109 | ## just puts link to the document 110 | if DCS.DOC_PER_FILE == doc_creation: 111 | parent_folder_id = path2Id[dirName] 112 | content_string = f"" 113 | self._create_file_linking_doc( 114 | content_string, parent_folder_id, doc_name, path2Id 115 | ) 116 | if (DCS.DOC_PER_SUBFOLDER == doc_creation) and ( 117 | len(rs_files_in_subdir) > 0 118 | ): 119 | parent_folder_id = path2Id[dirName] 120 | content = self._generate_summary_content(rs_files_in_subdir) 121 | summary_name = f"Summary-doc{rs_files_in_subdir[0][1]['created']}" 122 | self._create_file_linking_doc( 123 | content, parent_folder_id, summary_name, path2Id 124 | ) 125 | if (DCS.SUMMARY_DOC == doc_creation) and (len(all_rs_files) > 0): 126 | content = self._generate_summary_content(all_rs_files) 127 | summary_name = f"Summary-doc{all_rs_files[0][1]['created']}" 128 | self._create_file_linking_doc(content, folder["id"], summary_name, path2Id) 129 | result["status"] = "OK" 130 | return result 131 | -------------------------------------------------------------------------------- /rspace_client/inv/attachment_fs.py: -------------------------------------------------------------------------------- 1 | # This script implements a pyfilesystem for Inventory attachments. 2 | # Inventory records -- containers, samples, subsamples, etc -- are modelled as 3 | # directories, with each containing a set of attachments: the files. 4 | 5 | from fs.base import FS 6 | from rspace_client.inv import inv 7 | from typing import Optional, List, Text, BinaryIO, Mapping, Any 8 | from fs.info import Info 9 | from fs.permissions import Permissions 10 | from fs.subfs import SubFS 11 | from fs import errors 12 | from fs.mode import Mode 13 | from io import BytesIO 14 | from ..fs_utils import path_to_id 15 | 16 | class InventoryAttachmentInfo(Info): 17 | def __init__(self, obj, *args, **kwargs) -> None: 18 | super().__init__(obj, *args, **kwargs) 19 | self.globalId = obj['rspace']['globalId']; 20 | 21 | class InventoryAttachmentFilesystem(FS): 22 | 23 | def __init__(self, server: str, api_key: str) -> None: 24 | super(InventoryAttachmentFilesystem, self).__init__() 25 | self.inv_client = inv.InventoryClient(server, api_key) 26 | 27 | def getinfo(self, path, namespaces=None) -> Info: 28 | is_attachment = path.split('/')[-1][:2] == "IF" 29 | if not is_attachment: 30 | raise errors.ResourceNotFound(path) 31 | info = self.inv_client.get_attachment_by_id(path_to_id(path)) 32 | return InventoryAttachmentInfo({ 33 | "basic": { 34 | "name": path, 35 | "is_dir": False, 36 | }, 37 | "details": { 38 | "size": info.get('size', 0), 39 | "type": "2", 40 | }, 41 | "rspace": info, 42 | }) 43 | 44 | def listdir(self, path: Text) -> List[Text]: 45 | global_id_prefix = path.split('/')[-1][:2] 46 | if global_id_prefix == "IC": 47 | dict = self.inv_client.get_container_by_id(path_to_id(path)) 48 | if 'attachments' in dict: 49 | return [attachment['globalId'] for attachment in dict['attachments']] 50 | if global_id_prefix == "SA": 51 | dict = self.inv_client.get_sample_by_id(path_to_id(path)) 52 | if 'attachments' in dict: 53 | return [attachment['globalId'] for attachment in dict['attachments']] 54 | if global_id_prefix == "SS": 55 | dict = self.inv_client.get_subsample_by_id(path_to_id(path)) 56 | if 'attachments' in dict: 57 | return [attachment['globalId'] for attachment in dict['attachments']] 58 | if global_id_prefix == "IT": 59 | dict = self.inv_client.get_sample_template_by_id(path_to_id(path)) 60 | if 'attachments' in dict: 61 | return [attachment['globalId'] for attachment in dict['attachments']] 62 | raise errors.ResourceNotFound(path) 63 | 64 | def makedir(self, path: Text, permissions: Optional[Permissions] = None, recreate: bool = False) -> SubFS[FS]: 65 | raise NotImplementedError() 66 | 67 | def openbin(self, path: Text, mode: Text = 'r', buffering: int = -1, **options) -> BinaryIO: 68 | """ 69 | This method is added for conformance with the FS interface, but in 70 | almost all circumstances you probably want to be using upload and 71 | download directly as they have more information available e.g. uploaded 72 | files will have the same name as the source file when called directly 73 | """ 74 | _mode = Mode(mode) 75 | if _mode.reading and _mode.writing: 76 | raise errors.Unsupported("read/write mode") 77 | if _mode.appending: 78 | raise errors.Unsupported("appending mode") 79 | if _mode.exclusive: 80 | raise errors.Unsupported("exclusive mode") 81 | if _mode.truncate: 82 | raise errors.Unsupported("truncate mode") 83 | 84 | if _mode.reading: 85 | file = BytesIO() 86 | self.download(path, file) 87 | file.seek(0) 88 | return file 89 | 90 | if _mode.writing: 91 | file = BytesIO() 92 | def upload_callback(): 93 | file.seek(0) 94 | self.upload(path, file) 95 | file.close = upload_callback 96 | return file 97 | 98 | raise errors.Unsupported("mode {!r}".format(_mode)) 99 | 100 | def remove(self, path: Text) -> None: 101 | is_attachment = path.split('/')[-1][:2] == "IF" 102 | if not is_attachment: 103 | raise errors.ResourceNotFound(path) 104 | self.inv_client.delete_attachment_by_id(path_to_id(path)) 105 | 106 | def removedir(self, path: Text) -> None: 107 | raise NotImplementedError() 108 | 109 | def setinfo(self, path: Text, info: Mapping[Text, Any]) -> None: 110 | raise NotImplementedError() 111 | 112 | def download(self, path: Text, file: BinaryIO, chunk_size: Optional[int] = None, **options: Any) -> None: 113 | is_attachment = path.split('/')[-1][:2] == "IF" 114 | if not is_attachment: 115 | raise errors.ResourceNotFound(path) 116 | if chunk_size is not None: 117 | self.inv_client.download_attachment_by_id(path_to_id(path), file, chunk_size) 118 | else: 119 | self.inv_client.download_attachment_by_id(path_to_id(path), file) 120 | 121 | def upload(self, path: Text, file: BinaryIO, chunk_size: Optional[int] = None, **options: Any) -> None: 122 | global_id_prefix = path.split('/')[-1][:2] 123 | if global_id_prefix not in ["IC", "SS", "SA", "IT"]: 124 | raise errors.ResourceNotFound(path) 125 | self.inv_client.upload_attachment_by_global_id(path.split('/')[-1], file) 126 | -------------------------------------------------------------------------------- /rspace_client/tests/template_builder_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Created on Mon Jun 7 08:38:55 2021 5 | 6 | @author: richard 7 | """ 8 | 9 | 10 | from rspace_client.inv.template_builder import TemplateBuilder 11 | import unittest 12 | import datetime as dt 13 | 14 | builder = None 15 | 16 | 17 | class TemplateBuilderTest(unittest.TestCase): 18 | def test_invalid_unit(self): 19 | self.assertRaises( 20 | ValueError, 21 | TemplateBuilder, 22 | "name", 23 | "unknownUnit", 24 | ) 25 | 26 | def test_add_radio(self): 27 | builder = TemplateBuilder("myTemplate", "ml") 28 | builder.radio("r1", ["a", "b", "c"], "a") 29 | self.assertEqual(1, builder.field_count()) 30 | self.assertEqual(["a"], builder._fields()[0]["selectedOptions"]) 31 | 32 | def test_add_radio_selected_ignored_if_not_option(self): 33 | builder = TemplateBuilder("myTemplate", "ml") 34 | builder.radio("r1", ["a", "b", "c"], "XXXX") 35 | self.assertFalse("selectedOptions" in builder._fields()[0]) 36 | 37 | def test_add_radio_with_no_selection(self): 38 | builder = TemplateBuilder("myTemplate", "ml") 39 | builder.radio("r1", options=["a", "b", "c"]) 40 | self.assertFalse("selectedOptions" in builder._fields()[0]) 41 | 42 | def test_add_choice(self): 43 | builder = TemplateBuilder("myTemplate", "ml") 44 | builder.choice("r1", ["a", "b", "c"], ["a"]) 45 | self.assertEqual(1, builder.field_count()) 46 | self.assertEqual(["a"], builder._fields()[0]["selectedOptions"]) 47 | 48 | def test_add_choice_selected_ignored_if_not_option(self): 49 | builder = TemplateBuilder("myTemplate", "ml") 50 | builder.choice("r1", ["a", "b", "c"], ["XXXX"]) 51 | self.assertFalse("selectedOptions" in builder._fields()[0]) 52 | 53 | def test_add_text_field(self): 54 | builder = TemplateBuilder("myTemplate", "ml") 55 | builder.text("textfield", "defaultVal") 56 | self.assertEqual(1, builder.field_count()) 57 | 58 | def test_add_string_field(self): 59 | builder = TemplateBuilder("myTemplate", "ml") 60 | builder.string("stringfield", "defaultVal") 61 | self.assertEqual(1, builder.field_count()) 62 | 63 | def test_add_number_field(self): 64 | builder = TemplateBuilder("myTemplate", "ml") 65 | builder.number("pH", 7.2).number("index", 1) 66 | self.assertEqual(2, builder.field_count()) 67 | 68 | def test_add_number_field_requires_number(self): 69 | builder = TemplateBuilder("myTemplate", "ml") 70 | 71 | self.assertRaises( 72 | ValueError, 73 | builder.number, 74 | "pH", 75 | "XXX", 76 | ) 77 | 78 | def test_add_number_field_no_default(self): 79 | builder = TemplateBuilder("myTemplate", "ml") 80 | builder.number("pH").number("index") 81 | self.assertEqual(2, builder.field_count()) 82 | 83 | def test_name_required(self): 84 | builder = TemplateBuilder("myTemplate", "ml") 85 | self.assertRaises( 86 | ValueError, 87 | builder.number, 88 | "", 89 | ) 90 | 91 | def test_add_date(self): 92 | builder = TemplateBuilder("myTemplate", "ml") 93 | builder.date("d", dt.date(2021, 10, 26)) 94 | self.assertEqual(1, builder.field_count()) 95 | 96 | builder.date("fromstr", "2021-10-26") 97 | self.assertEqual(2, builder.field_count()) 98 | 99 | builder.date("fromdate-time", dt.datetime.strptime("2021-10-26", "%Y-%m-%d")) 100 | self.assertEqual(3, builder.field_count()) 101 | contents = [a["content"] for a in builder._fields()] 102 | self.assertTrue(all(d == "2021-10-26" for d in contents)) 103 | 104 | def test_add_time(self): 105 | builder = TemplateBuilder("myTemplate", "ml") 106 | builder.time("d", dt.time(2, 30, 59)) 107 | self.assertEqual(1, builder.field_count()) 108 | 109 | builder.time("fromstr", "02:30:59") 110 | self.assertEqual(2, builder.field_count()) 111 | 112 | builder.time("fromdate-time", dt.datetime(2021, 10, 26, 2, 30, 59)) 113 | self.assertEqual(3, builder.field_count()) 114 | contents = [a["content"] for a in builder._fields()] 115 | self.assertTrue(all(d == "02:30:59" for d in contents)) 116 | 117 | def test_add_attachment(self): 118 | builder = TemplateBuilder("myTemplate", "ml") 119 | desc = "A PDF of a CoSH form" 120 | builder.attachment("Safety data", desc) 121 | self.assertEqual(desc, builder._fields()[0]["content"]) 122 | 123 | builder.attachment("Safety data") 124 | self.assertFalse("content" in builder._fields()[1]) 125 | 126 | builder.attachment("Safety data", "") 127 | self.assertFalse("content" in builder._fields()[1]) 128 | 129 | builder.attachment("Safety data", " ") 130 | self.assertFalse("content" in builder._fields()[2]) 131 | 132 | def test_add_uri(self): 133 | builder = TemplateBuilder("myTemplate", "ml") 134 | builder.uri("A URI", "https://www.google.com") 135 | builder.uri("no default") 136 | self.assertEqual(2, builder.field_count()) 137 | self.assertEqual("https://www.google.com", builder._fields()[0]["content"]) 138 | self.assertRaises( 139 | ValueError, 140 | builder.uri, 141 | "name", 142 | "aa://www.goog[le.com:XXXX", 143 | ) 144 | 145 | def test_build(self): 146 | builder = TemplateBuilder("water sample", "ml").text("notes").number("pH", 7) 147 | to_post = builder.build() 148 | -------------------------------------------------------------------------------- /examples/freezer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Created on Fri Jan 28 20:04:29 2022 5 | 6 | @author: richardadams 7 | """ 8 | 9 | #%% 10 | import sys, time 11 | import rspace_client 12 | 13 | cli = rspace_client.utils.createInventoryClient() 14 | from rspace_client.inv.inv import ( 15 | GridContainer, 16 | InventoryClient, 17 | ByRow, 18 | ByColumn, 19 | SamplePost, 20 | GridContainerPost, 21 | ) 22 | 23 | #%% 24 | 25 | 26 | class FreezerCreator: 27 | def __init__( 28 | self, 29 | cli, 30 | shelves_per_freezer: int, 31 | racks_per_shelf: int, 32 | trays_per_rack: int, 33 | boxes_per_tray: int, 34 | ): 35 | self.shelves_per_freezer = shelves_per_freezer 36 | self.racks_per_shelf = racks_per_shelf 37 | self.trays_per_rack = trays_per_rack 38 | self.boxes_per_tray = boxes_per_tray 39 | self.cli = cli 40 | 41 | def create_freezer(self, name: str): 42 | print("Creating freezer", file=sys.stderr) 43 | root = self.create_tier(1, name, self.shelves_per_freezer, 1)[0] 44 | 45 | ## shelves 46 | print(f"Creating {shelves_per_freezer} shelves", file=sys.stderr) 47 | shelves = self.create_tier( 48 | self.shelves_per_freezer, "shelf", self.racks_per_shelf, 1 49 | ) 50 | self.add_to_parent_tier([root], 1, self.shelves_per_freezer, shelves) 51 | 52 | ### racks 53 | racks_total = racks_per_shelf * shelves_per_freezer 54 | print(f"Creating {racks_total} racks", file=sys.stderr) 55 | racks = self.create_tier(racks_total, "rack", self.trays_per_rack, 1) 56 | self.add_to_parent_tier( 57 | shelves, self.shelves_per_freezer, self.racks_per_shelf, racks 58 | ) 59 | 60 | ### Trays 61 | trays_total = racks_total * trays_per_rack 62 | print(f"Creating {trays_total} trays", file=sys.stderr) 63 | trays = self.create_tier(trays_total, "tray", self.boxes_per_tray, 1) 64 | self.add_to_parent_tier(racks, self.racks_per_shelf, self.trays_per_rack, trays) 65 | 66 | ### Boxes 67 | boxes_total = trays_total * boxes_per_tray 68 | print(f"Creating {boxes_total} boxes", file=sys.stderr) 69 | boxes = self.create_tier(boxes_total, "box", 8, 12, store_samples=True) 70 | self.add_to_parent_tier(trays, self.trays_per_rack, self.boxes_per_tray, boxes) 71 | 72 | return { 73 | "freezer": [root], 74 | "shelves": shelves, 75 | "racks": racks, 76 | "trays": trays, 77 | "boxes": boxes, 78 | } 79 | 80 | def create_tier(self, n, name_prefix, rows, columns, store_samples=False): 81 | rc = [] 82 | posts = [] 83 | for i in range(0, n, 100): 84 | for j in range(i, min(n, i + 100) - i): 85 | c_post = GridContainerPost( 86 | f"{name_prefix}-{i}", rows, columns, can_store_samples=store_samples 87 | ) 88 | posts.append(c_post) 89 | 90 | results = self.cli.bulk_create_container(*posts) 91 | if not results.is_ok(): 92 | raise Exception("creating didn't work") 93 | items = [c["record"]["globalId"] for c in results.success_results()] 94 | rc.extend(items) 95 | 96 | return rc 97 | 98 | def add_to_parent_tier(self, parents, parents_per_gp, items_per_parent, items): 99 | for j in range(len(parents)): 100 | k = items_per_parent 101 | rack_slice = items[j * k : (j * k) + k] 102 | br = ByRow(1, 1, 1, k, *rack_slice) 103 | self.cli.add_items_to_grid_container(parents[j], br) 104 | 105 | 106 | #%% 107 | ### Configure the size of the freezer here ( or ask for input) 108 | print( 109 | "Please enter the number of shelves, racks, trays and boxes to create. Boxes are 12 x 8" 110 | ) 111 | shelves_per_freezer = int(input("Number of shelves? (1-5)")) 112 | racks_per_shelf = int(input("Number of racks per shelf? (1-5)")) 113 | trays_per_rack = int(input("Number of trays per rack? (1-5)")) 114 | boxes_per_tray = int(input("Number of boxes per tray? (1-4)")) 115 | 116 | params = (shelves_per_freezer, racks_per_shelf, trays_per_rack, boxes_per_tray) 117 | for i in params: 118 | if i < 1 or i > 5: 119 | raise ValueError(f"Input arguments {params}out of range") 120 | box_cols = 12 121 | box_rows = 8 122 | freezer_name = ( 123 | f"-80:{shelves_per_freezer}x{racks_per_shelf}x{trays_per_rack}x{boxes_per_tray}" 124 | ) 125 | user_freezer_name = input(f"Freezer name? ( default = {freezer_name})") 126 | if len(user_freezer_name) > 0: 127 | freezer_name = user_freezer_name 128 | print("Creating freezer", file=sys.stderr) 129 | freezerFactory = FreezerCreator( 130 | cli, shelves_per_freezer, racks_per_shelf, trays_per_rack, boxes_per_tray 131 | ) 132 | freezer = freezerFactory.create_freezer(freezer_name) 133 | 134 | 135 | #%% 136 | samples_created = 0 137 | total_samples_to_create = len(freezer["boxes"]) * box_cols 138 | print(f"Creating {total_samples_to_create} samples...", file=sys.stderr) 139 | for box in freezer["boxes"]: 140 | st = time.perf_counter() 141 | print(f"Creating samples for {box}", file=sys.stderr) 142 | posts = [SamplePost(f"s{i}", subsample_count=box_rows) for i in range(box_cols)] 143 | resp = cli.bulk_create_sample(*posts) 144 | col = 1 145 | samples_created = samples_created + box_cols 146 | print( 147 | f" created {box_cols} samples / {total_samples_to_create}", file=sys.stderr, 148 | ) 149 | 150 | ## we can move 12 samples at a time 151 | ss_ids = [] 152 | for result in resp.data["results"]: 153 | sample = result["record"] 154 | s_ids = [ss["globalId"] for ss in sample["subSamples"]] 155 | ss_ids.extend(s_ids) 156 | print(f"moving {box_cols} samples to {box}", file=sys.stderr) 157 | gp = ByColumn(col, 1, box_cols, box_rows, *ss_ids) 158 | cli.add_items_to_grid_container(box, gp) 159 | stop = time.perf_counter() 160 | print(f"Filling {box} took {(stop - st):.2f}s", file=sys.stderr) 161 | print("COMPLETED", file=sys.stderr) 162 | -------------------------------------------------------------------------------- /rspace_client/inv/sample_builder2.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Created on Fri Jan 14 21:43:11 2022 5 | 6 | @author: richard 7 | """ 8 | import re 9 | import pprint as p 10 | import datetime as dt 11 | 12 | 13 | import rspace_client.validators as v 14 | 15 | 16 | class AbsFieldBuilder: 17 | """ 18 | Base class of dynamically generated SampleTemplate classes 19 | """ 20 | 21 | def to_field_post(self): 22 | """ 23 | Generates a list of Fields to include in a create_sample POST. 24 | 25 | Returns 26 | ------- 27 | toPost : list 28 | An array of FieldPost 29 | 30 | """ 31 | toPost = [] 32 | for f in self._fields: 33 | if f in self._data: 34 | f_def = self._san2field[f] 35 | if f_def["type"].lower() == "choice": 36 | toPost.append({"selectedOptions": self._data[f]}) 37 | elif f_def["type"].lower() == "radio": 38 | toPost.append({"selectedOptions": [self._data[f]]}) 39 | elif f_def["type"].lower() == "time": 40 | t = self._data[f] 41 | toPost.append({"content": f"{t.hour}:{t.minute}"}) 42 | 43 | else: 44 | toPost.append({"content": str(self._data[f])}) 45 | else: 46 | toPost.append({}) 47 | 48 | return toPost 49 | 50 | 51 | class FieldBuilderGenerator: 52 | """ 53 | Helper class for creating Python classes from SampleTemplates, to help with 54 | setting field information into Samples. 55 | """ 56 | 57 | def generate_class(self, sample_template): 58 | """ 59 | Generates a Python class where attributes and validation is generated 60 | from the supplied SampleTemplate dict. The SampleTemplate should be the response from a 61 | POST to create a new sampleTemplate or a GET to retrieve SampleTemplate by its Id. 62 | 63 | Use of this class helps to provide type-saety and argument validation before submitting 64 | a create_sample POST to the RSpace server. 65 | 66 | Property names are generated from template field names, converting all characters to lower-case and 67 | replacing groups of non-alphanumeric characters with '_'. 68 | Leaing numbers are prefixed with 'n', e.g. 69 | 70 | Sample Template Field Name -> Python property name 71 | 72 | pH -> ph 73 | Notes and Queries -> notes_and_queries 74 | 5' sequence -> n5_sequence 75 | 76 | Validators and documentation for each property are generated from the fied definition , e.g: 77 | """ 78 | 79 | st_name = sample_template["name"] 80 | class_atts = {} 81 | _san2field = {} 82 | _fields = [] 83 | class_atts["_data"] = {} 84 | 85 | defs = sample_template["fields"] 86 | for f_def in defs: 87 | field_name = f_def["name"] 88 | 89 | sanitized_name = FieldBuilderGenerator._sanitize_name(field_name) 90 | handlers = self._build_handlers(f_def, sanitized_name) 91 | 92 | class_atts[sanitized_name] = property(*handlers) 93 | _san2field[sanitized_name] = f_def 94 | _fields.append(sanitized_name) 95 | self.clazz = type(st_name, (AbsFieldBuilder,), class_atts) 96 | self.clazz._fields = _fields 97 | self.clazz._san2field = _san2field 98 | return self.clazz 99 | 100 | def _sanitize_name(name): 101 | 102 | s1 = re.sub(r"[^\w]+", "_", name).lower() 103 | return re.sub(r"(^\d+)", r"n\1", s1) 104 | 105 | def _get_validator_for_type(self, f_def): 106 | t = f_def["type"].lower() 107 | if t == "string" or t == "text" or t == "attachment": 108 | return v.String() 109 | elif t == "aumber": 110 | return v.Number() 111 | ## the api varies between 1.73 and 1.74 112 | elif t == "radio": 113 | options = [] 114 | if "options" in f_def: 115 | options = f_def["options"] 116 | elif "definition" in f_def: 117 | options = f_def["definition"]["options"] 118 | return v.OneOf(options) 119 | elif t == "choice": 120 | options = [] 121 | if "options" in f_def: 122 | options = f_def["options"] 123 | elif "definition" in f_def: 124 | options = f_def["definition"]["options"] 125 | return v.AllOf(options) 126 | elif t == "date": 127 | return v.Date() 128 | elif t == "time": 129 | return v.Time() 130 | else: 131 | return v.AbsValidator() ## allows anything 132 | 133 | def _get_doc_for_type(self, f_def, sanitized_name): 134 | def basic_doc(n, t): 135 | if f_def["name"] == sanitized_name: 136 | return f"Property {sanitized_name} of type {t}" 137 | else: 138 | return f"Property {sanitized_name} matching sample template field {f_def['name']}" 139 | 140 | n = f_def["name"] 141 | t = f_def["type"] 142 | doc = basic_doc(n, t) 143 | if t == "Radio" or t == "Choice": 144 | return doc + f" Options: {', '.join(f_def['options'])}" 145 | else: 146 | return doc 147 | 148 | def _build_handlers(self, f_def, sanitized_name): 149 | 150 | validator = self._get_validator_for_type(f_def) 151 | 152 | def setter(self, value): 153 | validator.validate(value) 154 | self._data[sanitized_name] = value 155 | 156 | def getter(self): 157 | return self._data[sanitized_name] 158 | 159 | def deleter(self): 160 | del self.data[sanitized_name] 161 | 162 | return (getter, setter, deleter, self._get_doc_for_type(f_def, sanitized_name)) 163 | 164 | 165 | if __name__ == "__main__": 166 | st = { 167 | "name": "Enzyme", 168 | "fields": [ 169 | {"name": "comment", "type": "String"}, 170 | {"name": "pH", "type": "Number"}, 171 | {"name": "source", "type": "Radio", "options": ["Commercial", "Academic"]}, 172 | {"name": "supplier", "type": "Choice", "options": ["NEB", "BM", "Sigma"]}, 173 | {"name": "5' manufacture Date", "type": "Date"}, 174 | {"name": "manufacture Time", "type": "Time"}, 175 | {"name": "Safety Data", "type": "Attachment"}, 176 | ], 177 | } 178 | b = FieldBuilderGenerator() 179 | Enzyme = b.generate_class(st) 180 | inst = Enzyme() 181 | inst.source = "Academic" 182 | inst.ph = 4.3 183 | inst.comment = "some comment about the enzyme" 184 | inst.supplier = ["Sigma", "BM"] 185 | inst.n5_manufacture_date = dt.date(2001, 2, 3) 186 | inst.manufacture_time = dt.time(12, 34) 187 | inst.safety_data = "MyPdf" # a description of the file. Upload the file separately 188 | 189 | p.pprint(inst.to_field_post()) 190 | -------------------------------------------------------------------------------- /rspace_client/tests/eln_fs_test.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch, MagicMock, ANY 2 | import unittest 3 | from rspace_client.eln.fs import path_to_id, GalleryFilesystem 4 | from io import BytesIO 5 | 6 | def mock_requests_get(url, *args, **kwargs): 7 | mock_response = MagicMock() 8 | if url.endswith('/folders/tree'): 9 | mock_response.json.return_value = { 10 | 'records': [ 11 | {'id': '123', 'name': 'Gallery'} 12 | ] 13 | } 14 | elif url.endswith('/folders/tree/123'): 15 | mock_response.json.return_value = { 16 | 'records': [ 17 | {'globalId': 'GF123', 'name': 'Folder1'}, 18 | {'globalId': 'GL456', 'name': 'File1'}, 19 | {'globalId': 'GF789', 'name': 'Folder2'}, 20 | {'globalId': 'GL012', 'name': 'File2'} 21 | ] 22 | } 23 | elif url.endswith('/folders/tree/456'): 24 | mock_response.json.return_value = { 25 | 'records': [ 26 | ] 27 | } 28 | elif url.endswith('/folders/123'): 29 | mock_response.json.return_value = { 30 | 'id': '123', 31 | 'globalId': 'GF123', 32 | 'name': 'Test Folder', 33 | 'size': 0, 34 | 'created': '2025-02-11T16:00:16.392Z', 35 | 'lastModified': '2025-02-11T16:00:16.392Z', 36 | 'parentFolderId': 131, 37 | 'notebook': False, 38 | 'mediaType': 'Images', 39 | 'pathToRootFolder': None, 40 | '_links': [{'link': 'http://localhost:8080/api/v1/folders/306', 'rel': 'self'}] 41 | } 42 | elif url.endswith('/folders/456'): 43 | mock_response.json.return_value = { 44 | 'id': '456', 45 | 'globalId': 'GF456', 46 | 'name': 'newFolder', 47 | 'size': 0, 48 | 'created': '2025-02-11T16:00:16.392Z', 49 | 'lastModified': '2025-02-11T16:00:16.392Z', 50 | 'parentFolderId': 131, 51 | 'notebook': False, 52 | 'mediaType': 'Images', 53 | 'pathToRootFolder': None, 54 | '_links': [{'link': 'http://localhost:8080/api/v1/folders/306', 'rel': 'self'}] 55 | } 56 | elif url.endswith('/files/123'): 57 | mock_response.json.return_value = { 58 | 'id': '123', 59 | 'globalId': 'GF123', 60 | 'name': 'Test File', 61 | 'size': 1024, 62 | 'created': '2025-02-11T16:00:16.392Z', 63 | 'lastModified': '2025-02-11T16:00:16.392Z', 64 | 'parentFolderId': 131, 65 | 'notebook': False, 66 | 'mediaType': 'Images', 67 | 'pathToRootFolder': None, 68 | '_links': [{'link': 'http://localhost:8080/api/v1/folders/306', 'rel': 'self'}] 69 | } 70 | else: 71 | mock_response.json.return_value = {} 72 | mock_response.headers = {'Content-Type': 'application/json'} 73 | return mock_response 74 | 75 | def mock_requests_post(url, *args, **kwargs): 76 | mock_response = MagicMock() 77 | mock_response.json.return_value = { 78 | 'id': '456', 79 | 'globalId': 'GF456', 80 | 'name': 'newFolder', 81 | 'size': 0, 82 | 'created': '2025-02-11T16:00:16.392Z', 83 | 'lastModified': '2025-02-11T16:00:16.392Z', 84 | 'parentFolderId': 131, 85 | 'notebook': False, 86 | 'mediaType': 'Images', 87 | 'pathToRootFolder': None, 88 | '_links': [{'link': 'http://localhost:8080/api/v1/folders/306', 'rel': 'self'}] 89 | } 90 | mock_response.headers = {'Content-Type': 'application/json'} 91 | return mock_response 92 | 93 | 94 | class ElnFilesystemTest(unittest.TestCase): 95 | 96 | @patch('requests.get', side_effect=mock_requests_get) 97 | def setUp(self, mock_get) -> None: 98 | super().setUp() 99 | self.fs = GalleryFilesystem("https://example.com", "api_key") 100 | 101 | def test_path_to_id(self): 102 | self.assertEqual("123", path_to_id("GF123")) 103 | self.assertEqual("123", path_to_id("/GF123")) 104 | self.assertEqual("456", path_to_id("GF123/GF456")) 105 | self.assertEqual("456", path_to_id("/GF123/GF456")) 106 | 107 | @patch('requests.get', side_effect=mock_requests_get) 108 | def test_get_info_folder(self, mock_get): 109 | folder_info = self.fs.getinfo("GF123") 110 | self.assertEqual("GF123", folder_info.raw["basic"]["name"]) 111 | self.assertTrue(folder_info.raw["basic"]["is_dir"]) 112 | self.assertEqual(0, folder_info.raw["details"]["size"]) 113 | 114 | @patch('requests.get', side_effect=mock_requests_get) 115 | def test_get_info_file(self, mock_get): 116 | file_info = self.fs.getinfo("GL123") 117 | self.assertEqual("GL123", file_info.raw["basic"]["name"]) 118 | self.assertFalse(file_info.raw["basic"]["is_dir"]) 119 | self.assertEqual(1024, file_info.raw["details"]["size"]) 120 | 121 | @patch('requests.get', side_effect=mock_requests_get) 122 | def test_listdir_root(self, mock_list_folder_tree): 123 | result = self.fs.listdir('/') 124 | expected = ['GF123', 'GL456', 'GF789', 'GL012'] 125 | self.assertEqual(result, expected) 126 | 127 | @patch('requests.get', side_effect=mock_requests_get) 128 | def test_listdir_root_specific_folder(self, mock_list_folder_tree): 129 | expected = ['GF123', 'GL456', 'GF789', 'GL012'] 130 | result = self.fs.listdir('GF123') 131 | self.assertEqual(result, expected) 132 | 133 | @patch('requests.request', side_effect=mock_requests_post) 134 | @patch('requests.get', side_effect=mock_requests_get) 135 | def test_makedir(self, mock_get, mock_post): 136 | self.fs.makedir('GF123/newFolder') 137 | mock_post.assert_called_once_with( 138 | 'POST', 139 | 'https://example.com/api/v1/folders', 140 | json={'name': 'newFolder', 'parentFolderId': 123, 'notebook': False}, 141 | headers=ANY 142 | ) 143 | 144 | @patch('requests.request', side_effect=mock_requests_post) 145 | @patch('requests.get', side_effect=mock_requests_get) 146 | def test_removedir(self, mock_get, mock_post): 147 | self.fs.removedir('GF456') 148 | mock_post.assert_called_once_with( 149 | 'DELETE', 150 | 'https://example.com/api/v1/folders/456', 151 | json=ANY, 152 | headers=ANY 153 | ) 154 | 155 | @patch('requests.get') 156 | def test_download(self, mock_get): 157 | mock_response = MagicMock() 158 | mock_response.iter_content = MagicMock(return_value=[b'chunk1', b'chunk2', b'chunk3']) 159 | mock_get.return_value = mock_response 160 | file_obj = BytesIO() 161 | self.fs.download('/GL123', file_obj) 162 | file_obj.seek(0) 163 | self.assertEqual(file_obj.read(), b'chunk1chunk2chunk3') 164 | mock_get.assert_called_once_with( 165 | 'https://example.com/api/v1/files/123/file', 166 | headers=ANY 167 | ) 168 | 169 | @patch('requests.post') 170 | def test_upload(self, mock_post): 171 | mock_response = MagicMock() 172 | mock_response.json.return_value = {'id': '456'} 173 | mock_post.return_value = mock_response 174 | file_obj = BytesIO(b'test file content') 175 | self.fs.upload('/GF123', file_obj) 176 | mock_post.assert_called_once_with( 177 | 'https://example.com/api/v1/files', 178 | files={'file': file_obj}, 179 | data={'folderId': 123}, 180 | headers=ANY 181 | ) 182 | 183 | if __name__ == '__main__': 184 | unittest.main() 185 | -------------------------------------------------------------------------------- /jupyter_notebooks/samples_to_lom.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "49a9bb2e", 6 | "metadata": {}, 7 | "source": [ 8 | "This script prepares for an experiment testing a set of samples. \n", 9 | "\n", 10 | "It creates a notebook with an entry\n", 11 | "for each sample assay. \n", 12 | "\n", 13 | "A ListOfMaterials is attached to each notebook entry linking to the information and physical location of the sample.\n", 14 | "\n", 15 | "This sort of script could be use to prepare notebooks for the scientists performing the experiement, cutting down on tedious manual data entry. " 16 | ] 17 | }, 18 | { 19 | "cell_type": "markdown", 20 | "id": "e6c0cf9d", 21 | "metadata": {}, 22 | "source": [ 23 | "Set up CLI and assert it's working OK. You'll need RSpace 1.70 or later, and rspace_client 2.0.0 alpha." 24 | ] 25 | }, 26 | { 27 | "cell_type": "code", 28 | "execution_count": 12, 29 | "id": "c7ba1578", 30 | "metadata": {}, 31 | "outputs": [ 32 | { 33 | "data": { 34 | "text/plain": [ 35 | "{'message': 'OK', 'rspaceVersion': '1.70.4-SNAPSHOT-2021-09-30T22:15:08Z'}" 36 | ] 37 | }, 38 | "execution_count": 12, 39 | "metadata": {}, 40 | "output_type": "execute_result" 41 | } 42 | ], 43 | "source": [ 44 | "import rspace_client.inv.inv as inv\n", 45 | "import rspace_client.eln.eln as eln\n", 46 | "import os\n", 47 | "inv_cli = inv.InventoryClient(os.getenv(\"RSPACE_URL\"), os.getenv(\"RSPACE_API_KEY\"))\n", 48 | "eln_cli = eln.ELNClient(os.getenv(\"RSPACE_URL\"), os.getenv(\"RSPACE_API_KEY\"))\n", 49 | "inv_cli.get_workbenches()\n", 50 | "eln_cli.get_status()" 51 | ] 52 | }, 53 | { 54 | "cell_type": "markdown", 55 | "id": "e0d8d1d8", 56 | "metadata": {}, 57 | "source": [ 58 | "Read in sample information from a file. In these case these are Uniprot identifiers but could be anything - perhaps identifiers generated automatically as part of some automted synthesis or sample preparation" 59 | ] 60 | }, 61 | { 62 | "cell_type": "code", 63 | "execution_count": 13, 64 | "id": "c9c9d097", 65 | "metadata": {}, 66 | "outputs": [ 67 | { 68 | "name": "stdout", 69 | "output_type": "stream", 70 | "text": [ 71 | "['P12235', 'P12235', 'O00555', 'O00555', 'Q96F25', 'Q8TD30', 'Q01484', 'Q01484', 'Q01484', 'Q01484']\n" 72 | ] 73 | }, 74 | { 75 | "data": { 76 | "text/plain": [ 77 | "95" 78 | ] 79 | }, 80 | "execution_count": 13, 81 | "metadata": {}, 82 | "output_type": "execute_result" 83 | } 84 | ], 85 | "source": [ 86 | "\n", 87 | "with open(\"proteins.csv\", \"r\") as reader:\n", 88 | " samples = reader.read().splitlines()[1:]\n", 89 | "print(samples[0:10])\n", 90 | "len(samples)" 91 | ] 92 | }, 93 | { 94 | "cell_type": "markdown", 95 | "id": "97e5fba1", 96 | "metadata": {}, 97 | "source": [ 98 | "Now we'll create a 96 well plate to represent the location of the samples to assay" 99 | ] 100 | }, 101 | { 102 | "cell_type": "code", 103 | "execution_count": 14, 104 | "id": "124996e4", 105 | "metadata": {}, 106 | "outputs": [], 107 | "source": [ 108 | "plate_96 = inv_cli.create_grid_container(column_count=8, row_count=12, name=\"LossOFFunctionMutationsAssay#1\")" 109 | ] 110 | }, 111 | { 112 | "cell_type": "markdown", 113 | "id": "41fb08ca", 114 | "metadata": {}, 115 | "source": [ 116 | "Now we'll create the samples" 117 | ] 118 | }, 119 | { 120 | "cell_type": "code", 121 | "execution_count": 15, 122 | "id": "badc0727", 123 | "metadata": {}, 124 | "outputs": [ 125 | { 126 | "name": "stdout", 127 | "output_type": "stream", 128 | "text": [ 129 | "Creating sample P12235\n", 130 | "Creating sample P12235\n", 131 | "Creating sample O00555\n", 132 | "Creating sample O00555\n", 133 | "Creating sample Q96F25\n", 134 | "Creating sample Q8TD30\n", 135 | "Creating sample Q01484\n", 136 | "Creating sample Q01484\n", 137 | "Creating sample Q01484\n", 138 | "Creating sample Q01484\n", 139 | "Creating sample P41181\n", 140 | "Creating sample Q6ZSZ5\n", 141 | "Creating sample P50993\n", 142 | "Creating sample P50993\n", 143 | "Creating sample P30049\n", 144 | "Creating sample P30049\n" 145 | ] 146 | } 147 | ], 148 | "source": [ 149 | "created_samples = []\n", 150 | "for sample in samples[0:16]:\n", 151 | " print(f\"Creating sample {sample}\")\n", 152 | " created_samples.append(inv_cli.create_sample(sample, tags=\"loss-of-function\", subsample_count=5))" 153 | ] 154 | }, 155 | { 156 | "cell_type": "code", 157 | "execution_count": 16, 158 | "id": "4d34c1c1", 159 | "metadata": {}, 160 | "outputs": [], 161 | "source": [ 162 | "aliquots = [s['subSamples'][0] for s in created_samples ]" 163 | ] 164 | }, 165 | { 166 | "cell_type": "markdown", 167 | "id": "db8b9a62", 168 | "metadata": {}, 169 | "source": [ 170 | "Here we are auto-placing items row by row, but we can also set item locations individually or column by column." 171 | ] 172 | }, 173 | { 174 | "cell_type": "code", 175 | "execution_count": 17, 176 | "id": "d41443cd", 177 | "metadata": {}, 178 | "outputs": [ 179 | { 180 | "data": { 181 | "text/plain": [ 182 | "" 183 | ] 184 | }, 185 | "execution_count": 17, 186 | "metadata": {}, 187 | "output_type": "execute_result" 188 | } 189 | ], 190 | "source": [ 191 | "by_row = inv.ByRow(1,1,8,12, *subsamples)\n", 192 | "inv_cli.add_items_to_grid_container(target_container_id=plate_96['id'], grid_placement=by_row)" 193 | ] 194 | }, 195 | { 196 | "cell_type": "markdown", 197 | "id": "09094584", 198 | "metadata": {}, 199 | "source": [ 200 | "We've finished adding information to Inventory. \n", 201 | "Now we can create a notebook with entries - 1 per sample.\n", 202 | "\n", 203 | "Each entry has a ListOfMaterials linking to the sample." 204 | ] 205 | }, 206 | { 207 | "cell_type": "code", 208 | "execution_count": 18, 209 | "id": "28239490", 210 | "metadata": {}, 211 | "outputs": [ 212 | { 213 | "name": "stdout", 214 | "output_type": "stream", 215 | "text": [ 216 | "done - see prepared notebook NB732\n" 217 | ] 218 | } 219 | ], 220 | "source": [ 221 | "nb = eln_cli.create_folder(name=\"Sample Assay#1\", notebook=True)\n", 222 | "\n", 223 | "## we'd get this form ID from the UI. Or we could just create unstructured documents with single text fields.\n", 224 | "EXPERIMENT_FORM_ID = 3\n", 225 | "\n", 226 | "for i in range(len(subsamples)):\n", 227 | " doc = eln_cli.create_document(name=f\"assay{i}-{created_samples[i]['name']}\", \n", 228 | " form_id=EXPERIMENT_FORM_ID, parent_folder_id=nb['id'])\n", 229 | " \n", 230 | " ## this is the 2nd field - 'Objective'\n", 231 | " field_id = doc['fields'][1]['id']\n", 232 | " lom = inv_cli.create_list_of_materials(field_id, \"Assay target details\", subsamples[i], description=\" desc\")\n", 233 | "print(f\"done - see prepared notebook {nb['globalId']}\")" 234 | ] 235 | }, 236 | { 237 | "cell_type": "markdown", 238 | "id": "65becef6", 239 | "metadata": {}, 240 | "source": [ 241 | "At the end of this script we have:\n", 242 | "\n", 243 | "- Added sample and location information to Inventory\n", 244 | "- prepared a notebook with entries of structured data (Objective, Method, Results\n", 245 | "- Created a List of Materials linking to information about the sample.\n", 246 | "\n", 247 | "All that's left to do now is perform the experiment! " 248 | ] 249 | } 250 | ], 251 | "metadata": { 252 | "kernelspec": { 253 | "display_name": "Python 3", 254 | "language": "python", 255 | "name": "python3" 256 | }, 257 | "language_info": { 258 | "codemirror_mode": { 259 | "name": "ipython", 260 | "version": 3 261 | }, 262 | "file_extension": ".py", 263 | "mimetype": "text/x-python", 264 | "name": "python", 265 | "nbconvert_exporter": "python", 266 | "pygments_lexer": "ipython3", 267 | "version": "3.8.8" 268 | } 269 | }, 270 | "nbformat": 4, 271 | "nbformat_minor": 5 272 | } 273 | -------------------------------------------------------------------------------- /rspace_client/notebook_sync/links.md: -------------------------------------------------------------------------------- 1 | markdown syntax (for jupyter markdown cells) 2 | https://www.markdownguide.org/basic-syntax/#line-break-best-practices 3 | ======== 4 | Keyring (library we use to store password) 5 | https://pypi.org/project/keyring/10.0.2/ 6 | ====== 7 | Installing dependencies if not using magics (magics are code that starts with '%' - syntactic sugar around calls to IPython) 8 | https://jakevdp.github.io/blog/2017/12/05/installing-python-packages-from-jupyter/ 9 | 10 | =========== 11 | Kernels: 12 | https://github.com/jupyter/jupyter/wiki/Jupyter-kernels 13 | ======== 14 | https://cran.r-project.org/web/packages/reticulate/vignettes/calling_python.html#:~:text=Overview,%24listdir(%22.%22) 15 | ===== 16 | Reticulate: https://rstudio.github.io/reticulate/index.html 17 | 18 | Calling python from R 19 | 20 | install.packages('reticulate') 21 | library('reticulate') 22 | library("future") 23 | plan(multisession) 24 | py_require(c('pickleshare')) 25 | py_require(c('notebook')) 26 | py_require(c('keyring')) 27 | py_require(c('rspace_client==2.6.2')) 28 | py_require(c('dill')) 29 | py_require(c('ipynbname')) 30 | py_require(c('ipylab')) 31 | py_require(c('lxml')) 32 | py_require(c('keyrings.alt')) 33 | py_require(c('bs4')) 34 | py_require(c('nbformat')) 35 | py_require(c('keyring')) 36 | asyncio <- import("asyncio") 37 | sync_notebook <- import('rspace_client.notebook_sync.sync_notebook') 38 | (You will see an error message: 39 | name 'get_ipython' is not definedTraceback: 40 | All references to get_ipython need to be removed from the sync_notebook code - which then breaks it for python. In the end it was not possible to package a non python version with poetry so I stopped trying to have R Cells call python. 41 | However, technically it does work). 42 | run: 43 | 44 | asyncio$run(sync_notebook$sync_notebook_to_rspace(rspace_username="user1a",attached_data_files="spectroscopy_data.csv",rspace_url="https://researchspace2.eu.ngrok.io/", notebook_name="R4_D4.ipynb", server_url="localhost:10111")) 45 | ===== 46 | Docker STACKS 47 | Im running the script docker stack locally. I used: 48 | 49 | docker run -p 10000:8888 quay.io/jupyter/scipy-notebook:latest 50 | python -c "import sys; print('\n',sys.version); import ipympl; print('ipympl version:', ipympl.__version__)" && jupyter --version && jupyter labextension list 51 | 52 | And for the datascience Jupyter stack: 53 | docker run -it --rm -p 10111:8888 -v "${PWD}":/home/jovyan/work quay.io/jupyter/datascience-notebook:2025-03-14 54 | 55 | ====== 56 | BeautifulSoup for removing html tags 57 | https://www.crummy.com/software/BeautifulSoup/bs4/doc/ 58 | ======= 59 | IPYLAB 60 | **Install of Ipylab requires a browser refresh after %pip install step or it does not work and SAVE FAILS** 61 | 62 | Note JupyterLab lists all commands here: https://jupyterlab.readthedocs.io/en/latest/user/commands.html - ipylab can call these commands through its proxy. 63 | 64 | app.commands.execute('docmanager:save') - does save the notebook! (Asynchronous) 65 | 66 | Using JupyterFrontEnd from ipylab 67 | https://nbviewer.org/github/jtpio/ipylab/blob/main/examples/commands.ipynb 68 | Ready event 69 | 70 | Listing Commands are asynchronous 71 | Some functionalities might require the JupyterFrontEnd widget to be ready on the frontend first. 72 | This is for example the case when listing all the available commands, or retrieving the version with app.version. 73 | The on_ready method can be used to register a callback that will be fired when the frontend is ready. 74 | (In https://github.com/jtpio/ipylab/blob/main/examples/commands.ipynb) 75 | 76 | IPYLAB: https://github.com/jtpio/ipylab 77 | ======= 78 | 79 | TIPS and Tricks 80 | https://www.dataquest.io/blog/jupyter-notebook-tips-tricks-shortcuts/ 81 | 82 | =========== 83 | ASYNCIO 84 | 85 | https://docs.python.org/3/library/asyncio-task.html 86 | ====== 87 | 88 | MAGICS: 89 | 90 | https://ipython.readthedocs.io/en/stable/interactive/magics.html# 91 | 92 | ========== 93 | NBGITPULLER https://github.com/jupyterhub/nbgitpuller (The Bonn Jupyter expert said that this could be used to have Jupyter 'pull' data from RSPace and that we would not be able to push data 94 | from RSpace to Jupyter (at least at Bonnm who use multiple K8 clusters and each user ends up with a dedicated K8 node)) 95 | 96 | ===== 97 | 98 | NBFORMAT: https://nbformat.readthedocs.io/en/latest/format_description.html - used to read and write notebooks 99 | 100 | Kaggle https://www.kaggle.com/code/neilhanlon/notebook8ec06231db/edit username is Rspace email 101 | 102 | Parent: 103 | https://researchspace.atlassian.net/jira/polaris/projects/RPD/ideas/view/3486269?selectedIssue=RPD-65 104 | 105 | NBViewer https://nbviewer.org/ for publishing notebooks. We cant claim that there is not an existing mechanism to make notebooks public as this actually does it. 106 | 107 | Docker stacks https://jupyter-docker-stacks.readthedocs.io/en/latest/index.html 108 | Link to base notebook image on BINDER: try the quay.io/jupyter/base-notebook image  109 | 110 | Open Notebooks in R: https://tmphub.elff.eu/ (think its a BINDER Session) 111 | 112 | Log in to a Jupyter hub discussion: https://discourse.jupyter.org/t/jupyterhub-api-only-mode-how-should-user-login-work/36278 113 | 114 | IPYTHON : https://ipython.readthedocs.io/en/stable/config/extensions/storemagic.html 115 | 116 | JupyterLite https://jupyterlite.readthedocs.io/en/latest/howto/pyodide/packages.html 117 | 118 | Binder tutorial on Jupyter https://hub.gesis.mybinder.org/user/ipython-ipython-in-depth-a351eg8t/notebooks/binder/Index.ipynb 119 | 120 | https://jupyterlab.readthedocs.io/en/latest/user/extensions.html EXTENSIONS that are browser based and are found on window._JUPYTER 121 | 122 | https://github.com/timkpaine/jupyterlab_commands install commands 123 | 124 | Forum https://discourse.jupyter.org/t/how-to-avoid-multiple-kernels-per-notebook/11645 125 | 126 | JUPYTERLAB demo on binder : https://hub.2i2c.mybinder.org/user/jupyterlab-jupyterlab-demo-j2k4tj7o/lab/tree/demo 127 | 128 | JUPYTERLAB docs https://jupyterlab.readthedocs.io/en/latest/user/export.html 129 | 130 | JUPYTER HUB REST API https://jupyterhub.readthedocs.io/en/5.2.1/reference/rest-api.html uses oauth. 131 | 132 | https://jupyterhub.readthedocs.io/en/latest/howto/rest.html 133 | See especially The same API token can also authorize access to the Jupyter Notebook REST API 134 | provided by notebook servers managed by JupyterHub if it has the necessary access:servers scope. 135 | 136 | JUPYTER SERVER REST API https://jupyter-server.readthedocs.io/en/latest/developers/rest-api.html 137 | 138 | https://github.com/jupyter/jupyter/wiki/Jupyter-Notebook-Server-API 139 | 140 | https://discourse.jupyter.org/t/how-to-version-control-jupyter-notebooks/566 How to Version Control Jupyter Notebooks 141 | 142 | How to store variables data for later runs: https://stackoverflow.com/questions/34342155/how-to-pickle-or-store-jupyter-ipython-notebook-session-for-later?rq=4 143 | 144 | How to execute a cell remotely: https://discourse.jupyter.org/t/rest-api-for-executing-cells-in-a-notebook/21346 145 | 146 | https://jupyter-client.readthedocs.io/en/latest/messaging.html 147 | 148 | Server API swagger: https://petstore.swagger.io/?url=https://raw.githubusercontent.com/jupyter/jupyter_server/master/jupyter_server/services/api/api.yaml#/ 149 | 150 | How to render in React https://victordibia.com/blog/jupyter-notebooks-react/ 151 | 152 | Sandbox viewer https://codesandbox.io/p/sandbox/react-example-react-jupyter-notebook-viewer-forked-xqdmh1?file=%2Fsrc%2FApp.js 153 | 154 | Lab archives integration https://help.labarchives.com/hc/en-us/articles/11780569021972-Jupyter-Integration 155 | 156 | Elabftw discuss using Jupyter light. GitHubhttps://github.comJupyterLite · elabftw elabftw · Discussion #3930 157 | 158 | ElabFTW allow display of Notebook and then revert the code due to security concerns: https://github.com/elabftw/elabftw/issues/310 159 | 160 | THE Big SPLIT https://blog.jupyter.org/the-big-split-9d7b88a031a7 161 | 162 | Jupyter Lite v full discussed here: https://discourse.jupyter.org/t/writing-jupyter-notebooks/24453/2 (and links to deployments) 163 | 164 | Google Collab https://colab.research.google.com/#scrollTo=-Rh3-Vt9Nev9 uses the drive api https://developers.google.com/workspace/drive/api/reference/rest/v3/files/get which uses oauth. But also has own api: see here for discussion and links https://stackoverflow.com/questions/50595831/google-colab-api 165 | 166 | Provenance git projects that are installable Jupyter extensions: 167 | https://github.com/Sheeba-Samuel/ProvBook 168 | https://github.com/fusion-jena/MLProvLab 169 | 170 | How to see modules: help("modules") 171 | -------------------------------------------------------------------------------- /rspace_client/inv/template_builder.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Define SampleTemplates using a Builder pattern 4 | """ 5 | from typing import Optional, Sequence, Union, List 6 | from urllib.parse import urlparse 7 | import numbers 8 | import datetime as dt 9 | 10 | from rspace_client.inv.quantity_unit import QuantityUnit 11 | 12 | 13 | class TemplateBuilder: 14 | """ 15 | Define a SampleTemplate prior to POSTing to RSpace. 16 | A SampleTemplate only requires a name and a default unit to be defined. 17 | The default unit is supplied as a String from a permitted list in class QuantityUnit. E.g. 'ml', 'g'. 18 | """ 19 | 20 | numeric = Union[int, float] 21 | 22 | def __init__(self, name, defaultUnit, description=None): 23 | if not QuantityUnit.is_supported_unit(defaultUnit): 24 | raise ValueError( 25 | f"{defaultUnit} must be a label of a supported unit in QuantityUnit" 26 | ) 27 | self.name = name 28 | self.fields = [] 29 | self.qu = QuantityUnit.of(defaultUnit) 30 | if description is not None: 31 | self.description = description 32 | 33 | def _set_name(self, name: str, f_type: str): 34 | if len(name) == 0: 35 | raise ValueError("Name cannot be empty or None") 36 | return {"name": name, "type": f_type} 37 | 38 | def radio(self, name: str, options: List, selected: str = None): 39 | """ 40 | Parameters 41 | ---------- 42 | name : str 43 | The field name. 44 | options : List 45 | A list of radio options. 46 | selected : str, optional 47 | An optional string indicating a radio option that should be selected 48 | by default. If this string is not in the 'options' List, it will be ignored 49 | 50 | """ 51 | f = self._set_name(name, "Radio") 52 | f["definition"] = {"options": options} 53 | 54 | if selected is not None and len(selected) > 0 and selected in options: 55 | f["selectedOptions"] = [selected] 56 | 57 | self.fields.append(f) 58 | return self 59 | 60 | def choice(self, name: str, options: List, selected: List = None): 61 | """ 62 | Parameters 63 | ---------- 64 | name : str 65 | The field name. 66 | options : List 67 | A list of choice options. 68 | selected : List, optional 69 | An optional list of options that should be selected. If items in 70 | this list are not in the 'options' List, they will be ignored 71 | 72 | """ 73 | f = self._set_name(name, "Choice") 74 | 75 | f["definition"] = {"options": options} 76 | 77 | if selected is not None and len(selected) > 0: 78 | selected = [x for x in selected if x in options] 79 | if len(selected) > 0: 80 | f["selectedOptions"] = selected 81 | 82 | self.fields.append(f) 83 | return self 84 | 85 | def string(self, name: str, default: str = None): 86 | f = self._set_name(name, "String") 87 | if default is not None: 88 | f["content"] = default 89 | self.fields.append(f) 90 | return self 91 | 92 | def text(self, name: str, default: str = None): 93 | f = self._set_name(name, "Text") 94 | if default is not None: 95 | f["content"] = default 96 | self.fields.append(f) 97 | return self 98 | 99 | def number(self, name: str, default: numeric = None): 100 | """ 101 | Parameters 102 | ---------- 103 | name : str 104 | The field's name. 105 | default : numeric, optional 106 | A default numeric value for the field. 107 | 108 | Raises 109 | ------ 110 | ValueError 111 | if default value is not a number (integer or float). 112 | 113 | Returns 114 | ------- 115 | This object for chaining 116 | 117 | """ 118 | f = self._set_name(name, "Number") 119 | if default is not None: 120 | if isinstance(default, numbers.Number): 121 | f["content"] = default 122 | else: 123 | raise ValueError(f"Numeric field requires number but was '{default}'") 124 | self.fields.append(f) 125 | 126 | return self 127 | 128 | ## TODO date, time, URI, attachment? 129 | 130 | def date(self, name: str, isodate: Union[dt.date, dt.datetime, str] = None): 131 | """ 132 | 133 | Parameters 134 | ---------- 135 | name : str 136 | The field name. 137 | isodate : Union[dt.date, dt.datetime, str] 138 | Either a datetime.dateime, a datetime.date, or an ISO-8601 string. 139 | Raises 140 | ------ 141 | ValueError 142 | if string value is not an ISO8601 date (e.g. 2022-01-27) 143 | 144 | Returns 145 | ------- 146 | This object for chaining 147 | 148 | """ 149 | f = self._set_name(name, "Date") 150 | defaultDate = None 151 | ## these conditions must be in order 152 | if isodate is not None: 153 | if isinstance(isodate, dt.datetime): 154 | defaultDate = isodate.date().isoformat() 155 | elif isinstance(isodate, dt.date): 156 | defaultDate = isodate.isoformat() 157 | elif isinstance(isodate, str): 158 | defaultDate = ( 159 | dt.datetime.strptime(isodate, "%Y-%m-%d").date().isoformat() 160 | ) 161 | if defaultDate is not None: 162 | f["content"] = defaultDate 163 | self.fields.append(f) 164 | return self 165 | 166 | def time(self, name: str, isotime: Union[dt.date, dt.time, str] = None): 167 | """ 168 | 169 | Parameters 170 | ---------- 171 | name : str 172 | The field name. 173 | isodate : Union[dt.time, dt.datetime, str] 174 | Either a datetime.datetime, a datetime.time, or an ISO-8601 string. 175 | Raises 176 | ------ 177 | ValueError 178 | if string value is not an ISO8601 time (e.g. 12:05:36) 179 | 180 | Returns 181 | ------- 182 | This object for chaining 183 | 184 | """ 185 | f = self._set_name(name, "Time") 186 | defaultTime = None 187 | if isotime is not None: 188 | ## these conditions must be in order 189 | if isinstance(isotime, dt.datetime): 190 | defaultTime = isotime.time().isoformat() 191 | elif isinstance(isotime, dt.time): 192 | defaultTime = isotime.isoformat() 193 | elif isinstance(isotime, str): 194 | defaultTime = dt.time.fromisoformat(isotime).isoformat() 195 | if defaultTime is not None: 196 | f["content"] = defaultTime 197 | self.fields.append(f) 198 | return self 199 | 200 | def attachment(self, name: str, desc: str = None): 201 | """ 202 | Parameters 203 | ---------- 204 | name : str 205 | The field name. 206 | desc : str, optional 207 | An optional default description of the file to upload. 208 | 209 | Returns 210 | ------- 211 | This object for chaining 212 | 213 | """ 214 | f = self._set_name(name, "Attachment") 215 | if desc is not None and len(desc) > 0 and len(str.strip(desc)) > 0: 216 | f["content"] = desc 217 | self.fields.append(f) 218 | return self 219 | 220 | def uri(self, name: str, uri: str = None): 221 | """ 222 | Parameters 223 | ---------- 224 | name : str 225 | The field name. 226 | uri : str, optional 227 | An optional default URI 228 | 229 | Returns 230 | ------- 231 | This object for chaining 232 | Raises 233 | ------ 234 | ValueError if URI is not parsable into a URI 235 | 236 | """ 237 | f = self._set_name(name, "Uri") 238 | if uri is not None and len(uri) > 0 and len(str.strip(uri)) > 0: 239 | parsed_uri = urlparse(uri) 240 | f["content"] = uri 241 | self.fields.append(f) 242 | return self 243 | 244 | def field_count(self): 245 | return len(self.fields) 246 | 247 | def build(self) -> dict: 248 | d = {"name": self.name, "defaultUnitId": self.qu["id"], "fields": self.fields} 249 | if hasattr(self, "description"): 250 | d["description"] = self.description 251 | return d 252 | 253 | def _fields(self): 254 | return self.fields 255 | -------------------------------------------------------------------------------- /jupyter_notebooks/rspace-demo-kaggle-v11.ipynb: -------------------------------------------------------------------------------- 1 | {"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.7.12","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"### Working with RSpace data.\n\nThis notebook illustrates a workflow to get data from RSpace, analyse it, and send back results to RSpace.\nTo work with this tutorial, you'll need an account on RSpace, an RSpace API key and Python 3.6 or later.\n\nThis project requires modules `rspace_client` (Version 2) , `pandas` and `matplotlib`.\n\nTo install rspace client `pip install rspace-client==2.0.1`\n\nThe top-level README.md has more information on getting set up. \n\nThe notebook is split into 3 sections:\n\n1. Adding some data to RSpace to analyse. In reality, this might be done manually by a wet-lab scientist or be delivered programmatically by an instrument. \n2. Getting the datasets to analyse\n3. Sending back the analysis linked to an experimental record","metadata":{}},{"cell_type":"markdown","source":"#### Setup Step 1 - configuring the RSpace Client. `rspace_client` is available from pip.\n\nIt's good practice to store API keys as environment variables rather than hard-coding it.","metadata":{}},{"cell_type":"code","source":"!pip install rspace-client==2.0.1\nprint(\"Kernel running OK\")","metadata":{"execution":{"iopub.status.busy":"2021-12-02T09:34:56.205067Z","iopub.execute_input":"2021-12-02T09:34:56.20544Z","iopub.status.idle":"2021-12-02T09:35:05.672998Z","shell.execute_reply.started":"2021-12-02T09:34:56.205406Z","shell.execute_reply":"2021-12-02T09:35:05.671894Z"},"trusted":true},"execution_count":null,"outputs":[]},{"cell_type":"code","source":"## get your API key and set your RSpace URL. Change this code as needed for your own environment\nfrom kaggle_secrets import UserSecretsClient\napi_key_label = \"mp_demos_key\"\nAPI_KEY = UserSecretsClient().get_secret(api_key_label)\nprint (f\"Retrieved API key {API_KEY[0:4]}...\")\nURL=\"https://demos.researchspace.com\"","metadata":{},"execution_count":null,"outputs":[]},{"cell_type":"code","source":"from rspace_client.eln import eln\nimport os\n\napi = eln.ELNClient(URL, API_KEY)\n\n## sanity check that that the client is configured correctly\nprint(api.get_status())","metadata":{"execution":{"iopub.status.busy":"2021-12-02T09:11:50.086056Z","iopub.execute_input":"2021-12-02T09:11:50.086431Z","iopub.status.idle":"2021-12-02T09:11:51.361265Z","shell.execute_reply.started":"2021-12-02T09:11:50.086396Z","shell.execute_reply":"2021-12-02T09:11:51.360371Z"},"trusted":true},"execution_count":null,"outputs":[]},{"cell_type":"markdown","source":"#### Setup Step 2 - adding some test data.\n\nHere we'll add a CSV file to RSpace, containing some synthetic weather-related data.","metadata":{}},{"cell_type":"code","source":"import os\ndata_input_dir='/kaggle/input/rspacedemofiles'\ntemp_data_path=os.path.join(data_input_dir, 'temp_data.csv')\n\nwith open (temp_data_path) as f:\n raw_data_file = api.upload_file(f)['id']\nraw_data_file_id= raw_data_file\nprint(f\"Temperature data uploaded to RSpace with ID {raw_data_file_id}\")","metadata":{"execution":{"iopub.status.busy":"2021-12-02T09:29:29.73737Z","iopub.execute_input":"2021-12-02T09:29:29.737804Z","iopub.status.idle":"2021-12-02T09:29:30.742869Z","shell.execute_reply.started":"2021-12-02T09:29:29.737762Z","shell.execute_reply":"2021-12-02T09:29:30.741818Z"},"trusted":true},"execution_count":null,"outputs":[]},{"cell_type":"markdown","source":"#### Analysis Step 1 - retrieving dataset\n\nOK, now we can start working with this dataset. If this dataset had been uploaded by a colleague, we could have been notified by Slack, Teams, email or within RSpace itself that this file was available for analysis.","metadata":{}},{"cell_type":"code","source":"file_name = \"downloaded_\"+(api.get_file_info(raw_data_file_id)['name'])\nprint(file_name)\n\n## retrieve from RSpace - here we are downloading the file\nraw_temp_data = api.download_file(raw_data_file_id, file_name)","metadata":{"execution":{"iopub.status.busy":"2021-12-02T09:29:43.868586Z","iopub.execute_input":"2021-12-02T09:29:43.86891Z","iopub.status.idle":"2021-12-02T09:29:45.244744Z","shell.execute_reply.started":"2021-12-02T09:29:43.868874Z","shell.execute_reply":"2021-12-02T09:29:45.243908Z"},"trusted":true},"execution_count":null,"outputs":[]},{"cell_type":"markdown","source":"#### Analysis Step 2 - the analysis\n\nHere is where you do your actual analytis of the data... here we'll just plot the data and generate a summary, saving both to file.","metadata":{}},{"cell_type":"code","source":"import pandas as pd;\ndf = pd.read_csv(file_name)\nsummary_stats = df.describe()\n\ndf = df.set_index('city_id')\nplot = df.plot(ylabel='Celsius', title=f'Temperature plots from dataset {raw_data_file_id}')\nimg_f= f'Temperature_per_city-{raw_data_file_id}'\nplot.get_figure().savefig(img_f)\n\nsummary_stats_csv = f'{file_name[:file_name.rindex(\".\")]}-summarystats.csv'\nsummary_stats.to_csv(summary_stats_csv)","metadata":{"execution":{"iopub.status.busy":"2021-12-02T09:30:05.805607Z","iopub.execute_input":"2021-12-02T09:30:05.805916Z","iopub.status.idle":"2021-12-02T09:30:06.227801Z","shell.execute_reply.started":"2021-12-02T09:30:05.805882Z","shell.execute_reply":"2021-12-02T09:30:06.226521Z"},"trusted":true},"execution_count":null,"outputs":[]},{"cell_type":"markdown","source":"#### Analysis Step 3 - uploading back to RSpace\n\nYou can add captions to the file to help describe your analysis","metadata":{}},{"cell_type":"code","source":"with open(summary_stats_csv, 'rb') as f:\n summary_file = api.upload_file(f, caption=f\"Summary data for {raw_data_file_id}\")\n print(f\"uploaded id = {summary_file['id']}\")\nwith open(img_f+\".png\", 'rb') as f:\n uploaded_image = api.upload_file(f, caption=f\"City vs temperature for {raw_data_file_id}\")\n print(f\"uploaded id = {uploaded_image['id']}\") ","metadata":{"execution":{"iopub.status.busy":"2021-12-02T09:30:13.165456Z","iopub.execute_input":"2021-12-02T09:30:13.165815Z","iopub.status.idle":"2021-12-02T09:30:15.074355Z","shell.execute_reply.started":"2021-12-02T09:30:13.165782Z","shell.execute_reply":"2021-12-02T09:30:15.073388Z"},"trusted":true},"execution_count":null,"outputs":[]},{"cell_type":"markdown","source":"There are several options now:\n\n* You can create an RSpace document, and insert these files, and share the document with your group or colleage. \n* Your colleagues may have already created and shared document describing an experiment that generated these files, in which case you would already have access to a document.\n\nHere we'll go with a simple flow where we create a new RSpace document to share with the rest of our research group.\n\nThe content we'll insert will be HTML. However you don't need to figure out how to display the linked files. Just include file links as `` syntax and RSpace will turn these into formatted links\n","metadata":{}},{"cell_type":"code","source":"new_doc = api.create_document(name=f\"Analysis of dataset {raw_data_file_id}\")\ncontent = f\"\"\"\n

Analysis of temperature dataset from our standard locations.\n

No variation between locations:\nRaw data: \n

\nStatistical summary: \n

\nLocation vs temperature: \n\"\"\"\n\nupdated_doc = api.append_content(new_doc['id'], content)\n\n## a simple utility function so you can get a link to view the updated contents in a browser.\ndef api_to_browser(link):\n return '/globalId/SD'.join(link.split('/api/v1/documents/'))\n\nprint(f\"You can view this in a browser at {api_to_browser(updated_doc['_links'][0]['link'])}\")\n","metadata":{"execution":{"iopub.status.busy":"2021-12-02T09:30:33.035126Z","iopub.execute_input":"2021-12-02T09:30:33.036313Z","iopub.status.idle":"2021-12-02T09:30:38.342404Z","shell.execute_reply.started":"2021-12-02T09:30:33.036246Z","shell.execute_reply":"2021-12-02T09:30:38.341291Z"},"trusted":true},"execution_count":null,"outputs":[]},{"cell_type":"markdown","source":"If you're in a group, you can now share this with your group. You can get your groups' IDs: ","metadata":{}},{"cell_type":"code","source":"groups = api.get_groups()\nfor gp in groups:\n print(f\"{gp['name']:30}{gp['id']}\")\nchosen_group = None\n#chosen_group = input(\"please enter a group ID to share with\")\nchosen_group = chosen_group or groups[0]['id'] ## if not running interactively, choose 1st group","metadata":{"execution":{"iopub.status.busy":"2021-12-02T09:48:52.764733Z","iopub.execute_input":"2021-12-02T09:48:52.765029Z","iopub.status.idle":"2021-12-02T09:48:53.294518Z","shell.execute_reply.started":"2021-12-02T09:48:52.764997Z","shell.execute_reply":"2021-12-02T09:48:53.293245Z"},"trusted":true},"execution_count":53,"outputs":[]},{"cell_type":"code","source":"api.shareDocuments([new_doc['id']], chosen_group, permission=\"EDIT\")","metadata":{"execution":{"iopub.status.busy":"2021-12-02T09:31:28.045633Z","iopub.execute_input":"2021-12-02T09:31:28.045924Z","iopub.status.idle":"2021-12-02T09:31:29.469595Z","shell.execute_reply.started":"2021-12-02T09:31:28.045892Z","shell.execute_reply":"2021-12-02T09:31:29.468475Z"},"trusted":true},"execution_count":null,"outputs":[]},{"cell_type":"code","source":"### tidy up - remove output files\noutfile_dir=\"/kaggle/working\"\nfor root,dirs,files in os.walk(outfile_dir):\n for f in files:\n os.remove(f)\nprint (\"output files removed\")","metadata":{"execution":{"iopub.status.busy":"2021-12-02T09:32:58.850987Z","iopub.execute_input":"2021-12-02T09:32:58.851459Z","iopub.status.idle":"2021-12-02T09:32:58.859278Z","shell.execute_reply.started":"2021-12-02T09:32:58.851405Z","shell.execute_reply":"2021-12-02T09:32:58.858292Z"},"trusted":true},"execution_count":null,"outputs":[]}]} -------------------------------------------------------------------------------- /rspace_client/tests/data/sample_by_id.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 7, 3 | "globalId": "SA7", 4 | "name": "Basic Sample", 5 | "description": null, 6 | "created": "2025-02-17T09:33:37.000Z", 7 | "createdBy": "user2b", 8 | "lastModified": "2025-02-17T09:33:37.277Z", 9 | "modifiedBy": "user2b", 10 | "modifiedByFullName": "user two", 11 | "canBeDeleted": false, 12 | "deleted": false, 13 | "deletedDate": null, 14 | "iconId": -1, 15 | "quantity": { 16 | "numericValue": 1, 17 | "unitId": 3 18 | }, 19 | "tags": [], 20 | "type": "SAMPLE", 21 | "attachments": [ 22 | { 23 | "id": 32772, 24 | "globalId": "IF32772", 25 | "name": "test.pdf", 26 | "parentGlobalId": "SS4", 27 | "mediaFileGlobalId": null, 28 | "type": "GENERAL", 29 | "contentMimeType": "application/pdf", 30 | "extension": "pdf", 31 | "size": 3190, 32 | "created": "2025-02-17T13:48:28.000Z", 33 | "createdBy": "user1a", 34 | "deleted": false, 35 | "_links": [ 36 | { 37 | "link": "http://localhost:8080/api/inventory/v1/files/32772", 38 | "rel": "self" 39 | }, 40 | { 41 | "link": "http://localhost:8080/api/inventory/v1/files/32772/file", 42 | "rel": "enclosure" 43 | } 44 | ] 45 | } 46 | ], 47 | "barcodes": [], 48 | "identifiers": [], 49 | "owner": { 50 | "id": -3, 51 | "username": "user2b", 52 | "email": "u2@researchspace.com", 53 | "firstName": "user", 54 | "lastName": "two", 55 | "homeFolderId": 165, 56 | "workbenchId": null, 57 | "hasPiRole": false, 58 | "hasSysAdminRole": false, 59 | "_links": [] 60 | }, 61 | "permittedActions": ["READ", "UPDATE", "CHANGE_OWNER"], 62 | "sharingMode": "OWNER_GROUPS", 63 | "templateId": null, 64 | "templateVersion": null, 65 | "template": false, 66 | "revisionId": null, 67 | "version": 1, 68 | "historicalVersion": false, 69 | "subSampleAlias": { 70 | "alias": "aliquot", 71 | "plural": "aliquots" 72 | }, 73 | "subSamplesCount": 1, 74 | "storageTempMin": null, 75 | "storageTempMax": null, 76 | "sampleSource": "LAB_CREATED", 77 | "expiryDate": null, 78 | "sharedWith": [ 79 | { 80 | "group": { 81 | "id": 1, 82 | "globalId": "GP1", 83 | "name": "userGroup", 84 | "uniqueName": "userGroupKHqD", 85 | "_links": [] 86 | }, 87 | "shared": false, 88 | "itemOwnerGroup": true 89 | } 90 | ], 91 | "fields": [], 92 | "extraFields": [], 93 | "subSamples": [ 94 | { 95 | "id": 7, 96 | "globalId": "SS7", 97 | "name": "Basic Sample.01", 98 | "description": null, 99 | "created": "2025-02-17T09:33:37.000Z", 100 | "createdBy": "user2b", 101 | "lastModified": "2025-02-17T09:33:37.277Z", 102 | "modifiedBy": "user2b", 103 | "modifiedByFullName": "user two", 104 | "deleted": false, 105 | "deletedDate": null, 106 | "iconId": -1, 107 | "tags": [], 108 | "attachments": [], 109 | "barcodes": [], 110 | "identifiers": [], 111 | "owner": { 112 | "id": -3, 113 | "username": "user2b", 114 | "email": "u2@researchspace.com", 115 | "firstName": "user", 116 | "lastName": "two", 117 | "homeFolderId": 165, 118 | "workbenchId": null, 119 | "hasPiRole": false, 120 | "hasSysAdminRole": false, 121 | "_links": [] 122 | }, 123 | "permittedActions": ["READ", "UPDATE", "CHANGE_OWNER"], 124 | "sharingMode": "OWNER_GROUPS", 125 | "quantity": { 126 | "numericValue": 1, 127 | "unitId": 3 128 | }, 129 | "type": "SUBSAMPLE", 130 | "parentContainers": [ 131 | { 132 | "id": 12, 133 | "globalId": "IC12", 134 | "name": "box #1 (list container)", 135 | "description": null, 136 | "created": "2025-02-17T09:33:37.000Z", 137 | "createdBy": "user2b", 138 | "lastModified": "2025-02-17T09:33:37.251Z", 139 | "modifiedBy": "user2b", 140 | "modifiedByFullName": null, 141 | "deleted": false, 142 | "deletedDate": null, 143 | "iconId": -1, 144 | "quantity": null, 145 | "tags": [], 146 | "type": "CONTAINER", 147 | "attachments": [], 148 | "barcodes": [], 149 | "identifiers": [], 150 | "owner": { 151 | "id": -3, 152 | "username": "user2b", 153 | "email": "u2@researchspace.com", 154 | "firstName": "user", 155 | "lastName": "two", 156 | "homeFolderId": 165, 157 | "workbenchId": null, 158 | "hasPiRole": false, 159 | "hasSysAdminRole": false, 160 | "_links": [] 161 | }, 162 | "permittedActions": [], 163 | "sharingMode": "OWNER_GROUPS", 164 | "parentContainers": [ 165 | { 166 | "id": 11, 167 | "globalId": "IC11", 168 | "name": "storage shelf #1 (list container)", 169 | "description": null, 170 | "created": "2025-02-17T09:33:37.000Z", 171 | "createdBy": "user2b", 172 | "lastModified": "2025-02-17T09:33:37.251Z", 173 | "modifiedBy": "user2b", 174 | "modifiedByFullName": null, 175 | "deleted": false, 176 | "deletedDate": null, 177 | "iconId": -1, 178 | "quantity": null, 179 | "tags": [], 180 | "type": "CONTAINER", 181 | "attachments": [], 182 | "barcodes": [], 183 | "identifiers": [], 184 | "owner": { 185 | "id": -3, 186 | "username": "user2b", 187 | "email": "u2@researchspace.com", 188 | "firstName": "user", 189 | "lastName": "two", 190 | "homeFolderId": 165, 191 | "workbenchId": null, 192 | "hasPiRole": false, 193 | "hasSysAdminRole": false, 194 | "_links": [] 195 | }, 196 | "permittedActions": [], 197 | "sharingMode": "OWNER_GROUPS", 198 | "parentContainers": [], 199 | "parentLocation": null, 200 | "lastNonWorkbenchParent": null, 201 | "lastMoveDate": null, 202 | "locationsCount": null, 203 | "contentSummary": { 204 | "totalCount": 3, 205 | "subSampleCount": 0, 206 | "containerCount": 3 207 | }, 208 | "cType": "LIST", 209 | "gridLayout": null, 210 | "canStoreSamples": true, 211 | "canStoreContainers": true, 212 | "_links": [] 213 | } 214 | ], 215 | "parentLocation": { 216 | "id": 14, 217 | "coordX": 1, 218 | "coordY": 1 219 | }, 220 | "lastNonWorkbenchParent": null, 221 | "lastMoveDate": "2025-02-17T09:33:37.000Z", 222 | "locationsCount": null, 223 | "contentSummary": { 224 | "totalCount": 4, 225 | "subSampleCount": 1, 226 | "containerCount": 3 227 | }, 228 | "cType": "LIST", 229 | "gridLayout": null, 230 | "canStoreSamples": true, 231 | "canStoreContainers": true, 232 | "_links": [] 233 | }, 234 | { 235 | "id": 11, 236 | "globalId": "IC11", 237 | "name": "storage shelf #1 (list container)", 238 | "description": null, 239 | "created": "2025-02-17T09:33:37.000Z", 240 | "createdBy": "user2b", 241 | "lastModified": "2025-02-17T09:33:37.251Z", 242 | "modifiedBy": "user2b", 243 | "modifiedByFullName": null, 244 | "deleted": false, 245 | "deletedDate": null, 246 | "iconId": -1, 247 | "quantity": null, 248 | "tags": [], 249 | "type": "CONTAINER", 250 | "attachments": [], 251 | "barcodes": [], 252 | "identifiers": [], 253 | "owner": { 254 | "id": -3, 255 | "username": "user2b", 256 | "email": "u2@researchspace.com", 257 | "firstName": "user", 258 | "lastName": "two", 259 | "homeFolderId": 165, 260 | "workbenchId": null, 261 | "hasPiRole": false, 262 | "hasSysAdminRole": false, 263 | "_links": [] 264 | }, 265 | "permittedActions": [], 266 | "sharingMode": "OWNER_GROUPS", 267 | "parentContainers": [], 268 | "parentLocation": null, 269 | "lastNonWorkbenchParent": null, 270 | "lastMoveDate": null, 271 | "locationsCount": null, 272 | "contentSummary": { 273 | "totalCount": 3, 274 | "subSampleCount": 0, 275 | "containerCount": 3 276 | }, 277 | "cType": "LIST", 278 | "gridLayout": null, 279 | "canStoreSamples": true, 280 | "canStoreContainers": true, 281 | "_links": [] 282 | } 283 | ], 284 | "parentLocation": { 285 | "id": 20, 286 | "coordX": 4, 287 | "coordY": 1 288 | }, 289 | "lastNonWorkbenchParent": null, 290 | "lastMoveDate": "2025-02-17T09:33:37.000Z", 291 | "revisionId": null, 292 | "deletedOnSampleDeletion": false, 293 | "storedInContainer": true, 294 | "_links": [ 295 | { 296 | "link": "http://localhost:8080/api/inventory/v1/subSamples/7", 297 | "rel": "self" 298 | }, 299 | { 300 | "link": "http://localhost:8080/api/inventory/v1/sampleTemplates/7/icon/-1", 301 | "rel": "image" 302 | }, 303 | { 304 | "link": "http://localhost:8080/api/inventory/v1/sampleTemplates/7/icon/-1", 305 | "rel": "thumbnail" 306 | } 307 | ] 308 | } 309 | ], 310 | "_links": [ 311 | { 312 | "link": "http://localhost:8080/api/inventory/v1/samples/7", 313 | "rel": "self" 314 | }, 315 | { 316 | "link": "http://localhost:8080/api/inventory/v1/sampleTemplates/7/icon/-1", 317 | "rel": "icon" 318 | }, 319 | { 320 | "link": "http://localhost:8080/api/inventory/v1/sampleTemplates/7/icon/-1", 321 | "rel": "thumbnail" 322 | }, 323 | { 324 | "link": "http://localhost:8080/api/inventory/v1/sampleTemplates/7/icon/-1", 325 | "rel": "image" 326 | } 327 | ] 328 | } 329 | -------------------------------------------------------------------------------- /rspace_client/client_base.py: -------------------------------------------------------------------------------- 1 | import re 2 | import requests 3 | import sys 4 | 5 | 6 | class Pagination: 7 | """ 8 | For setting page size, number and orderby/ sort fields of listings 9 | """ 10 | 11 | def __init__( 12 | self, 13 | page_number: int = 0, 14 | page_size: int = 10, 15 | order_by: str = None, 16 | sort_order: str = "asc", 17 | ): 18 | self.data = { 19 | "pageNumber": page_number, 20 | "pageSize": page_size, 21 | } 22 | if order_by is not None: 23 | self.data["orderBy"] = f"{order_by} {sort_order}" 24 | 25 | 26 | class ClientBase: 27 | """Base class of common methods for all API clients""" 28 | 29 | def __init__(self, rspace_url, api_key): 30 | """ 31 | Initializes RSpace client. 32 | :param api_key: RSpace API key of a user can be found on 'My Profile' page 33 | """ 34 | self.rspace_url = rspace_url.rstrip('/') 35 | self.api_key = api_key 36 | 37 | def _get_headers(self, content_type="application/json"): 38 | return {"apiKey": self.api_key, "Accept": content_type} 39 | 40 | @staticmethod 41 | def _get_numeric_record_id(global_id): 42 | """ 43 | Gets numeric part of a global ID. 44 | :param global_id: global ID (for example, SD123 or FM12) 45 | :return: numeric record id 46 | """ 47 | if re.match(r"[a-zA-Z]{2}\d+$", str(global_id)) is not None: 48 | return int(global_id[2:]) 49 | elif re.match(r"\d+$", str(global_id)) is not None: 50 | return int(global_id) 51 | else: 52 | raise ValueError("{} is not a valid global ID".format(global_id)) 53 | 54 | @staticmethod 55 | def _get_formated_error_message(json_error): 56 | return "error message: {}, errors: {}".format( 57 | json_error.get("message", ""), 58 | ", ".join(json_error.get("errors", [])) or "no error list", 59 | ) 60 | 61 | @staticmethod 62 | def _responseContainsJson(response): 63 | return ( 64 | "Content-Type" in response.headers 65 | and "application/json" in response.headers["Content-Type"] 66 | ) 67 | 68 | @staticmethod 69 | def _handle_response(response): 70 | # Check whether response includes UNAUTHORIZED response code 71 | # print("status: {}, header: {}".format(response.headers, response.status_code)) 72 | if response.status_code == 401: 73 | raise ClientBase.AuthenticationError(response.json()["message"]) 74 | 75 | try: 76 | response.raise_for_status() 77 | 78 | if ClientBase._responseContainsJson(response): 79 | return response.json() 80 | elif response.text: 81 | return response.text 82 | else: 83 | return response 84 | except: 85 | if "application/json" in response.headers["Content-Type"]: 86 | error = "Error code: {}, {}".format( 87 | response.status_code, 88 | ClientBase._get_formated_error_message(response.json()), 89 | ) 90 | else: 91 | error = "Error code: {}, error message: {}".format( 92 | response.status_code, response.text 93 | ) 94 | raise ClientBase.ApiError(error, response_status_code=response.status_code) 95 | 96 | def doDelete(self, path, resource_id): 97 | """ 98 | Performs a delete operation for a given resource 99 | """ 100 | numeric_id = self._get_numeric_record_id(resource_id) 101 | return self.retrieve_api_results( 102 | "/{}/{}".format(path, numeric_id), 103 | content_type=None, 104 | request_type="DELETE", 105 | ) 106 | 107 | def retrieve_api_results( 108 | self, endpoint, params=None, content_type="application/json", request_type="GET" 109 | ): 110 | """ 111 | Makes the requested API call and returns either an exception or a parsed JSON response as a dictionary. 112 | Authentication header is automatically added. In most cases, a specialised method can be used instead. 113 | :endpoint url: API endpoint 114 | :param request_type: 'GET', 'POST', 'PUT', 'DELETE' 115 | :param params: arguments to be added to the API request 116 | :param content_type: content type 117 | :return: parsed JSON response as a dictionary 118 | """ 119 | url = endpoint 120 | if not endpoint.startswith(self._get_api_url()): 121 | url = self._get_api_url() + endpoint 122 | 123 | headers = self._get_headers(content_type) 124 | try: 125 | if request_type == "GET": 126 | response = requests.get(url, params=params, headers=headers) 127 | elif ( 128 | request_type == "PUT" 129 | or request_type == "POST" 130 | or request_type == "DELETE" 131 | ): 132 | response = requests.request( 133 | request_type, url, json=params, headers=headers 134 | ) 135 | else: 136 | raise ValueError( 137 | "Expected GET / PUT / POST / DELETE request type, received {} instead".format( 138 | request_type 139 | ) 140 | ) 141 | 142 | return self._handle_response(response) 143 | except requests.exceptions.ConnectionError as e: 144 | raise ClientBase.ConnectionError(e) 145 | 146 | @staticmethod 147 | def _get_links(response): 148 | """ 149 | Finds links part of the response. Most responses contain links section with URLs that might be useful to query 150 | for further information. 151 | :param response: response from the API server 152 | :return: links section of the response 153 | """ 154 | try: 155 | return response["_links"] 156 | except KeyError: 157 | raise ClientBase.NoSuchLinkRel("There are no links!") 158 | 159 | def get_link_contents(self, response, link_rel): 160 | """ 161 | Finds a link with rel attribute equal to link_rel and retrieves its contents. 162 | :param response: response from the API server 163 | :param link_rel: rel attribute value to look for 164 | :return: parsed response from the found URL 165 | """ 166 | return self.retrieve_api_results(self.get_link(response, link_rel)) 167 | 168 | def get_link(self, response, link_rel): 169 | """ 170 | Finds a link with rel attribute equal to link_rel. 171 | :param response: response from the API server. 172 | :param link_rel: rel attribute value to look for 173 | :return: string URL 174 | """ 175 | for link in self._get_links(response): 176 | if link["rel"] == link_rel: 177 | return link["link"] 178 | raise ClientBase.NoSuchLinkRel( 179 | 'Requested link rel "{}", available rel(s): {}'.format( 180 | link_rel, (", ".join(x["rel"] for x in self._get_links(response))) 181 | ) 182 | ) 183 | 184 | def download_link_to_file(self, url, filename, chunk_size=128): 185 | """ 186 | Downloads a file from the API server. 187 | :param url: URL of the file to be downloaded 188 | :param filename: file path to save the file to or an already opened file object 189 | :param chunk_size: size of the chunks to download at a time, default is 128 190 | """ 191 | headers = {"apiKey": self.api_key, "Accept": "application/octet-stream"} 192 | if isinstance(filename, str): 193 | with open(filename, "wb") as fd: 194 | for chunk in requests.get(url, headers=headers).iter_content( 195 | chunk_size=chunk_size 196 | ): 197 | fd.write(chunk) 198 | else: 199 | for chunk in requests.get(url, headers=headers).iter_content( 200 | chunk_size=chunk_size 201 | ): 202 | filename.write(chunk) 203 | 204 | def link_exists(self, response, link_rel): 205 | """ 206 | Checks whether there is a link with rel attribute equal to link_rel in the links section of the response. 207 | :param response: response from the API server 208 | :param link_rel: rel attribute value to look for 209 | :return: True, if the link exists 210 | """ 211 | return link_rel in [x["rel"] for x in self._get_links(response)] 212 | 213 | def serr(self, msg: str): 214 | print(msg, file=sys.stderr) 215 | 216 | def _stream( 217 | self, 218 | endpoint: str, 219 | pagination: Pagination = Pagination(), 220 | ): 221 | """ 222 | Yields items, making paginated requests to the server as each page 223 | is consumed by the calling code. 224 | 225 | Note this method assumes that the name of the collection of items in the 226 | response matches the endpoint name. For example 'samples' returns a response 227 | with a dictionary entry 'samples'. 228 | Parameters 229 | ---------- 230 | endpoint : str 231 | An endpoint with a GET request that makes paginated listings 232 | pagination : Pagination, optional 233 | The pagination control. The default is Pagination(). 234 | : Pagination 235 | 236 | Yields 237 | ------ 238 | item : A stream of items, depending on the endpoint called 239 | """ 240 | 241 | urlStr = f"{self._get_api_url()}/{endpoint}" 242 | 243 | next_link = requests.Request(url=urlStr, params=pagination.data).prepare().url 244 | while True: 245 | if next_link is not None: 246 | items = self.retrieve_api_results(next_link) 247 | for item in items[endpoint]: 248 | yield item 249 | if self.link_exists(items, "next"): 250 | next_link = self.get_link(items, "next") 251 | else: 252 | break 253 | 254 | class ConnectionError(Exception): 255 | pass 256 | 257 | class AuthenticationError(Exception): 258 | pass 259 | 260 | class NoSuchLinkRel(Exception): 261 | pass 262 | 263 | class ApiError(Exception): 264 | def __init__(self, error_message, response_status_code=None): 265 | Exception.__init__(self, error_message) 266 | self.response_status_code = response_status_code 267 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /rspace_client/tests/data/subsample_by_id.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 4, 3 | "globalId": "SS4", 4 | "name": "Complex Sample #1.01", 5 | "description": null, 6 | "created": "2025-02-17T09:33:36.000Z", 7 | "createdBy": "user1a", 8 | "lastModified": "2025-02-17T13:35:02.730Z", 9 | "modifiedBy": "user1a", 10 | "modifiedByFullName": "user user", 11 | "deleted": false, 12 | "deletedDate": null, 13 | "iconId": -1, 14 | "quantity": { 15 | "numericValue": 1, 16 | "unitId": 3 17 | }, 18 | "tags": [], 19 | "type": "SUBSAMPLE", 20 | "attachments": [ 21 | { 22 | "id": 32772, 23 | "globalId": "IF32772", 24 | "name": "test.pdf", 25 | "parentGlobalId": "SS4", 26 | "mediaFileGlobalId": null, 27 | "type": "GENERAL", 28 | "contentMimeType": "application/pdf", 29 | "extension": "pdf", 30 | "size": 3190, 31 | "created": "2025-02-17T13:48:28.000Z", 32 | "createdBy": "user1a", 33 | "deleted": false, 34 | "_links": [ 35 | { 36 | "link": "http://localhost:8080/api/inventory/v1/files/32772", 37 | "rel": "self" 38 | }, 39 | { 40 | "link": "http://localhost:8080/api/inventory/v1/files/32772/file", 41 | "rel": "enclosure" 42 | } 43 | ] 44 | } 45 | ], 46 | "barcodes": [ 47 | { 48 | "id": 1, 49 | "data": "test", 50 | "format": null, 51 | "description": "Scanned Unknown: test", 52 | "created": "2025-02-17T13:35:02.000Z", 53 | "createdBy": "user1a", 54 | "_links": [ 55 | { 56 | "link": "http://localhost:8080/api/inventory/v1/barcodes%3Fcontent=test&barcodeType=QR", 57 | "rel": "enclosure" 58 | } 59 | ] 60 | } 61 | ], 62 | "identifiers": [], 63 | "owner": { 64 | "id": -1, 65 | "username": "user1a", 66 | "email": "user@user.com", 67 | "firstName": "user", 68 | "lastName": "user", 69 | "homeFolderId": 124, 70 | "workbenchId": null, 71 | "hasPiRole": true, 72 | "hasSysAdminRole": false, 73 | "_links": [] 74 | }, 75 | "permittedActions": ["READ", "UPDATE", "CHANGE_OWNER"], 76 | "sharingMode": "OWNER_GROUPS", 77 | "parentContainers": [ 78 | { 79 | "id": 1, 80 | "globalId": "BE1", 81 | "name": "WB user1a", 82 | "description": null, 83 | "created": "2025-02-17T09:33:36.000Z", 84 | "createdBy": "user1a", 85 | "lastModified": "2025-02-17T09:33:36.543Z", 86 | "modifiedBy": "user1a", 87 | "modifiedByFullName": null, 88 | "deleted": false, 89 | "deletedDate": null, 90 | "iconId": -1, 91 | "quantity": null, 92 | "tags": [], 93 | "type": "CONTAINER", 94 | "attachments": [], 95 | "barcodes": [], 96 | "identifiers": [], 97 | "owner": { 98 | "id": -1, 99 | "username": "user1a", 100 | "email": "user@user.com", 101 | "firstName": "user", 102 | "lastName": "user", 103 | "homeFolderId": 124, 104 | "workbenchId": null, 105 | "hasPiRole": true, 106 | "hasSysAdminRole": false, 107 | "_links": [] 108 | }, 109 | "permittedActions": [], 110 | "sharingMode": "OWNER_GROUPS", 111 | "parentContainers": [], 112 | "parentLocation": null, 113 | "lastNonWorkbenchParent": null, 114 | "lastMoveDate": null, 115 | "locationsCount": null, 116 | "contentSummary": { 117 | "totalCount": 1, 118 | "subSampleCount": 1, 119 | "containerCount": 0 120 | }, 121 | "cType": "WORKBENCH", 122 | "gridLayout": null, 123 | "canStoreSamples": true, 124 | "canStoreContainers": true, 125 | "_links": [] 126 | } 127 | ], 128 | "parentLocation": { 129 | "id": 1, 130 | "coordX": 1, 131 | "coordY": 1 132 | }, 133 | "lastNonWorkbenchParent": null, 134 | "lastMoveDate": null, 135 | "deletedOnSampleDeletion": false, 136 | "revisionId": null, 137 | "sample": { 138 | "id": 4, 139 | "globalId": "SA4", 140 | "name": "Complex Sample #1", 141 | "description": null, 142 | "created": "2025-02-17T09:33:36.000Z", 143 | "createdBy": "user1a", 144 | "lastModified": "2025-02-17T09:33:36.540Z", 145 | "modifiedBy": "user1a", 146 | "modifiedByFullName": null, 147 | "deleted": false, 148 | "deletedDate": null, 149 | "iconId": 3, 150 | "quantity": { 151 | "numericValue": 1, 152 | "unitId": 3 153 | }, 154 | "tags": [], 155 | "type": "SAMPLE", 156 | "attachments": [], 157 | "barcodes": [], 158 | "identifiers": [], 159 | "owner": { 160 | "id": -1, 161 | "username": "user1a", 162 | "email": "user@user.com", 163 | "firstName": "user", 164 | "lastName": "user", 165 | "homeFolderId": 124, 166 | "workbenchId": null, 167 | "hasPiRole": true, 168 | "hasSysAdminRole": false, 169 | "_links": [] 170 | }, 171 | "permittedActions": [], 172 | "sharingMode": "OWNER_GROUPS", 173 | "templateId": 2, 174 | "templateVersion": 1, 175 | "template": false, 176 | "revisionId": null, 177 | "version": 1, 178 | "historicalVersion": false, 179 | "subSampleAlias": { 180 | "alias": "aliquot", 181 | "plural": "aliquots" 182 | }, 183 | "subSamplesCount": 1, 184 | "storageTempMin": { 185 | "numericValue": 3, 186 | "unitId": 8 187 | }, 188 | "storageTempMax": { 189 | "numericValue": 10, 190 | "unitId": 8 191 | }, 192 | "sampleSource": "LAB_CREATED", 193 | "expiryDate": null, 194 | "sharedWith": [], 195 | "fields": [ 196 | { 197 | "id": 21, 198 | "globalId": "SF21", 199 | "name": "MyNumber", 200 | "type": "number", 201 | "mandatory": true, 202 | "content": "23", 203 | "lastModified": "2025-02-17T09:33:36.540Z", 204 | "columnIndex": 1, 205 | "definition": null, 206 | "selectedOptions": null, 207 | "attachment": null, 208 | "_links": [] 209 | }, 210 | { 211 | "id": 22, 212 | "globalId": "SF22", 213 | "name": "MyDate", 214 | "type": "date", 215 | "mandatory": false, 216 | "content": "2020-10-01", 217 | "lastModified": "2025-02-17T09:33:36.540Z", 218 | "columnIndex": 2, 219 | "definition": null, 220 | "selectedOptions": null, 221 | "attachment": null, 222 | "_links": [] 223 | }, 224 | { 225 | "id": 23, 226 | "globalId": "SF23", 227 | "name": "MyString", 228 | "type": "string", 229 | "mandatory": false, 230 | "content": "Default string value", 231 | "lastModified": "2025-02-17T09:33:36.540Z", 232 | "columnIndex": 3, 233 | "definition": null, 234 | "selectedOptions": null, 235 | "attachment": null, 236 | "_links": [] 237 | }, 238 | { 239 | "id": 24, 240 | "globalId": "SF24", 241 | "name": "MyText", 242 | "type": "text", 243 | "mandatory": false, 244 | "content": "Default text value", 245 | "lastModified": "2025-02-17T09:33:36.540Z", 246 | "columnIndex": 4, 247 | "definition": null, 248 | "selectedOptions": null, 249 | "attachment": null, 250 | "_links": [] 251 | }, 252 | { 253 | "id": 25, 254 | "globalId": "SF25", 255 | "name": "MyURL", 256 | "type": "uri", 257 | "mandatory": false, 258 | "content": "https://www.google.com", 259 | "lastModified": "2025-02-17T09:33:36.540Z", 260 | "columnIndex": 5, 261 | "definition": null, 262 | "selectedOptions": null, 263 | "attachment": null, 264 | "_links": [] 265 | }, 266 | { 267 | "id": 26, 268 | "globalId": "SF26", 269 | "name": "My reference", 270 | "type": "reference", 271 | "mandatory": false, 272 | "content": null, 273 | "lastModified": "2025-02-17T09:33:36.540Z", 274 | "columnIndex": 6, 275 | "definition": null, 276 | "selectedOptions": null, 277 | "attachment": null, 278 | "_links": [] 279 | }, 280 | { 281 | "id": 27, 282 | "globalId": "SF27", 283 | "name": "MyAttachment", 284 | "type": "attachment", 285 | "mandatory": false, 286 | "content": null, 287 | "lastModified": "2025-02-17T09:33:36.540Z", 288 | "columnIndex": 7, 289 | "definition": null, 290 | "selectedOptions": null, 291 | "attachment": { 292 | "id": 1, 293 | "globalId": "IF1", 294 | "name": "loremIpsem20para.txt", 295 | "parentGlobalId": "SF27", 296 | "mediaFileGlobalId": null, 297 | "type": "GENERAL", 298 | "contentMimeType": "text/plain", 299 | "extension": "txt", 300 | "size": 10295, 301 | "created": "2025-02-17T09:33:36.000Z", 302 | "createdBy": "user1a", 303 | "deleted": false, 304 | "_links": [ 305 | { 306 | "link": "http://localhost:8080/api/inventory/v1/files/1", 307 | "rel": "self" 308 | }, 309 | { 310 | "link": "http://localhost:8080/api/inventory/v1/files/1/file", 311 | "rel": "enclosure" 312 | } 313 | ] 314 | }, 315 | "_links": [] 316 | }, 317 | { 318 | "id": 28, 319 | "globalId": "SF28", 320 | "name": "MyTime", 321 | "type": "time", 322 | "mandatory": false, 323 | "content": "00:00", 324 | "lastModified": "2025-02-17T09:33:36.540Z", 325 | "columnIndex": 8, 326 | "definition": null, 327 | "selectedOptions": null, 328 | "attachment": null, 329 | "_links": [] 330 | }, 331 | { 332 | "id": 29, 333 | "globalId": "SF29", 334 | "name": "radioField", 335 | "type": "radio", 336 | "mandatory": false, 337 | "content": null, 338 | "lastModified": "2025-02-17T09:33:36.540Z", 339 | "columnIndex": 9, 340 | "definition": { 341 | "options": ["option1", "option2"], 342 | "multiple": false 343 | }, 344 | "selectedOptions": [], 345 | "attachment": null, 346 | "_links": [] 347 | }, 348 | { 349 | "id": 30, 350 | "globalId": "SF30", 351 | "name": "choiceField", 352 | "type": "choice", 353 | "mandatory": false, 354 | "content": null, 355 | "lastModified": "2025-02-17T09:33:36.540Z", 356 | "columnIndex": 10, 357 | "definition": { 358 | "options": ["optionA", "optionB"], 359 | "multiple": false 360 | }, 361 | "selectedOptions": [], 362 | "attachment": null, 363 | "_links": [] 364 | } 365 | ], 366 | "extraFields": [ 367 | { 368 | "id": 1, 369 | "globalId": "EF1", 370 | "name": "My extra text", 371 | "lastModified": "2025-02-17T09:33:36.540Z", 372 | "modifiedBy": "user1a", 373 | "type": "text", 374 | "content": "Lorem ipsum dolor sit amet...", 375 | "parentGlobalId": "SA4", 376 | "_links": [], 377 | "deleted": false 378 | } 379 | ], 380 | "_links": [ 381 | { 382 | "link": "http://localhost:8080/api/inventory/v1/files/image/6f2904706397452a3c3772fadd2ca6e1fbcc084f68abc6535f4f26c5e953471d", 383 | "rel": "image" 384 | }, 385 | { 386 | "link": "http://localhost:8080/api/inventory/v1/files/image/3c4b7a3d823a128ea5325e8228324d6a5740af6977acb45861b47e8e23fc109d", 387 | "rel": "thumbnail" 388 | }, 389 | { 390 | "link": "http://localhost:8080/api/inventory/v1/samples/4", 391 | "rel": "self" 392 | }, 393 | { 394 | "link": "http://localhost:8080/api/inventory/v1/sampleTemplates/4/icon/3", 395 | "rel": "icon" 396 | }, 397 | { 398 | "link": "http://localhost:8080/api/inventory/v1/files/image/6f2904706397452a3c3772fadd2ca6e1fbcc084f68abc6535f4f26c5e953471d", 399 | "rel": "image" 400 | }, 401 | { 402 | "link": "http://localhost:8080/api/inventory/v1/files/image/3c4b7a3d823a128ea5325e8228324d6a5740af6977acb45861b47e8e23fc109d", 403 | "rel": "thumbnail" 404 | }, 405 | { 406 | "link": "http://localhost:8080/api/inventory/v1/samples/4", 407 | "rel": "self" 408 | }, 409 | { 410 | "link": "http://localhost:8080/api/inventory/v1/sampleTemplates/4/icon/3", 411 | "rel": "icon" 412 | } 413 | ] 414 | }, 415 | "extraFields": [ 416 | { 417 | "id": 2, 418 | "globalId": "EF2", 419 | "name": "My extra number", 420 | "lastModified": "2025-02-17T09:33:36.540Z", 421 | "modifiedBy": "user1a", 422 | "type": "number", 423 | "content": "3.141592", 424 | "parentGlobalId": "SS4", 425 | "_links": [], 426 | "deleted": false 427 | } 428 | ], 429 | "notes": [], 430 | "_links": [ 431 | { 432 | "link": "http://localhost:8080/api/inventory/v1/subSamples/4", 433 | "rel": "self" 434 | }, 435 | { 436 | "link": "http://localhost:8080/api/inventory/v1/files/image/6f2904706397452a3c3772fadd2ca6e1fbcc084f68abc6535f4f26c5e953471d", 437 | "rel": "image" 438 | }, 439 | { 440 | "link": "http://localhost:8080/api/inventory/v1/files/image/3c4b7a3d823a128ea5325e8228324d6a5740af6977acb45861b47e8e23fc109d", 441 | "rel": "thumbnail" 442 | } 443 | ], 444 | "storedInContainer": false 445 | } 446 | --------------------------------------------------------------------------------