From f16f6d14aa84f39f1c3e1e0cfde31998b0fdd624 Mon Sep 17 00:00:00 2001 From: Andre Ambrosio Date: Mon, 20 Apr 2026 15:51:50 -0300 Subject: [PATCH 1/4] chore(exports): include __version__ in __all__ for explicit public API __version__ = '2.1.0' was defined at module level but omitted from __all__, meaning `from bsp_sdk import *` would not expose it and tools like `bsp_sdk.__version__` checks via __all__ introspection would miss it. Add it as the first entry under a 'Package metadata' comment. Co-Authored-By: Claude Sonnet 4.6 --- bsp_sdk/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bsp_sdk/__init__.py b/bsp_sdk/__init__.py index 48a72eb..25e63f0 100644 --- a/bsp_sdk/__init__.py +++ b/bsp_sdk/__init__.py @@ -45,6 +45,8 @@ __version__ = "2.1.0" __all__ = [ + # Package metadata + "__version__", # Clients "BSPClient", "BEOClient", "IEOBuilder", "BioRecordBuilder", "TaxonomyResolver", From 2fceb208b29fcee8abb099f1693edd05cadca0ca Mon Sep 17 00:00:00 2001 From: Andre Ambrosio Date: Mon, 20 Apr 2026 16:01:47 -0300 Subject: [PATCH 2/4] feat(beo): add list_beos(), destroy() docs, and Beta classifier - BEOClient.list_beos(limit, offset): GET /api/beo with pagination - README: added list_beos and destroy() usage examples; updated method table - pyproject.toml: added "Development Status :: 4 - Beta" classifier Co-Authored-By: Claude Sonnet 4.6 --- README.md | 18 ++++++++++++++++++ bsp_sdk/beo.py | 28 ++++++++++++++++++++++++++++ pyproject.toml | 1 + 3 files changed, 47 insertions(+) diff --git a/README.md b/README.md index f8136ec..575e190 100644 --- a/README.md +++ b/README.md @@ -89,8 +89,26 @@ beo = BEOClient(config) | `is_available` | `(domain: str) -> bool` | Check if a domain is unclaimed | | `lock` | `(reason?) -> dict` | Lock a BEO against new writes | | `unlock` | `() -> dict` | Unlock a previously locked BEO | +| `list_beos` | `(limit=20, offset=0) -> list` | List BEOs accessible to the configured IEO | +| `destroy` | `(beo_id: str \| int, reason?) -> dict` | Permanently destroy a BEO (LGPD/GDPR erasure) | | `update_recovery` | `(config: RecoveryConfig) -> dict` | Update guardian recovery settings | +```python +# List BEOs accessible to the configured IEO +beos = beo_client.list_beos(limit=50, offset=0) +for b in beos: + print(b["beo_id"], b["domain"]) + +# Permanently destroy a BEO (LGPD Art. 18 / GDPR Art. 17 — right to erasure) +# WARNING: irreversible. Nullifies key, revokes all ConsentTokens, releases domain. +result = beo_client.destroy( + beo_id=42, # accepts int or decimal string + reason="user_requested_deletion", +) +print(result["destroyed_at"]) +print(result["aptos_tx"]) +``` + ```python # Create with guardians from bsp_sdk import Guardian, RecoveryConfig diff --git a/bsp_sdk/beo.py b/bsp_sdk/beo.py index 1c547a6..833da1e 100644 --- a/bsp_sdk/beo.py +++ b/bsp_sdk/beo.py @@ -120,6 +120,34 @@ def update_recovery(self, config: RecoveryConfig) -> dict: raise ValueError(f"threshold must be between 1 and {len(config.guardians)}") raise NotImplementedError("Registry connection required") + def list_beos(self, limit: int = 20, offset: int = 0) -> list: + """List BEOs accessible to the configured IEO. + + Makes a GET request to ``/api/beo?limit=N&offset=N`` and returns the + list of BEO objects from the response. + + :param limit: Maximum number of BEOs to return (default: 20). + :param offset: Number of records to skip for pagination (default: 0). + :returns: List of BEO dicts as returned by the registry API. + :raises BSPApiError: Non-2xx response from the registry API. + + Example:: + + client = BSPClient(ieo_domain="lab.bsp", private_key="...", environment="mainnet") + beos = client.beo.list_beos(limit=50, offset=0) + for beo in beos: + print(beo["beo_id"], beo["domain"]) + """ + data = self.http.get(f"/api/beo?limit={limit}&offset={offset}") + if isinstance(data, list): + return data + # Some registry responses wrap the list in a 'beos' or 'items' key + if isinstance(data, dict): + for key in ("beos", "items", "results", "data"): + if isinstance(data.get(key), list): + return data[key] + return [] + def destroy( self, beo_id: BeoId, diff --git a/pyproject.toml b/pyproject.toml index 42742e0..45f24ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ license = { text = "Apache-2.0" } authors = [{ name = "Ambrósio Institute", email = "dev@ambrosioinstitute.org" }] keywords = ["bsp", "biological-sovereignty", "longevity", "health-data", "biomarker", "aptos", "blockchain"] classifiers = [ + "Development Status :: 4 - Beta", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", From d3004bd38f018c71dbe79fb4301210f5b4426dcf Mon Sep 17 00:00:00 2001 From: Andre Ambrosio Date: Mon, 20 Apr 2026 16:14:21 -0300 Subject: [PATCH 3/4] feat(sdk-py): add ExchangeClient tests and ISO8601 validation for collection_time - tests/test_exchange.py: covers submit_records/read_records success, empty/over-limit records, token required, expired token error - bsp_sdk/biorecord.py: set_collection_time() validates via datetime.fromisoformat(), raises ValueError with clear message on invalid input --- bsp_sdk/biorecord.py | 11 +- tests/test_exchange.py | 225 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 tests/test_exchange.py diff --git a/bsp_sdk/biorecord.py b/bsp_sdk/biorecord.py index 476f0f0..688e022 100644 --- a/bsp_sdk/biorecord.py +++ b/bsp_sdk/biorecord.py @@ -20,6 +20,7 @@ from __future__ import annotations import uuid +from datetime import datetime, timezone from typing import Optional, Union from .types import BioRecord, BioLevel, RangeObject, SourceMeta, RecordStatus from .taxonomy import TaxonomyResolver @@ -71,6 +72,15 @@ def set_unit(self, unit: str) -> "BioRecordBuilder": return self def set_collection_time(self, iso8601: str) -> "BioRecordBuilder": + """Validate that iso8601 is a parseable ISO 8601 datetime string.""" + # Normalise trailing 'Z' to '+00:00' so fromisoformat() accepts it on Python < 3.11 + normalized = iso8601.replace("Z", "+00:00") if iso8601.endswith("Z") else iso8601 + try: + datetime.fromisoformat(normalized) + except (ValueError, TypeError) as exc: + raise ValueError( + f'collection_time must be a valid ISO 8601 datetime string — got: "{iso8601}"' + ) from exc self._collected_at = iso8601 return self @@ -125,7 +135,6 @@ def build(self) -> BioRecord: if missing: raise ValueError(f'BioRecord missing required fields: {", ".join(missing)}') - from datetime import datetime, timezone now = datetime.now(tz=timezone.utc).isoformat().replace("+00:00", "Z") source = SourceMeta( diff --git a/tests/test_exchange.py b/tests/test_exchange.py new file mode 100644 index 0000000..7f1043b --- /dev/null +++ b/tests/test_exchange.py @@ -0,0 +1,225 @@ +"""Tests for ExchangeClient — submit_records and read_records.""" + +from __future__ import annotations + +from typing import Any, Optional + +import pytest + +from bsp_sdk.exchange import ExchangeClient +from bsp_sdk.http_client import BSPApiError +from bsp_sdk.types import BSPConfig, BioRecord, ReadFilters + + +# ── Fake HTTP client (same pattern as test_access.py) ───────────────────────── + +class FakeHttp: + """Records every request and returns pre-seeded responses.""" + + def __init__(self) -> None: + self.calls: list[dict[str, Any]] = [] + self.responses: dict[tuple[str, str], Any] = {} + self.errors: dict[tuple[str, str], BSPApiError] = {} + + def seed(self, method: str, path: str, response: Any) -> None: + self.responses[(method.upper(), path)] = response + + def seed_error(self, method: str, path: str, err: BSPApiError) -> None: + self.errors[(method.upper(), path)] = err + + def _dispatch(self, method: str, path: str, **kwargs: Any) -> Any: + self.calls.append({"method": method.upper(), "path": path, **kwargs}) + key = (method.upper(), path) + if key in self.errors: + raise self.errors[key] + if key in self.responses: + return self.responses[key] + return {} + + def get(self, path: str, params: Optional[dict[str, Any]] = None) -> Any: + return self._dispatch("GET", path, params=params) + + def post(self, path: str, body: dict[str, Any]) -> Any: + return self._dispatch("POST", path, body=body) + + def delete(self, path: str, body: Optional[dict[str, Any]] = None) -> Any: + return self._dispatch("DELETE", path, body=body) + + +# ── Fixtures ────────────────────────────────────────────────────────────────── + +@pytest.fixture +def client() -> tuple[ExchangeClient, FakeHttp]: + config = BSPConfig( + ieo_domain="fleury.bsp", + private_key="a" * 64, + environment="local", + registry_url="http://localhost:3000", + ) + http = FakeHttp() + return ExchangeClient(config, http=http), http # type: ignore[arg-type] + + +def _make_record(biomarker: str = "BSP-HM-001") -> dict[str, Any]: + """Minimal dict that stands in for a BioRecord in HTTP payloads.""" + return { + "record_id": "00000000-0000-0000-0000-000000000001", + "beo_id": "550e8400-e29b-41d4-a716-446655440000", + "ieo_id": "770e8400-e29b-41d4-a716-446655440001", + "version": "1.0.0", + "biomarker": biomarker, + "category": "BSP-HM", + "level": "STANDARD", + "value": 13.8, + "unit": "g/dL", + "collected_at": "2026-04-19T08:00:00Z", + "submitted_at": "2026-04-19T10:00:00Z", + "status": "ACTIVE", + "supersedes": None, + "confidence": None, + } + + +def _submit_result() -> dict[str, Any]: + return { + "request_id": "req-abc123", + "status": "SUCCESS", + "record_ids": ["00000000-0000-0000-0000-000000000001"], + "aptos_txs": ["0xdeadbeef"], + "timestamp": "2026-04-19T10:00:00Z", + } + + +def _read_result(records: list[dict[str, Any]] | None = None) -> dict[str, Any]: + if records is None: + records = [_make_record()] + return { + "request_id": "req-read-001", + "beo_id": "550e8400-e29b-41d4-a716-446655440000", + "records": records, + "total": len(records), + "has_more": False, + } + + +# ── submit_records ──────────────────────────────────────────────────────────── + +class TestSubmitRecords: + def test_submit_records_success(self, client: tuple[ExchangeClient, FakeHttp]) -> None: + """Happy path — HTTP 200 returns SubmitResult with aptos_txs.""" + exc, http = client + http.seed("POST", "/api/exchange/records", _submit_result()) + + result = exc.submit_records( + target_beo="patient.bsp", + records=[_make_record()], # type: ignore[list-item] + consent_token="tok_abc", + ) + + assert result["request_id"] == "req-abc123" + assert result["status"] == "SUCCESS" + assert "0xdeadbeef" in result["aptos_txs"] + + call = http.calls[0] + assert call["method"] == "POST" + + def test_submit_empty_records_raises(self, client: tuple[ExchangeClient, FakeHttp]) -> None: + exc, _ = client + with pytest.raises(ValueError, match="At least one BioRecord"): + exc.submit_records( + target_beo="patient.bsp", + records=[], + consent_token="tok_abc", + ) + + def test_submit_too_many_records_raises(self, client: tuple[ExchangeClient, FakeHttp]) -> None: + exc, _ = client + records = [_make_record() for _ in range(101)] # type: ignore[misc] + with pytest.raises(ValueError, match="Maximum 100"): + exc.submit_records( + target_beo="patient.bsp", + records=records, # type: ignore[arg-type] + consent_token="tok_abc", + ) + + +# ── read_records ────────────────────────────────────────────────────────────── + +class TestReadRecords: + def test_read_records_success(self, client: tuple[ExchangeClient, FakeHttp]) -> None: + """Happy path — HTTP 200 returns ReadResult with records list.""" + exc, http = client + http.seed("GET", "/api/exchange/records", _read_result()) + + result = exc.read_records( + target_beo="patient.bsp", + consent_token="tok_abc", + ) + + assert result["beo_id"] == "550e8400-e29b-41d4-a716-446655440000" + assert len(result["records"]) == 1 + assert result["records"][0]["biomarker"] == "BSP-HM-001" + assert result["has_more"] is False + + def test_read_records_with_filters(self, client: tuple[ExchangeClient, FakeHttp]) -> None: + exc, http = client + http.seed("GET", "/api/exchange/records", _read_result([_make_record("BSP-LA-001")])) + + result = exc.read_records( + target_beo="patient.bsp", + consent_token="tok_abc", + filters=ReadFilters(categories=["BSP-LA"]), + ) + + assert result["records"][0]["biomarker"] == "BSP-LA-001" + + def test_read_records_empty_list(self, client: tuple[ExchangeClient, FakeHttp]) -> None: + exc, http = client + http.seed("GET", "/api/exchange/records", _read_result([])) + + result = exc.read_records( + target_beo="patient.bsp", + consent_token="tok_abc", + ) + + assert result["records"] == [] + assert result["total"] == 0 + + +# ── token requirements ──────────────────────────────────────────────────────── + +class TestTokenRequired: + def test_submit_requires_token(self, client: tuple[ExchangeClient, FakeHttp]) -> None: + """Calling submit_records with empty consent_token must raise.""" + exc, _ = client + with pytest.raises((ValueError, TypeError, Exception)): + exc.submit_records( + target_beo="patient.bsp", + records=[_make_record()], # type: ignore[list-item] + consent_token="", + ) + + def test_read_requires_token(self, client: tuple[ExchangeClient, FakeHttp]) -> None: + """Calling read_records with empty consent_token must raise.""" + exc, _ = client + with pytest.raises((ValueError, TypeError, Exception)): + exc.read_records( + target_beo="patient.bsp", + consent_token="", + ) + + def test_expired_token_raises_bsp_error(self, client: tuple[ExchangeClient, FakeHttp]) -> None: + """API returning TOKEN_EXPIRED (403) propagates as BSPApiError.""" + exc, http = client + http.seed_error( + "GET", "/api/exchange/records", + BSPApiError("TOKEN_EXPIRED", 403), + ) + + with pytest.raises(BSPApiError) as exc_info: + exc.read_records( + target_beo="patient.bsp", + consent_token="tok_expired", + ) + + assert exc_info.value.status_code == 403 From c7071487fab89bf33fd8ae40241d92230863b66b Mon Sep 17 00:00:00 2001 From: Andre Ambrosio Date: Mon, 20 Apr 2026 16:24:17 -0300 Subject: [PATCH 4/4] chore: add .nvmrc (18) and .python-version (3.11) Co-Authored-By: Claude Sonnet 4.6 --- .nvmrc | 1 + .python-version | 1 + 2 files changed, 2 insertions(+) create mode 100644 .nvmrc create mode 100644 .python-version diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..3c03207 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +18 diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..2c07333 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11