From e7559283f2e28c0ef812d55908ea6452bb85396d Mon Sep 17 00:00:00 2001 From: nebay-abraha Date: Thu, 16 Apr 2026 10:08:40 +0100 Subject: [PATCH 1/6] - migrate pytest config to pyproject and align test dependencies - bump pytest and pin setuptools - add RSpace bootstrapping and python playwright api key generation - update development and test run guidance - python version baseline and clearer unit vs integration instructions - make test more robust - run integration tests against rspace-docker container - update barcode data class to align with rspace-java-client - add sample_post tests --- .env.example | 5 +- .github/scripts/get_rspace_api_key.py | 98 +++++++++++++++ .github/workflows/codeql-and-tests.yml | 152 +++++++++++++++++++++--- DEVELOPING.md | 25 ++-- pyproject.toml | 10 +- pytest.ini | 2 - rspace_client/inv/inv.py | 31 +++-- rspace_client/tests/elnapi_test.py | 72 ++++++----- rspace_client/tests/inv_lom_test.py | 3 + rspace_client/tests/invapi_test.py | 28 +++-- rspace_client/tests/sample_post_test.py | 59 +++++++++ 11 files changed, 407 insertions(+), 78 deletions(-) create mode 100644 .github/scripts/get_rspace_api_key.py delete mode 100644 pytest.ini create mode 100644 rspace_client/tests/sample_post_test.py diff --git a/.env.example b/.env.example index 8469ca8..d0a1977 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,5 @@ RSPACE_URL= -RSPACE_API_KEY= \ No newline at end of file +RSPACE_API_KEY= + +RSPACE_USERNAME= +RSPACE_PASSWORD= \ No newline at end of file diff --git a/.github/scripts/get_rspace_api_key.py b/.github/scripts/get_rspace_api_key.py new file mode 100644 index 0000000..9351359 --- /dev/null +++ b/.github/scripts/get_rspace_api_key.py @@ -0,0 +1,98 @@ +import os +import re +import sys +from playwright.sync_api import sync_playwright, expect +from dotenv import load_dotenv + +load_dotenv() + +RSPACE_URL = os.getenv("RSPACE_URL") +RSPACE_USERNAME = os.getenv("RSPACE_USERNAME") +RSPACE_PASSWORD = os.getenv("RSPACE_PASSWORD") + + +def main(): + if not RSPACE_URL or not RSPACE_USERNAME or not RSPACE_PASSWORD: + raise RuntimeError( + "Missing required environment variables: RSPACE_URL, RSPACE_USERNAME, RSPACE_PASSWORD" + ) + + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + context = browser.new_context() + page = context.new_page() + + print("Step 1: Navigating to RSpace...", file=sys.stderr) + page.goto(RSPACE_URL, wait_until="networkidle") + + print("Step 2: Logging in...", file=sys.stderr) + page.get_by_role("textbox", name="User").fill(RSPACE_USERNAME) + page.get_by_role("textbox", name="Password").fill(RSPACE_PASSWORD) + page.get_by_role("button", name="Log in").click() + + # Wait for login to complete and redirect + page.wait_for_load_state("networkidle") + print("Step 3: Login successful, navigating to My RSpace...", file=sys.stderr) + + # Step 2: Navigate to Profile → Manage API Key + page.get_by_role("link", name="My RSpace").click() + page.wait_for_load_state("networkidle") + + print("Step 4: Looking for Generate/Regenerate key button...", file=sys.stderr) + # Wait for the button to appear (either "Generate key" or "Regenerate key") + page.wait_for_selector("a#apiKeyRegenerateBtn", timeout=10000) + page.click("a#apiKeyRegenerateBtn") + + print("Step 5: Confirming password...", file=sys.stderr) + dialog = page.get_by_role("dialog", name="Confirm password") + dialog.wait_for() + dialog.get_by_role("textbox", name="Please confirm your password").fill(RSPACE_PASSWORD) + dialog.get_by_role("button", name="OK").click() + dialog.wait_for(state="detached") + + + print("Step 6: Waiting for key to be displayed...", file=sys.stderr) + page.wait_for_load_state("networkidle") + + # Wait for the key to appear in the page + page.wait_for_selector("#apiKeyInfo") + + # Extract the key from the displayed text + # Format: "Key: {32-char-string}" + key_locator = page.locator("div.api-menu__key") + if key_locator.count() == 0: + raise RuntimeError("Selector 'div.api-menu__key' not found on page — page structure may have changed") + + info_text = key_locator.inner_text() + print(f"Key element text length: {len(info_text)}", file=sys.stderr) + + match = re.search(r"Key:\s*([A-Za-z0-9]{32})", info_text) + if not match: + raise RuntimeError( + f"API key regex did not match — text length {len(info_text)}, starts with: {repr(info_text[:20])}" + ) + + api_key = match.group(1) + print("Successfully extracted API key", file=sys.stderr) + + # Write to GITHUB_OUTPUT so the key never touches stdout/stderr. + # Writing to GITHUB_OUTPUT is the standard GitHub Actions mechanism for passing + # step outputs; the file is ephemeral and runner-scoped. + github_output = os.getenv("GITHUB_OUTPUT") + if github_output: + with open(github_output, "a") as f: + f.write(f"rspace_api_key={api_key}\n") + else: + raise RuntimeError("GITHUB_OUTPUT is not set; refusing to emit API key to stdout.") + + browser.close() + + + +if __name__ == "__main__": + try: + main() + except Exception as exc: + print(f"Error generating API key: {exc}", file=sys.stderr)c + sys.exit(1) + \ No newline at end of file diff --git a/.github/workflows/codeql-and-tests.yml b/.github/workflows/codeql-and-tests.yml index 77ae1db..9b8d063 100644 --- a/.github/workflows/codeql-and-tests.yml +++ b/.github/workflows/codeql-and-tests.yml @@ -1,17 +1,21 @@ -name: CodeQL and Unit Test +name: codeQL and Test on: + push: + branches: [ master ] pull_request: branches: [ master ] workflow_dispatch: # allow manual trigger +permissions: + contents: read + jobs: analyze: name: CodeQL Analyze - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 + timeout-minutes: 20 permissions: - actions: read - contents: read security-events: write strategy: fail-fast: false @@ -20,35 +24,147 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} - name: Autobuild - uses: github/codeql-action/autobuild@v3 + uses: github/codeql-action/autobuild@v4 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 test: - name: Unit Test - runs-on: ubuntu-latest + name: unit test + runs-on: ubuntu-24.04 + timeout-minutes: 30 needs: analyze + permissions: + contents: read + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12"] steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - - name: Set up Poetry - run: | - curl -sSL https://install.python-poetry.org | python3 - - echo 'export PATH="$HOME/.local/bin:$PATH"' >> $GITHUB_ENV + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + version: 1.8.4 + virtualenvs-create: true + virtualenvs-in-project: true + + - name: Install dependencies + run: poetry install --no-interaction + + - name: Run unit tests + run: poetry run pytest -m "not integration" + + integration-test: + name: integration test + runs-on: ubuntu-24.04 + timeout-minutes: 45 + needs: test + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + version: 1.8.4 + virtualenvs-create: true + virtualenvs-in-project: true - name: Install dependencies - run: poetry install + run: poetry install --no-interaction + + - name: Clone rspace-docker + run: git clone https://github.com/rspace-os/rspace-docker.git /tmp/rspace-docker + + - name: Download latest RSpace WAR + run: | + set -euo pipefail + + release_json=$(curl -fsSL https://api.github.com/repos/rspace-os/rspace-web/releases/latest) + latest_tag=$(echo "$release_json" | jq -r '.tag_name') + war_url=$(echo "$release_json" | jq -r '.assets[]? | select(.name | test("^researchspace-.*\\.war$")) | .browser_download_url' | head -n1) + + if [ -z "$latest_tag" ] || [ "$latest_tag" = "null" ]; then + echo "Unable to determine latest release tag for rspace-web" + exit 1 + fi + + if [ -z "$war_url" ] || [ "$war_url" = "null" ]; then + war_url="https://github.com/rspace-os/rspace-web/releases/download/${latest_tag}/researchspace-${latest_tag}.war" + fi + + echo "Latest RSpace tag: $latest_tag" + curl -fsSL "$war_url" -o /tmp/rspace-docker/rspace.war + + - name: Free up disk space + run: | + df -h + sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL || true + docker image prune -af || true + df -h + + - name: Start RSpace + working-directory: /tmp/rspace-docker + run: docker compose up -d + + - name: Wait for RSpace to be ready + timeout-minutes: 10 + run: | + set -euo pipefail + + echo "Waiting for RSpace on http://localhost:8080 ..." + for i in {1..40}; do + if timeout 2 bash -c "cat < /dev/null > /dev/tcp/localhost/8080" 2>/dev/null; then + echo "RSpace is up" + exit 0 + fi + echo " attempt $i/40..." + sleep 15 + done + echo "RSpace failed to start" + exit 1 + + - name: Install Playwright Browsers + run: poetry run python -m playwright install chromium --with-deps + + - name: Generate RSpace API Key + id: generate_key + run: poetry run python .github/scripts/get_rspace_api_key.py + env: + RSPACE_URL: http://localhost:8080 + RSPACE_USERNAME: ${{ secrets.RSPACE_USERNAME }} + RSPACE_PASSWORD: ${{ secrets.RSPACE_PASSWORD }} + + - name: Mask API key + run: echo "::add-mask::${{ steps.generate_key.outputs.rspace_api_key }}" - - name: Run Unit Test - run: poetry run pytest rspace_client/tests + - name: Run integration tests + timeout-minutes: 20 + env: + RSPACE_URL: http://localhost:8080 + RSPACE_API_KEY: ${{ steps.generate_key.outputs.rspace_api_key }} + run: poetry run pytest -m integration -v \ No newline at end of file diff --git a/DEVELOPING.md b/DEVELOPING.md index 1152ce0..062200f 100644 --- a/DEVELOPING.md +++ b/DEVELOPING.md @@ -1,6 +1,6 @@ ## Development -Python 3.7 or later is required. We aim to support only active versions of Python. +Python 3.9 or later is required. We aim to support only active versions of Python. ### Setup @@ -21,23 +21,30 @@ to install all project dependencies into your virtual environment. ### Running tests -Tests are a mixture of plain unit tests and integration tests making calls to an RSpace server. -To run all tests, set these environment variables,replacing with your own values +Tests are a mixture of plain unit tests and integration tests that make calls to a live RSpace server. + +#### Unit tests only ``` -bash> export RSPACE_URL=https:/ -bash> export RSPACE_API_KEY=abcdefgh... +poetry run pytest -m "not integration" ``` -If these aren't set, integration tests will be skipped. +#### Integration tests + +Integration tests require credentials for a live RSpace instance. Create a `.env` file in the project root: + +``` +RSPACE_URL=https:// +RSPACE_API_KEY= +``` -Tests can be invoked: +Then run: ``` -poetry run pytest rspace_client/tests +poetry run pytest -m integration ``` -They should be run with a new RSpace account that does not belong to any groups. +Integration tests should be run with a new RSpace account that does not belong to any groups. ### Writing Tests diff --git a/pyproject.toml b/pyproject.toml index 2cb92ad..2a31942 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,12 +25,20 @@ python = "^3.9" requests = "^2.25.1" beautifulsoup4 = "^4.9.3" fs = "^2.4.16" +setuptools = "<82" [tool.poetry.group.dev.dependencies] python-dotenv = "^1.1.1" black = "^21.6b0" -pytest = "^6.2.4" +pytest = "^8.0.0" +playwright = "^1.58.0" [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" + +[tool.pytest.ini_options] +testpaths = ["rspace_client/tests"] +markers = [ + "integration: marks tests as integration tests requiring a live RSpace server (deselect with '-m not integration')", +] diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index de19c9f..0000000 --- a/pytest.ini +++ /dev/null @@ -1,2 +0,0 @@ -[pytest] -testpaths = tests \ No newline at end of file diff --git a/rspace_client/inv/inv.py b/rspace_client/inv/inv.py index a206a08..1567d4c 100644 --- a/rspace_client/inv/inv.py +++ b/rspace_client/inv/inv.py @@ -8,7 +8,7 @@ import requests import pprint import requests -from typing import Optional, Sequence, Union, List, TypedDict, BinaryIO +from typing import Optional, Sequence, Union, List, TypedDict, BinaryIO, ClassVar from rspace_client.client_base import ClientBase, Pagination from rspace_client.inv import quantity_unit as qu @@ -33,19 +33,22 @@ class Tag(TypedDict): @dataclass class Barcode: - data: str - format: BarcodeFormat + data: Optional[str] = None + format: Optional[BarcodeFormat] = None description: str = "" - newBarcodeRequest: bool = True - id: Optional[str] = "" + new_barcode_request: bool = True + id: Optional[str] = None def to_dict(self): - return{ - "data": self.data, - "format": self.format.value, + result = { "description": self.description, - "newBarcodeRequest": self.newBarcodeRequest + "newBarcodeRequest": self.new_barcode_request, } + if self.data is not None: + result["data"] = self.data + if self.format is not None: + result["format"] = self.format.value + return result class FillingStrategy(Enum): @@ -809,7 +812,8 @@ def __init__( subsample_count: int = None, total_quantity: Quantity = None, attachments=None, - barcodes: Optional[List[Barcode]] = None + barcodes: Optional[List[Barcode]] = None, + location: "TargetLocation" = None, ): super().__init__(name, "SAMPLE", tags, description, extra_fields) ## converts arguments into JSON POST syntax @@ -828,10 +832,11 @@ def __init__( self.data["templateId"] = sample_template_id if fields is not None: self.data["fields"] = fields + if location is not None: + self.data.update(location.data) ## fail early - if attachments is not None: - if not isinstance(attachments, list): - raise ValueError("attachments must be a list of open files") + if attachments is not None and not isinstance(attachments, list): + raise ValueError("attachments must be a list of open files") if barcodes is not None: self.data["barcodes"] = [barcode.to_dict() for barcode in barcodes] diff --git a/rspace_client/tests/elnapi_test.py b/rspace_client/tests/elnapi_test.py index 0ddff33..1f6cc81 100755 --- a/rspace_client/tests/elnapi_test.py +++ b/rspace_client/tests/elnapi_test.py @@ -6,6 +6,9 @@ @author: richard """ import os, os.path +import tempfile + +import pytest import rspace_client.eln.eln as cli from rspace_client.eln.dcs import DocumentCreationStrategy @@ -15,6 +18,7 @@ from rspace_client.client_base import Pagination +@pytest.mark.integration class ELNClientAPIIntegrationTest(BaseApiTest): def setUp(self): self.assertClientCredentials() @@ -26,29 +30,34 @@ def test_get_status(self): def test_upload_downloadfile(self): file = get_datafile("fish_method.doc") + with tempfile.NamedTemporaryFile(suffix=".doc", delete=False) as tmp: + tmp_path = tmp.name try: with open(file, "rb") as to_upload: rs_file = self.api.upload_file(to_upload) - rs_get = self.api.download_file(rs_file["id"], "out.doc") + self.api.download_file(rs_file["id"], tmp_path) finally: - os.remove(os.path.join(os.getcwd(), "out.doc")) + if os.path.exists(tmp_path): + os.remove(tmp_path) def test_get_documents(self): + self.api.create_document(name=random_string(10)) resp = self.api.get_documents() self.assertTrue(resp["totalHits"] > 0) self.assertTrue(len(resp["documents"]) > 0) def test_stream_documents(self): + self.api.create_document(name=random_string(10)) + self.api.create_document(name=random_string(10)) doc_gen = self.api.stream_documents(pagination=Pagination(page_size=1)) d1 = next(doc_gen) d2 = next(doc_gen) self.assertNotEqual(d1["id"], d2["id"]) def test_get_documents_by_id(self): - resp = self.api.get_documents() - first_id = resp["documents"][0]["id"] - doc = self.api.get_document(first_id) - self.assertEqual(first_id, doc["id"]) + created = self.api.create_document(name=random_string(10)) + doc = self.api.get_document(created["id"]) + self.assertEqual(created["id"], doc["id"]) def test_create_document(self): nameStr = random_string(10) @@ -59,22 +68,25 @@ def test_create_document(self): def test_import_tree(self): tree_dir = get_datafile("tree") - res = self.api.import_tree(tree_dir) + folder = self.api.create_folder("tree-" + random_string(6)) + res = self.api.import_tree(tree_dir, parent_folder_id=folder["id"]) self.assertEqual("OK", res["status"]) ## f, 2sf, and 3files in each sf self.assertEqual(9, len(res["path2Id"].keys())) def test_import_tree_include_dot_files(self): tree_dir = get_datafile("tree") - res = self.api.import_tree(tree_dir, ignore_hidden_folders=False) + folder = self.api.create_folder("tree-" + random_string(6)) + res = self.api.import_tree(tree_dir, parent_folder_id=folder["id"], ignore_hidden_folders=False) self.assertEqual("OK", res["status"]) ## f, 2sf, and 3files in each sf + hidden self.assertTrue(len(res["path2Id"].keys()) >= 9) def test_import_tree_summary_doc_only(self): tree_dir = get_datafile("tree") + folder = self.api.create_folder("tree-" + random_string(6)) res = self.api.import_tree( - tree_dir, doc_creation=DocumentCreationStrategy.SUMMARY_DOC + tree_dir, parent_folder_id=folder["id"], doc_creation=DocumentCreationStrategy.SUMMARY_DOC ) self.assertEqual("OK", res["status"]) ## original folder + summary doc @@ -82,15 +94,16 @@ def test_import_tree_summary_doc_only(self): def test_import_tree_summary_doc_per_subfolder(self): tree_dir = get_datafile("tree") + folder = self.api.create_folder("tree-" + random_string(6)) res = self.api.import_tree( - tree_dir, doc_creation=DocumentCreationStrategy.DOC_PER_SUBFOLDER + tree_dir, parent_folder_id=folder["id"], doc_creation=DocumentCreationStrategy.DOC_PER_SUBFOLDER ) self.assertEqual("OK", res["status"]) ## original folder + 2 sf + 2 summary docs self.assertEqual(5, len(res["path2Id"].keys())) def test_import_tree_into_subfolder(self): - folder = self.api.create_folder("tree-root") + folder = self.api.create_folder("tree-root-" + random_string(6)) tree_dir = get_datafile("tree") res = self.api.import_tree(tree_dir, parent_folder_id=folder["id"]) self.assertEqual("OK", res["status"]) @@ -98,26 +111,31 @@ def test_import_tree_into_subfolder(self): self.assertEqual(9, len(res["path2Id"].keys())) def test_export_all_work_with_log_file(self): - file_path = "tmp-export.zip" - log_file = "tmp-log.txt" - try: + with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as f: + file_path = f.name + with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as f: + log_file = f.name + try: self.api.export_and_download("html", "user", file_path, progress_log=log_file, wait_between_requests=5) self.assertTrue(os.path.getsize(log_file) > 0) self.assertTrue(os.path.getsize(file_path) > 0) - except BaseException as e: - self.fail("Unexpected exception" + str(e)) + except Exception as e: + self.fail("Unexpected exception" + str(e)) finally: - os.remove(file_path) - os.remove(log_file) - + for p in (file_path, log_file): + if os.path.exists(p): + os.remove(p) + def test_export_all_work_with_no_file(self): - file_path = "tmp-export.zip" - try: - self.api.export_and_download("html", "user", file_path, wait_between_requests=5) - self.assertTrue(os.path.getsize(file_path) > 0) - except BaseException as e: - self.fail("Unexpected exception" + str(e)) - finally: - os.remove(file_path) + with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as f: + file_path = f.name + try: + self.api.export_and_download("html", "user", file_path, wait_between_requests=5) + self.assertTrue(os.path.getsize(file_path) > 0) + except Exception as e: + self.fail("Unexpected exception" + str(e)) + finally: + if os.path.exists(file_path): + os.remove(file_path) diff --git a/rspace_client/tests/inv_lom_test.py b/rspace_client/tests/inv_lom_test.py index 8c18cfc..fdc75d8 100644 --- a/rspace_client/tests/inv_lom_test.py +++ b/rspace_client/tests/inv_lom_test.py @@ -5,11 +5,14 @@ @author: richard """ +import pytest + import rspace_client.tests.base_test as base from rspace_client.inv import inv from rspace_client.eln import eln +@pytest.mark.integration class LomApiTest(base.BaseApiTest): def setUp(self): """ diff --git a/rspace_client/tests/invapi_test.py b/rspace_client/tests/invapi_test.py index aefcd98..32bd6a1 100644 --- a/rspace_client/tests/invapi_test.py +++ b/rspace_client/tests/invapi_test.py @@ -17,6 +17,7 @@ from rspace_client.inv import quantity_unit as qu +@pytest.mark.integration class InventoryApiTest(base.BaseApiTest): def setUp(self): """ @@ -57,7 +58,7 @@ def test_create_sample(self): def test_set_image_sample(self): sample = self.invapi.create_sample(base.random_string(5)) - file = base.get_datafile("AntibodySample150.png") + file = base.get_datafile("antibodySample150.png") with open(file, "rb") as f: updated_sample = self.invapi.set_image(sample, f) @@ -110,10 +111,11 @@ def test_rename_item(self): self.assertEqual(new_name, updated["name"]) def test_list_samples(self): + self.invapi.create_sample(base.random_string(5)) samples = self.invapi.list_samples(inv.Pagination(sort_order="desc")) self.assertEqual(0, samples["pageNumber"]) - self.assertEqual(10, len(samples["samples"])) + self.assertGreaterEqual(len(samples["samples"]), 1) def test_add_note_to_subsample(self): note = " a note about a subsample " + base.random_string() @@ -124,6 +126,9 @@ def test_add_note_to_subsample(self): self.assertEqual(note, updated["notes"][0]["content"]) def test_paginated_samples(self): + # Create 2 samples so page 1 (0-indexed) has results + self.invapi.create_sample(base.random_string(5)) + self.invapi.create_sample(base.random_string(5)) pag = inv.Pagination(page_number=1, page_size=1, sort_order="desc") samples = self.invapi.list_samples(pag) self.assertEqual(1, samples["pageNumber"]) @@ -136,9 +141,10 @@ def test_paginated_containers(self): c = self.invapi.set_as_top_level_container(c) containers = self.invapi.list_top_level_containers(pag) self.assertEqual(0, containers["pageNumber"]) - self.assertEqual(1, len(containers["containers"])) + self.assertGreaterEqual(len(containers["containers"]), 1) def test_paginated_subsamples(self): + self.invapi.create_sample(base.random_string(5), subsample_count=1) pag = inv.Pagination(page_number=0, page_size=1) ss = self.invapi.list_subsamples(pag) self.assertEqual(0, ss["pageNumber"]) @@ -162,6 +168,9 @@ def test_stream_containers(self): self.assertEqual(c2["id"], c2_l["id"]) def test_stream_samples(self): + # Ensure at least 2 samples exist + self.invapi.create_sample(base.random_string(5)) + self.invapi.create_sample(base.random_string(5)) onePerPage = inv.Pagination(page_size=1) gen = self.invapi.stream_samples(onePerPage) # get 2 items @@ -286,7 +295,7 @@ def test_duplicate(self): def test_get_benches(self): benches = self.invapi.get_workbenches() - self.assertEqual(2, len(benches)) + self.assertGreaterEqual(len(benches), 1) bench_ob = inv.Container.of(benches[0]) self.assertTrue(bench_ob.is_workbench()) @@ -679,12 +688,17 @@ def test_calculate_grid_start_validation(self): def test_barcode(self): barcode_bytes = self.invapi.barcode("SA14567") - self.assertEqual(99, len(barcode_bytes)) + self.assertTrue(len(barcode_bytes) > 0) + self.assertTrue(barcode_bytes.startswith(b"\x89PNG\r\n\x1a\n")) qr_bytes = self.invapi.barcode( - "SA12345", outfile="out10.png", barcode_type=inv.Barcode.QR + "SA12345", outfile="out10.png", barcode_type=inv.BarcodeFormat.QR ) - self.assertEqual(293, len(qr_bytes)) + self.assertTrue(len(qr_bytes) > 0) + self.assertTrue(qr_bytes.startswith(b"\x89PNG\r\n\x1a\n")) + self.assertTrue(os.path.exists("out10.png")) + self.assertTrue(os.path.getsize("out10.png") > 0) + os.remove("out10.png") def test_delete_samples(self): new_sample = self.invapi.create_sample("to_delete") diff --git a/rspace_client/tests/sample_post_test.py b/rspace_client/tests/sample_post_test.py new file mode 100644 index 0000000..75a185d --- /dev/null +++ b/rspace_client/tests/sample_post_test.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import unittest + +from rspace_client.inv import inv + + +class SamplePostTest(unittest.TestCase): + def test_sample_post_without_location_has_no_parent_fields(self): + post = inv.SamplePost("sample") + + self.assertNotIn("parentContainers", post.data) + self.assertNotIn("parentLocation", post.data) + self.assertNotIn("removeFromParentContainerRequest", post.data) + + def test_sample_post_with_list_container_location(self): + location = inv.ListContainerTargetLocation(123) + post = inv.SamplePost("sample", location=location) + + self.assertEqual([{"id": 123}], post.data["parentContainers"]) + self.assertNotIn("parentLocation", post.data) + + def test_sample_post_with_grid_location(self): + location = inv.GridContainerTargetLocation(123, 2, 3) + post = inv.SamplePost("sample", location=location) + + self.assertEqual([{"id": 123}], post.data["parentContainers"]) + self.assertEqual({"coordX": 2, "coordY": 3}, post.data["parentLocation"]) + + def test_sample_post_with_empty_barcode_request(self): + barcode = inv.Barcode(new_barcode_request=True) + post = inv.SamplePost("sample", barcodes=[barcode]) + + self.assertEqual( + [{"description": "", "newBarcodeRequest": True}], + post.data["barcodes"], + ) + + def test_sample_post_with_barcode_format_included_when_set(self): + barcode = inv.Barcode( + data="SA123", + format=inv.BarcodeFormat.QR, + description="test", + new_barcode_request=True, + ) + post = inv.SamplePost("sample", barcodes=[barcode]) + + self.assertEqual( + [ + { + "data": "SA123", + "format": "QR", + "description": "test", + "newBarcodeRequest": True, + } + ], + post.data["barcodes"], + ) From dc3d8614684345a99fb70073095fa0cbc59e47f4 Mon Sep 17 00:00:00 2001 From: nebay-abraha Date: Thu, 16 Apr 2026 15:16:01 +0100 Subject: [PATCH 2/6] remove c --- .github/scripts/get_rspace_api_key.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/get_rspace_api_key.py b/.github/scripts/get_rspace_api_key.py index 9351359..045cfbe 100644 --- a/.github/scripts/get_rspace_api_key.py +++ b/.github/scripts/get_rspace_api_key.py @@ -93,6 +93,6 @@ def main(): try: main() except Exception as exc: - print(f"Error generating API key: {exc}", file=sys.stderr)c + print(f"Error generating API key: {exc}", file=sys.stderr) sys.exit(1) \ No newline at end of file From b6b0c0fb05e422bbd9e9b67a3b95114873c7aab9 Mon Sep 17 00:00:00 2001 From: nebay-abraha Date: Mon, 20 Apr 2026 13:37:42 +0100 Subject: [PATCH 3/6] remove unused imports --- .github/scripts/get_rspace_api_key.py | 2 +- rspace_client/inv/inv.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/scripts/get_rspace_api_key.py b/.github/scripts/get_rspace_api_key.py index 045cfbe..7c85b5e 100644 --- a/.github/scripts/get_rspace_api_key.py +++ b/.github/scripts/get_rspace_api_key.py @@ -1,7 +1,7 @@ import os import re import sys -from playwright.sync_api import sync_playwright, expect +from playwright.sync_api import sync_playwright from dotenv import load_dotenv load_dotenv() diff --git a/rspace_client/inv/inv.py b/rspace_client/inv/inv.py index 1567d4c..bd0b1a1 100644 --- a/rspace_client/inv/inv.py +++ b/rspace_client/inv/inv.py @@ -8,7 +8,7 @@ import requests import pprint import requests -from typing import Optional, Sequence, Union, List, TypedDict, BinaryIO, ClassVar +from typing import Optional, Sequence, Union, List, TypedDict, BinaryIO from rspace_client.client_base import ClientBase, Pagination from rspace_client.inv import quantity_unit as qu From 141d4a267fb4614bd9c9054620e451fde819da24 Mon Sep 17 00:00:00 2001 From: nebay-abraha Date: Mon, 20 Apr 2026 13:45:11 +0100 Subject: [PATCH 4/6] avoid including page text that may contain secret --- .github/scripts/get_rspace_api_key.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/.github/scripts/get_rspace_api_key.py b/.github/scripts/get_rspace_api_key.py index 7c85b5e..e899ec4 100644 --- a/.github/scripts/get_rspace_api_key.py +++ b/.github/scripts/get_rspace_api_key.py @@ -68,10 +68,8 @@ def main(): match = re.search(r"Key:\s*([A-Za-z0-9]{32})", info_text) if not match: - raise RuntimeError( - f"API key regex did not match — text length {len(info_text)}, starts with: {repr(info_text[:20])}" - ) - + raise RuntimeError(f"API key regex did not match for selector 'div.api-menu__key' — text length {len(info_text)}") + api_key = match.group(1) print("Successfully extracted API key", file=sys.stderr) @@ -88,11 +86,9 @@ def main(): browser.close() - if __name__ == "__main__": try: main() except Exception as exc: print(f"Error generating API key: {exc}", file=sys.stderr) - sys.exit(1) - \ No newline at end of file + sys.exit(1) \ No newline at end of file From 44c0f8b038832695c35615399d74fb60fe2498cf Mon Sep 17 00:00:00 2001 From: nebay-abraha Date: Mon, 20 Apr 2026 13:52:38 +0100 Subject: [PATCH 5/6] update analyze job permissions. --- .github/workflows/codeql-and-tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/codeql-and-tests.yml b/.github/workflows/codeql-and-tests.yml index 9b8d063..dc7394e 100644 --- a/.github/workflows/codeql-and-tests.yml +++ b/.github/workflows/codeql-and-tests.yml @@ -16,6 +16,8 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 20 permissions: + actions: read + contents: read security-events: write strategy: fail-fast: false From 700d482ab2318a9a0fcafa6957bb0860eb1d16a1 Mon Sep 17 00:00:00 2001 From: nebay-abraha Date: Mon, 20 Apr 2026 14:37:57 +0100 Subject: [PATCH 6/6] revert barcode dataclass --- rspace_client/inv/inv.py | 19 ++++++++----------- rspace_client/tests/sample_post_test.py | 11 +---------- 2 files changed, 9 insertions(+), 21 deletions(-) diff --git a/rspace_client/inv/inv.py b/rspace_client/inv/inv.py index bd0b1a1..f4a2f67 100644 --- a/rspace_client/inv/inv.py +++ b/rspace_client/inv/inv.py @@ -33,22 +33,19 @@ class Tag(TypedDict): @dataclass class Barcode: - data: Optional[str] = None - format: Optional[BarcodeFormat] = None + data: str + format: BarcodeFormat description: str = "" - new_barcode_request: bool = True - id: Optional[str] = None + newBarcodeRequest: bool = True + id: Optional[str] = "" def to_dict(self): - result = { + return{ + "data": self.data, + "format": self.format.value, "description": self.description, - "newBarcodeRequest": self.new_barcode_request, + "newBarcodeRequest": self.newBarcodeRequest } - if self.data is not None: - result["data"] = self.data - if self.format is not None: - result["format"] = self.format.value - return result class FillingStrategy(Enum): diff --git a/rspace_client/tests/sample_post_test.py b/rspace_client/tests/sample_post_test.py index 75a185d..be5f95b 100644 --- a/rspace_client/tests/sample_post_test.py +++ b/rspace_client/tests/sample_post_test.py @@ -28,21 +28,12 @@ def test_sample_post_with_grid_location(self): self.assertEqual([{"id": 123}], post.data["parentContainers"]) self.assertEqual({"coordX": 2, "coordY": 3}, post.data["parentLocation"]) - def test_sample_post_with_empty_barcode_request(self): - barcode = inv.Barcode(new_barcode_request=True) - post = inv.SamplePost("sample", barcodes=[barcode]) - - self.assertEqual( - [{"description": "", "newBarcodeRequest": True}], - post.data["barcodes"], - ) - def test_sample_post_with_barcode_format_included_when_set(self): barcode = inv.Barcode( data="SA123", format=inv.BarcodeFormat.QR, description="test", - new_barcode_request=True, + newBarcodeRequest=True, ) post = inv.SamplePost("sample", barcodes=[barcode])