aidbox-python-sdk 0.1.25__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {aidbox_python_sdk-0.1.25/aidbox_python_sdk.egg-info → aidbox_python_sdk-0.2.1}/PKG-INFO +78 -2
  2. {aidbox_python_sdk-0.1.25 → aidbox_python_sdk-0.2.1}/README.md +76 -0
  3. {aidbox_python_sdk-0.1.25 → aidbox_python_sdk-0.2.1}/aidbox_python_sdk/__init__.py +1 -1
  4. {aidbox_python_sdk-0.1.25 → aidbox_python_sdk-0.2.1}/aidbox_python_sdk/db.py +19 -8
  5. {aidbox_python_sdk-0.1.25 → aidbox_python_sdk-0.2.1}/aidbox_python_sdk/handlers.py +12 -5
  6. aidbox_python_sdk-0.2.1/aidbox_python_sdk/pytest_plugin.py +165 -0
  7. {aidbox_python_sdk-0.1.25 → aidbox_python_sdk-0.2.1}/aidbox_python_sdk/sdk.py +12 -10
  8. {aidbox_python_sdk-0.1.25 → aidbox_python_sdk-0.2.1}/aidbox_python_sdk/types.py +12 -3
  9. {aidbox_python_sdk-0.1.25 → aidbox_python_sdk-0.2.1/aidbox_python_sdk.egg-info}/PKG-INFO +78 -2
  10. {aidbox_python_sdk-0.1.25 → aidbox_python_sdk-0.2.1}/pyproject.toml +3 -3
  11. {aidbox_python_sdk-0.1.25 → aidbox_python_sdk-0.2.1}/tests/test_sdk.py +48 -19
  12. aidbox_python_sdk-0.1.25/aidbox_python_sdk/pytest_plugin.py +0 -89
  13. {aidbox_python_sdk-0.1.25 → aidbox_python_sdk-0.2.1}/LICENSE.md +0 -0
  14. {aidbox_python_sdk-0.1.25 → aidbox_python_sdk-0.2.1}/MANIFEST.in +0 -0
  15. {aidbox_python_sdk-0.1.25 → aidbox_python_sdk-0.2.1}/aidbox_python_sdk/aidboxpy.py +0 -0
  16. {aidbox_python_sdk-0.1.25 → aidbox_python_sdk-0.2.1}/aidbox_python_sdk/app_keys.py +0 -0
  17. {aidbox_python_sdk-0.1.25 → aidbox_python_sdk-0.2.1}/aidbox_python_sdk/db_migrations.py +0 -0
  18. {aidbox_python_sdk-0.1.25 → aidbox_python_sdk-0.2.1}/aidbox_python_sdk/exceptions.py +0 -0
  19. {aidbox_python_sdk-0.1.25 → aidbox_python_sdk-0.2.1}/aidbox_python_sdk/main.py +0 -0
  20. {aidbox_python_sdk-0.1.25 → aidbox_python_sdk-0.2.1}/aidbox_python_sdk/py.typed +0 -0
  21. {aidbox_python_sdk-0.1.25 → aidbox_python_sdk-0.2.1}/aidbox_python_sdk/settings.py +0 -0
  22. {aidbox_python_sdk-0.1.25 → aidbox_python_sdk-0.2.1}/aidbox_python_sdk.egg-info/SOURCES.txt +0 -0
  23. {aidbox_python_sdk-0.1.25 → aidbox_python_sdk-0.2.1}/aidbox_python_sdk.egg-info/dependency_links.txt +0 -0
  24. {aidbox_python_sdk-0.1.25 → aidbox_python_sdk-0.2.1}/aidbox_python_sdk.egg-info/not-zip-safe +0 -0
  25. {aidbox_python_sdk-0.1.25 → aidbox_python_sdk-0.2.1}/aidbox_python_sdk.egg-info/requires.txt +0 -0
  26. {aidbox_python_sdk-0.1.25 → aidbox_python_sdk-0.2.1}/aidbox_python_sdk.egg-info/top_level.txt +0 -0
  27. {aidbox_python_sdk-0.1.25 → aidbox_python_sdk-0.2.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aidbox-python-sdk
3
- Version: 0.1.25
3
+ Version: 0.2.1
4
4
  Summary: Aidbox SDK for python
5
5
  Author-email: "beda.software" <aidbox-python-sdk@beda.software>
6
6
  License: MIT License
@@ -41,7 +41,7 @@ Classifier: Programming Language :: Python :: 3.11
41
41
  Classifier: Programming Language :: Python :: 3.12
42
42
  Classifier: Topic :: Internet :: WWW/HTTP
43
43
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
44
- Requires-Python: >=3.8
44
+ Requires-Python: >=3.9
45
45
  Description-Content-Type: text/markdown
46
46
  License-File: LICENSE.md
47
47
  Requires-Dist: aiohttp>=3.11.0
@@ -239,4 +239,80 @@ organizationType: non-profit
239
239
  employeesCount: 10
240
240
  ```
241
241
 
242
+ ## Testing with the pytest plugin
243
+
244
+ The SDK provides a pytest plugin that starts your app, exposes fixtures for the SDK and Aidbox client, and helps isolate tests that create resources.
245
+
246
+ ### Activating the plugin
247
+
248
+ In your project’s **`conftest.py`** (e.g. `tests/conftest.py`), register the plugin:
249
+
250
+ ```python
251
+ pytest_plugins = ["aidbox_python_sdk.pytest_plugin"]
252
+ ```
253
+
254
+ ### Configuring the app factory
255
+
256
+ The plugin needs your app factory (the callable that returns the `web.Application`). You can set it in pytest ini:
257
+
258
+ **`pyproject.toml`**
259
+ ```toml
260
+ [tool.pytest.ini_options]
261
+ aidbox_create_app = "main:create_app"
262
+ ```
263
+
264
+ Use the dotted path to your callable: either `module:name` (e.g. `main:create_app`) or `module.submodule.name` (e.g. `mypackage.main.create_app`). The default is `main:create_app`.
265
+
266
+ To use a different factory without changing ini, override the fixture in your `conftest.py`:
267
+
268
+ ```python
269
+ @pytest.fixture(scope="session")
270
+ def create_app():
271
+ from myapp.entry import create_app
272
+ return create_app
273
+ ```
274
+
275
+ ### Fixtures provided
276
+
277
+ | Fixture | Description |
278
+ |------------------|-------------|
279
+ | `app` | The running `web.Application` (server in a background thread on port 8081). |
280
+ | `client` | HTTP client for the app + `client.server.app` for the application instance. |
281
+ | `sdk` | The SDK instance: `app[ak.sdk]`. |
282
+ | `aidbox_client` | `AsyncAidboxClient` for calling Aidbox (operations, `/$psql`, etc.). |
283
+ | `aidbox_db` | DB proxy: `app[ak.db]`. |
284
+ | `safe_db` | Isolated DB for the test; see below. |
285
+
286
+ ### Using `safe_db` for tests that create resources
287
+
288
+ Use the **`safe_db`** fixture in tests that create or change data. It records the current transaction id, runs your test, then rolls back everything created in that test so the DB stays clean.
289
+
290
+ **NOTE:** Without `safe_db`, all subscriptions are implicitly ignored for that test.
291
+
292
+ Example:
293
+
294
+ ```python
295
+ @pytest.mark.asyncio
296
+ async def test_create_patient(aidbox_client, safe_db):
297
+ patient = await aidbox_client.resource("Patient", name=[{"family": "Test"}]).save()
298
+ patients = await aidbox_client.resources("Patient").fetch_all()
299
+ assert len(patients) >= 1
300
+ # after the test, safe_db rolls back and the patient is not persisted
301
+ ```
302
+
303
+ ### Subscription trigger helper (`was_subscription_triggered`)
304
+
305
+ Use **`sdk.was_subscription_triggered(entity)`** (or `was_subscription_triggered_n_times(entity, n)`) only together with the **`safe_db`** fixture. Without `safe_db`, subscription handling is skipped for the test and the returned future is never completed, so the test will hang until the timeout.
306
+
307
+ Example:
308
+
309
+ ```python
310
+ @pytest.mark.asyncio
311
+ async def test_patient_subscription(aidbox_client, safe_db, sdk):
312
+ was_patient_sub_triggered = sdk.was_subscription_triggered("Patient")
313
+ patient = await aidbox_client.resource("Patient", name=[{"family": "Test"}]).save()
314
+ await was_patient_sub_triggered
315
+ # assertions...
316
+ ```
317
+
242
318
 
@@ -177,4 +177,80 @@ organizationType: non-profit
177
177
  employeesCount: 10
178
178
  ```
179
179
 
180
+ ## Testing with the pytest plugin
181
+
182
+ The SDK provides a pytest plugin that starts your app, exposes fixtures for the SDK and Aidbox client, and helps isolate tests that create resources.
183
+
184
+ ### Activating the plugin
185
+
186
+ In your project’s **`conftest.py`** (e.g. `tests/conftest.py`), register the plugin:
187
+
188
+ ```python
189
+ pytest_plugins = ["aidbox_python_sdk.pytest_plugin"]
190
+ ```
191
+
192
+ ### Configuring the app factory
193
+
194
+ The plugin needs your app factory (the callable that returns the `web.Application`). You can set it in pytest ini:
195
+
196
+ **`pyproject.toml`**
197
+ ```toml
198
+ [tool.pytest.ini_options]
199
+ aidbox_create_app = "main:create_app"
200
+ ```
201
+
202
+ Use the dotted path to your callable: either `module:name` (e.g. `main:create_app`) or `module.submodule.name` (e.g. `mypackage.main.create_app`). The default is `main:create_app`.
203
+
204
+ To use a different factory without changing ini, override the fixture in your `conftest.py`:
205
+
206
+ ```python
207
+ @pytest.fixture(scope="session")
208
+ def create_app():
209
+ from myapp.entry import create_app
210
+ return create_app
211
+ ```
212
+
213
+ ### Fixtures provided
214
+
215
+ | Fixture | Description |
216
+ |------------------|-------------|
217
+ | `app` | The running `web.Application` (server in a background thread on port 8081). |
218
+ | `client` | HTTP client for the app + `client.server.app` for the application instance. |
219
+ | `sdk` | The SDK instance: `app[ak.sdk]`. |
220
+ | `aidbox_client` | `AsyncAidboxClient` for calling Aidbox (operations, `/$psql`, etc.). |
221
+ | `aidbox_db` | DB proxy: `app[ak.db]`. |
222
+ | `safe_db` | Isolated DB for the test; see below. |
223
+
224
+ ### Using `safe_db` for tests that create resources
225
+
226
+ Use the **`safe_db`** fixture in tests that create or change data. It records the current transaction id, runs your test, then rolls back everything created in that test so the DB stays clean.
227
+
228
+ **NOTE:** Without `safe_db`, all subscriptions are implicitly ignored for that test.
229
+
230
+ Example:
231
+
232
+ ```python
233
+ @pytest.mark.asyncio
234
+ async def test_create_patient(aidbox_client, safe_db):
235
+ patient = await aidbox_client.resource("Patient", name=[{"family": "Test"}]).save()
236
+ patients = await aidbox_client.resources("Patient").fetch_all()
237
+ assert len(patients) >= 1
238
+ # after the test, safe_db rolls back and the patient is not persisted
239
+ ```
240
+
241
+ ### Subscription trigger helper (`was_subscription_triggered`)
242
+
243
+ Use **`sdk.was_subscription_triggered(entity)`** (or `was_subscription_triggered_n_times(entity, n)`) only together with the **`safe_db`** fixture. Without `safe_db`, subscription handling is skipped for the test and the returned future is never completed, so the test will hang until the timeout.
244
+
245
+ Example:
246
+
247
+ ```python
248
+ @pytest.mark.asyncio
249
+ async def test_patient_subscription(aidbox_client, safe_db, sdk):
250
+ was_patient_sub_triggered = sdk.was_subscription_triggered("Patient")
251
+ patient = await aidbox_client.resource("Patient", name=[{"family": "Test"}]).save()
252
+ await was_patient_sub_triggered
253
+ # assertions...
254
+ ```
255
+
180
256
 
@@ -1,5 +1,5 @@
1
1
  __title__ = "aidbox-python-sdk"
2
- __version__ = "0.1.25"
2
+ __version__ = "0.2.1"
3
3
  __author__ = "beda.software"
4
4
  __license__ = "None"
5
5
  __copyright__ = "Copyright 2024 beda.software"
@@ -1,5 +1,6 @@
1
1
  import json
2
2
  import logging
3
+ from typing import Optional
3
4
 
4
5
  from aiohttp import BasicAuth, ClientSession
5
6
  from sqlalchemy import (
@@ -73,13 +74,13 @@ def create_table(table_name):
73
74
 
74
75
 
75
76
  class DBProxy:
76
- _client = None
77
- _settings = None
78
- _table_cache = None
77
+ _client: Optional[ClientSession] = None
78
+ _settings: Settings
79
+ _table_cache: dict
79
80
 
80
- def __init__(self, settings: Settings):
81
+ def __init__(self, settings: Settings, _table_cache: Optional[dict] = None):
81
82
  self._settings = settings
82
- self._table_cache = {}
83
+ self._table_cache = _table_cache or {}
83
84
 
84
85
  async def initialize(self):
85
86
  basic_auth = BasicAuth(
@@ -87,12 +88,20 @@ class DBProxy:
87
88
  password=self._settings.APP_INIT_CLIENT_SECRET,
88
89
  )
89
90
  self._client = ClientSession(auth=basic_auth)
90
- # TODO: remove _init_table_cache
91
- await self._init_table_cache()
91
+ if not self._table_cache:
92
+ await self._init_table_cache()
92
93
 
93
94
  async def deinitialize(self):
94
95
  await self._client.close()
95
96
 
97
+ def clone(self) -> "DBProxy":
98
+ """
99
+ Create a new DBProxy with the same settings and table cache
100
+ NOTE: it should be initialized after cloning
101
+ """
102
+
103
+ return DBProxy(self._settings, _table_cache=self._table_cache)
104
+
96
105
  async def raw_sql(self, sql_query, *, execute=False):
97
106
  """
98
107
  Executes SQL query and returns result. Specify `execute` to True
@@ -146,7 +155,9 @@ class DBProxy:
146
155
 
147
156
  if not result:
148
157
  # Support legacy instalations with entity attribute data structure
149
- query_url = f"{self._settings.APP_INIT_URL}/Entity?type=resource&_elements=id&_count=999"
158
+ query_url = (
159
+ f"{self._settings.APP_INIT_URL}/Entity?type=resource&_elements=id&_count=999"
160
+ )
150
161
  async with self._client.get(query_url) as resp:
151
162
  json_resp = await resp.json()
152
163
  result = [entry["resource"]["id"] for entry in json_resp.get("entry", [])]
@@ -1,9 +1,10 @@
1
1
  import asyncio
2
+ import json
2
3
  import logging
3
4
  from typing import Any
4
5
 
5
6
  from aiohttp import web
6
- from fhirpy.base.exceptions import OperationOutcome
7
+ from fhirpy.base.exceptions import BaseFHIRError, OperationOutcome
7
8
 
8
9
  from . import app_keys as ak
9
10
 
@@ -48,6 +49,12 @@ async def operation(request: web.Request, data: dict[str, Any]):
48
49
  return result
49
50
  except OperationOutcome as exc:
50
51
  return web.json_response(exc.resource, status=422)
52
+ except BaseFHIRError as exc:
53
+ try:
54
+ payload = json.loads(str(exc))
55
+ return web.json_response(payload, status=422)
56
+ except (json.JSONDecodeError, TypeError):
57
+ return web.Response(text=str(exc), status=422, content_type="text/plain")
51
58
 
52
59
 
53
60
  TYPES = {
@@ -59,10 +66,10 @@ TYPES = {
59
66
  @routes.post("/aidbox")
60
67
  async def dispatch(request):
61
68
  logger.debug("Dispatch new request %s %s", request.method, request.url)
62
- json = await request.json()
63
- if "type" in json and json["type"] in TYPES:
64
- logger.debug("Dispatch to `%s` handler", json["type"])
65
- return await TYPES[json["type"]](request, json)
69
+ data = await request.json()
70
+ if "type" in data and data["type"] in TYPES:
71
+ logger.debug("Dispatch to `%s` handler", data["type"])
72
+ return await TYPES[data["type"]](request, data)
66
73
  req = {
67
74
  "method": request.method,
68
75
  "url": str(request.url),
@@ -0,0 +1,165 @@
1
+ import asyncio
2
+ import importlib
3
+ import os
4
+ import threading
5
+ import warnings
6
+ from collections.abc import Generator
7
+ from types import SimpleNamespace
8
+
9
+ import pytest
10
+ from aiohttp import BasicAuth, ClientSession, web
11
+ from yarl import URL
12
+
13
+ from aidbox_python_sdk.aidboxpy import AsyncAidboxClient
14
+
15
+ from . import app_keys as ak
16
+
17
+ _TEST_SERVER_URL = "http://127.0.0.1:8081"
18
+
19
+
20
+ def pytest_addoption(parser):
21
+ parser.addini(
22
+ "aidbox_create_app",
23
+ "Dotted path to the create_app callable (module:name or module.name), e.g. main:create_app",
24
+ default="main:create_app",
25
+ )
26
+
27
+
28
+ def _load_create_app(path: str):
29
+ """Import and return the create_app callable from the given dotted path."""
30
+ if ":" in path:
31
+ module_path, attr = path.split(":", 1)
32
+ else:
33
+ module_path, attr = path.rsplit(".", 1)
34
+ mod = importlib.import_module(module_path)
35
+ return getattr(mod, attr)
36
+
37
+
38
+ @pytest.fixture(scope="session")
39
+ def create_app(request):
40
+ """App factory; override in conftest or set pytest ini option aidbox_create_app."""
41
+ path = request.config.getini("aidbox_create_app")
42
+ return _load_create_app(path)
43
+
44
+
45
+ @pytest.fixture(scope="session", autouse=True)
46
+ def app(create_app) -> Generator[web.Application, None, None]:
47
+ """Start the aiohttp application server in a background thread.
48
+
49
+ Uses a dedicated event loop running continuously via loop.run_forever()
50
+ in a daemon thread. This is necessary (rather than an async fixture) because
51
+ the app needs the loop running at all times to handle external callbacks
52
+ from Aidbox (subscriptions, SDK heartbeats, etc.), not just during await
53
+ points in test code.
54
+
55
+ The server is guaranteed to be listening before any test runs (no sleep-based
56
+ waiting) since site.start() completes synchronously before yielding.
57
+ """
58
+
59
+ app = create_app()
60
+ app[ak.sdk]._test_start_txid = -1
61
+ loop = asyncio.new_event_loop()
62
+
63
+ runner = web.AppRunner(app)
64
+ loop.run_until_complete(runner.setup())
65
+ site = web.TCPSite(runner, host="0.0.0.0", port=8081)
66
+ loop.run_until_complete(site.start())
67
+
68
+ thread = threading.Thread(target=loop.run_forever, daemon=True)
69
+ thread.start()
70
+
71
+ yield app
72
+
73
+ loop.call_soon_threadsafe(loop.stop)
74
+ thread.join(timeout=5)
75
+ loop.run_until_complete(runner.cleanup())
76
+ loop.close()
77
+
78
+
79
+ @pytest.fixture
80
+ async def client(app):
81
+ server = SimpleNamespace(app=app)
82
+ session = ClientSession(base_url=URL(_TEST_SERVER_URL))
83
+ wrapper = SimpleNamespace(server=server)
84
+ wrapper.get = session.get
85
+ wrapper.post = session.post
86
+ wrapper.put = session.put
87
+ wrapper.patch = session.patch
88
+ wrapper.delete = session.delete
89
+ wrapper.request = session.request
90
+ wrapper._session = session
91
+ try:
92
+ yield wrapper
93
+ finally:
94
+ await session.close()
95
+
96
+
97
+ @pytest.fixture
98
+ async def safe_db(aidbox_client: AsyncAidboxClient, sdk):
99
+ results = await aidbox_client.execute(
100
+ "/$psql",
101
+ data={"query": "SELECT last_value from transaction_id_seq;"},
102
+ )
103
+ txid = results[0]["result"][0]["last_value"]
104
+ sdk._test_start_txid = int(txid)
105
+
106
+ yield txid
107
+
108
+ sdk._test_start_txid = -1
109
+ await aidbox_client.execute(
110
+ "/$psql",
111
+ data={"query": f"select drop_before_all({txid});"},
112
+ params={"execute": "true"},
113
+ )
114
+
115
+
116
+ @pytest.fixture
117
+ def sdk(app):
118
+ return app[ak.sdk]
119
+
120
+
121
+ @pytest.fixture
122
+ def aidbox_client(app):
123
+ return app[ak.client]
124
+
125
+
126
+ @pytest.fixture
127
+ async def aidbox_db(app):
128
+ # We clone it to init client session in the test thread
129
+ # because app is running in another thread with own loop
130
+ db = app[ak.db].clone()
131
+ await db.initialize()
132
+ yield db
133
+ await db.deinitialize()
134
+
135
+
136
+ # Deprecated
137
+ class AidboxSession(ClientSession):
138
+ def __init__(self, *args, base_url=None, **kwargs):
139
+ base_url_resolved = base_url or os.environ.get("AIDBOX_BASE_URL")
140
+ assert base_url_resolved, "Either base_url arg or AIDBOX_BASE_URL env var must be set"
141
+ self.base_url = URL(base_url_resolved)
142
+ super().__init__(*args, **kwargs)
143
+
144
+ def make_url(self, path):
145
+ return self.base_url.with_path(path)
146
+
147
+ async def _request(self, method, path, *args, **kwargs):
148
+ url = self.make_url(path)
149
+ return await super()._request(method, url, *args, **kwargs)
150
+
151
+
152
+ @pytest.fixture
153
+ async def aidbox(sdk, app):
154
+ warnings.warn(
155
+ "The 'aidbox' fixture is deprecated; use 'aidbox_client' for the Aidbox client instead.",
156
+ DeprecationWarning,
157
+ stacklevel=2,
158
+ )
159
+ basic_auth = BasicAuth(
160
+ login=app[ak.settings].APP_INIT_CLIENT_ID,
161
+ password=app[ak.settings].APP_INIT_CLIENT_SECRET,
162
+ )
163
+ session = AidboxSession(auth=basic_auth, base_url=app[ak.settings].APP_INIT_URL)
164
+ yield session
165
+ await session.close()
@@ -11,9 +11,12 @@ from .types import Compliance
11
11
 
12
12
  logger = logging.getLogger("aidbox_sdk")
13
13
 
14
+ # (target_loop, future, counter) per entity for was_subscription_triggered_*
15
+ _SubTriggered = dict[str, tuple[asyncio.AbstractEventLoop, asyncio.Future[bool], int]]
16
+
14
17
 
15
18
  class SDK:
16
- def __init__( # noqa: PLR0913
19
+ def __init__(
17
20
  self,
18
21
  settings,
19
22
  *,
@@ -42,7 +45,7 @@ class SDK:
42
45
  self._seeds = seeds or {}
43
46
  self._migrations = migrations or []
44
47
  self._app_endpoint_name = f"{settings.APP_ID}-endpoint"
45
- self._sub_triggered = {}
48
+ self._sub_triggered: _SubTriggered = {}
46
49
  self._test_start_txid = None
47
50
 
48
51
  async def apply_migrations(self, client: AsyncAidboxClient):
@@ -112,14 +115,14 @@ class SDK:
112
115
  result = coro_or_result
113
116
 
114
117
  if entity in self._sub_triggered:
115
- future, counter = self._sub_triggered[entity]
118
+ target_loop, future, counter = self._sub_triggered[entity]
116
119
  if counter > 1:
117
- self._sub_triggered[entity] = (future, counter - 1)
120
+ self._sub_triggered[entity] = (target_loop, future, counter - 1)
118
121
  elif future.done():
119
122
  pass
120
123
  # logger.warning('Uncaught subscription for %s', entity)
121
124
  else:
122
- future.set_result(True)
125
+ target_loop.call_soon_threadsafe(future.set_result, True)
123
126
 
124
127
  return result
125
128
 
@@ -133,14 +136,13 @@ class SDK:
133
136
 
134
137
  def was_subscription_triggered_n_times(self, entity, counter):
135
138
  timeout = 10
136
-
137
- future = asyncio.Future()
138
- self._sub_triggered[entity] = (future, counter)
139
- asyncio.get_event_loop().call_later(
139
+ target_loop = asyncio.get_running_loop()
140
+ future = target_loop.create_future()
141
+ self._sub_triggered[entity] = (target_loop, future, counter)
142
+ target_loop.call_later(
140
143
  timeout,
141
144
  lambda: None if future.done() else future.set_exception(Exception()),
142
145
  )
143
-
144
146
  return future
145
147
 
146
148
  def was_subscription_triggered(self, entity):
@@ -1,16 +1,25 @@
1
- from typing import Any, List
1
+ from typing import Any
2
2
 
3
3
  from aiohttp import web
4
4
  from typing_extensions import TypedDict
5
5
 
6
+
6
7
  class Compliance(TypedDict, total=True):
7
8
  fhirUrl: str
8
9
  fhirCode: str
9
- fhirResource: List[str]
10
+ fhirResource: list[str]
11
+
10
12
 
11
13
  SDKOperationRequest = TypedDict(
12
14
  "SDKOperationRequest",
13
- {"app": web.Application, "params": dict, "route-params": dict, "headers": dict, "resource": Any},
15
+ {
16
+ "app": web.Application,
17
+ "params": dict,
18
+ "route-params": dict,
19
+ "form-params": dict,
20
+ "headers": dict,
21
+ "resource": Any,
22
+ },
14
23
  )
15
24
 
16
25
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aidbox-python-sdk
3
- Version: 0.1.25
3
+ Version: 0.2.1
4
4
  Summary: Aidbox SDK for python
5
5
  Author-email: "beda.software" <aidbox-python-sdk@beda.software>
6
6
  License: MIT License
@@ -41,7 +41,7 @@ Classifier: Programming Language :: Python :: 3.11
41
41
  Classifier: Programming Language :: Python :: 3.12
42
42
  Classifier: Topic :: Internet :: WWW/HTTP
43
43
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
44
- Requires-Python: >=3.8
44
+ Requires-Python: >=3.9
45
45
  Description-Content-Type: text/markdown
46
46
  License-File: LICENSE.md
47
47
  Requires-Dist: aiohttp>=3.11.0
@@ -239,4 +239,80 @@ organizationType: non-profit
239
239
  employeesCount: 10
240
240
  ```
241
241
 
242
+ ## Testing with the pytest plugin
243
+
244
+ The SDK provides a pytest plugin that starts your app, exposes fixtures for the SDK and Aidbox client, and helps isolate tests that create resources.
245
+
246
+ ### Activating the plugin
247
+
248
+ In your project’s **`conftest.py`** (e.g. `tests/conftest.py`), register the plugin:
249
+
250
+ ```python
251
+ pytest_plugins = ["aidbox_python_sdk.pytest_plugin"]
252
+ ```
253
+
254
+ ### Configuring the app factory
255
+
256
+ The plugin needs your app factory (the callable that returns the `web.Application`). You can set it in pytest ini:
257
+
258
+ **`pyproject.toml`**
259
+ ```toml
260
+ [tool.pytest.ini_options]
261
+ aidbox_create_app = "main:create_app"
262
+ ```
263
+
264
+ Use the dotted path to your callable: either `module:name` (e.g. `main:create_app`) or `module.submodule.name` (e.g. `mypackage.main.create_app`). The default is `main:create_app`.
265
+
266
+ To use a different factory without changing ini, override the fixture in your `conftest.py`:
267
+
268
+ ```python
269
+ @pytest.fixture(scope="session")
270
+ def create_app():
271
+ from myapp.entry import create_app
272
+ return create_app
273
+ ```
274
+
275
+ ### Fixtures provided
276
+
277
+ | Fixture | Description |
278
+ |------------------|-------------|
279
+ | `app` | The running `web.Application` (server in a background thread on port 8081). |
280
+ | `client` | HTTP client for the app + `client.server.app` for the application instance. |
281
+ | `sdk` | The SDK instance: `app[ak.sdk]`. |
282
+ | `aidbox_client` | `AsyncAidboxClient` for calling Aidbox (operations, `/$psql`, etc.). |
283
+ | `aidbox_db` | DB proxy: `app[ak.db]`. |
284
+ | `safe_db` | Isolated DB for the test; see below. |
285
+
286
+ ### Using `safe_db` for tests that create resources
287
+
288
+ Use the **`safe_db`** fixture in tests that create or change data. It records the current transaction id, runs your test, then rolls back everything created in that test so the DB stays clean.
289
+
290
+ **NOTE:** Without `safe_db`, all subscriptions are implicitly ignored for that test.
291
+
292
+ Example:
293
+
294
+ ```python
295
+ @pytest.mark.asyncio
296
+ async def test_create_patient(aidbox_client, safe_db):
297
+ patient = await aidbox_client.resource("Patient", name=[{"family": "Test"}]).save()
298
+ patients = await aidbox_client.resources("Patient").fetch_all()
299
+ assert len(patients) >= 1
300
+ # after the test, safe_db rolls back and the patient is not persisted
301
+ ```
302
+
303
+ ### Subscription trigger helper (`was_subscription_triggered`)
304
+
305
+ Use **`sdk.was_subscription_triggered(entity)`** (or `was_subscription_triggered_n_times(entity, n)`) only together with the **`safe_db`** fixture. Without `safe_db`, subscription handling is skipped for the test and the returned future is never completed, so the test will hang until the timeout.
306
+
307
+ Example:
308
+
309
+ ```python
310
+ @pytest.mark.asyncio
311
+ async def test_patient_subscription(aidbox_client, safe_db, sdk):
312
+ was_patient_sub_triggered = sdk.was_subscription_triggered("Patient")
313
+ patient = await aidbox_client.resource("Patient", name=[{"family": "Test"}]).save()
314
+ await was_patient_sub_triggered
315
+ # assertions...
316
+ ```
317
+
242
318
 
@@ -12,7 +12,7 @@ pre-commit = ["autohooks.plugins.black", "autohooks.plugins.ruff"]
12
12
 
13
13
  [tool.black]
14
14
  line-length = 100
15
- target-version = ['py311']
15
+ target-version = ['py39']
16
16
  exclude = '''
17
17
  (
18
18
  /(
@@ -25,7 +25,7 @@ exclude = '''
25
25
  '''
26
26
 
27
27
  [tool.ruff]
28
- target-version = "py311"
28
+ target-version = "py39"
29
29
  line-length = 100
30
30
  extend-exclude = ["example"]
31
31
 
@@ -83,7 +83,7 @@ classifiers = [
83
83
  "Topic :: Internet :: WWW/HTTP",
84
84
  "Topic :: Software Development :: Libraries :: Python Modules",
85
85
  ]
86
- requires-python = ">=3.8"
86
+ requires-python = ">=3.9"
87
87
 
88
88
  [project.optional-dependencies]
89
89
  test = [
@@ -4,12 +4,14 @@ from unittest import mock
4
4
 
5
5
  import pytest
6
6
  from fhirpathpy import evaluate
7
+ from fhirpy.base.exceptions import OperationOutcome
7
8
 
8
9
  import main
10
+ from aidbox_python_sdk.db import DBProxy
9
11
 
10
12
 
11
13
  @pytest.mark.skip("Skipped because of regression in Aidbox 2510")
12
- @pytest.mark.asyncio()
14
+ @pytest.mark.asyncio
13
15
  @pytest.mark.parametrize(
14
16
  ("expression", "expected"),
15
17
  [
@@ -36,7 +38,7 @@ async def test_operation_with_compliance_params(aidbox_client, expression, expec
36
38
  assert evaluate(response, expression, {})[0] == expected
37
39
 
38
40
 
39
- @pytest.mark.asyncio()
41
+ @pytest.mark.asyncio
40
42
  async def test_health_check(client):
41
43
  resp = await client.get("/health")
42
44
  assert resp.status == 200
@@ -44,7 +46,7 @@ async def test_health_check(client):
44
46
  assert json == {"status": "OK"}
45
47
 
46
48
 
47
- @pytest.mark.asyncio()
49
+ @pytest.mark.asyncio
48
50
  async def test_live_health_check(client):
49
51
  resp = await client.get("/live")
50
52
  assert resp.status == 200
@@ -52,34 +54,30 @@ async def test_live_health_check(client):
52
54
  assert json == {"status": "OK"}
53
55
 
54
56
 
55
- @pytest.mark.skip()
56
- async def test_signup_reg_op(client, aidbox):
57
- resp = await aidbox.post("signup/register/21.02.19/testvalue")
58
- assert resp.status == 200
59
- json = await resp.json()
57
+ @pytest.mark.asyncio
58
+ async def test_signup_reg_op(aidbox_client):
59
+ json = await aidbox_client.execute("signup/register/21.02.19/testvalue")
60
60
  assert json == {
61
61
  "success": "Ok",
62
62
  "request": {"date": "21.02.19", "test": "testvalue"},
63
63
  }
64
64
 
65
65
 
66
- @pytest.mark.skip()
67
- async def test_appointment_sub(client, aidbox):
66
+ @pytest.mark.asyncio
67
+ async def test_appointment_sub(sdk, aidbox_client, safe_db):
68
68
  with mock.patch.object(main, "_appointment_sub") as appointment_sub:
69
69
  f = asyncio.Future()
70
70
  f.set_result("")
71
71
  appointment_sub.return_value = f
72
- sdk = client.server.app["sdk"]
73
72
  was_appointment_sub_triggered = sdk.was_subscription_triggered("Appointment")
74
- resp = await aidbox.post(
73
+ resource = aidbox_client.resource(
75
74
  "Appointment",
76
- json={
75
+ **{
77
76
  "status": "proposed",
78
77
  "participant": [{"status": "accepted"}],
79
- "resourceType": "Appointment",
80
78
  },
81
79
  )
82
- assert resp.status == 201
80
+ await resource.save()
83
81
  await was_appointment_sub_triggered
84
82
  event = appointment_sub.call_args_list[0][0][0]
85
83
  logging.debug("event: %s", event)
@@ -94,7 +92,7 @@ async def test_appointment_sub(client, aidbox):
94
92
  assert expected["resource"].items() <= event["resource"].items()
95
93
 
96
94
 
97
- @pytest.mark.asyncio()
95
+ @pytest.mark.asyncio
98
96
  async def test_database_isolation__1(aidbox_client, safe_db):
99
97
  patients = await aidbox_client.resources("Patient").fetch_all()
100
98
  assert len(patients) == 2
@@ -106,7 +104,7 @@ async def test_database_isolation__1(aidbox_client, safe_db):
106
104
  assert len(patients) == 3
107
105
 
108
106
 
109
- @pytest.mark.asyncio()
107
+ @pytest.mark.asyncio
110
108
  async def test_database_isolation__2(aidbox_client, safe_db):
111
109
  patients = await aidbox_client.resources("Patient").fetch_all()
112
110
  assert len(patients) == 2
@@ -121,7 +119,7 @@ async def test_database_isolation__2(aidbox_client, safe_db):
121
119
  assert len(patients) == 4
122
120
 
123
121
 
124
- @pytest.mark.asyncio()
122
+ @pytest.mark.asyncio
125
123
  async def test_database_isolation_with_history_in_name__1(aidbox_client, safe_db):
126
124
  resources = await aidbox_client.resources("FamilyMemberHistory").fetch_all()
127
125
  assert len(resources) == 0
@@ -144,7 +142,7 @@ async def test_database_isolation_with_history_in_name__1(aidbox_client, safe_db
144
142
  assert len(resources) == 1
145
143
 
146
144
 
147
- @pytest.mark.asyncio()
145
+ @pytest.mark.asyncio
148
146
  async def test_database_isolation_with_history_in_name__2(aidbox_client, safe_db):
149
147
  resources = await aidbox_client.resources("FamilyMemberHistory").fetch_all()
150
148
  assert len(resources) == 0
@@ -179,3 +177,34 @@ async def test_database_isolation_with_history_in_name__2(aidbox_client, safe_db
179
177
 
180
178
  resources = await aidbox_client.resources("FamilyMemberHistory").fetch_all()
181
179
  assert len(resources) == 2
180
+
181
+
182
+ async def test_aidbox_db_fixture(client, aidbox_db: DBProxy, safe_db):
183
+ """
184
+ Test that aidbox_db fixture works with isolated DB Proxy from app's instance
185
+ """
186
+ response = await client.get("/db-tests")
187
+ assert response.status == 200
188
+ json = await response.json()
189
+ assert json == [{"id": "app-test"}]
190
+
191
+ app_ids = await main.get_app_ids(aidbox_db)
192
+ assert app_ids == [{"id": "app-test"}]
193
+
194
+
195
+ async def test_operation_base_fhir_error_json_test_op(aidbox_client):
196
+ with pytest.raises(OperationOutcome) as exc:
197
+ await aidbox_client.execute("/$base-fhir-error-json-test")
198
+ assert exc.value.resource.get("issue")[0].get("diagnostics") == "Resource Patient/id not found"
199
+
200
+
201
+ async def test_operation_base_fhir_error_text_test_op(aidbox_client):
202
+ with pytest.raises(OperationOutcome) as exc:
203
+ await aidbox_client.execute("/$base-fhir-error-text-test")
204
+ assert exc.value.resource.get("issue")[0].get("diagnostics") == "plain"
205
+
206
+
207
+ async def test_operation_outcome_test_op(aidbox_client):
208
+ with pytest.raises(OperationOutcome) as exc:
209
+ await aidbox_client.execute("/$operation-outcome-test")
210
+ assert exc.value.resource.get("issue")[0].get("diagnostics") == "test reason"
@@ -1,89 +0,0 @@
1
- import os
2
- from typing import cast
3
-
4
- import pytest
5
- from aiohttp import BasicAuth, ClientSession, web
6
- from yarl import URL
7
-
8
- from main import create_app as _create_app
9
-
10
- from . import app_keys as ak
11
-
12
-
13
- async def start_app(aiohttp_client):
14
- app = await aiohttp_client(_create_app(), server_kwargs={"host": "0.0.0.0", "port": 8081})
15
- sdk = cast(web.Application, app.server.app)[ak.sdk]
16
- sdk._test_start_txid = -1
17
-
18
- return app
19
-
20
-
21
- @pytest.fixture
22
- async def client(aiohttp_client):
23
- """Instance of app's server and client"""
24
- return await start_app(aiohttp_client)
25
-
26
-
27
- class AidboxSession(ClientSession):
28
- def __init__(self, *args, base_url=None, **kwargs):
29
- base_url_resolved = base_url or os.environ.get("AIDBOX_BASE_URL")
30
- assert base_url_resolved, "Either base_url arg or AIDBOX_BASE_URL env var must be set"
31
- self.base_url = URL(base_url_resolved)
32
- super().__init__(*args, **kwargs)
33
-
34
- def make_url(self, path):
35
- return self.base_url.with_path(path)
36
-
37
- async def _request(self, method, path, *args, **kwargs):
38
- url = self.make_url(path)
39
- return await super()._request(method, url, *args, **kwargs)
40
-
41
-
42
- @pytest.fixture
43
- async def aidbox(client):
44
- """HTTP client for making requests to Aidbox"""
45
- app = cast(web.Application, client.server.app)
46
- basic_auth = BasicAuth(
47
- login=app[ak.settings].APP_INIT_CLIENT_ID,
48
- password=app[ak.settings].APP_INIT_CLIENT_SECRET,
49
- )
50
- session = AidboxSession(auth=basic_auth, base_url=app[ak.settings].APP_INIT_URL)
51
- yield session
52
- await session.close()
53
-
54
-
55
- @pytest.fixture
56
- async def safe_db(aidbox, client, sdk):
57
- resp = await aidbox.post(
58
- "/$psql",
59
- json={"query": "SELECT last_value from transaction_id_seq;"},
60
- raise_for_status=True,
61
- )
62
- results = await resp.json()
63
- txid = results[0]["result"][0]["last_value"]
64
- sdk._test_start_txid = int(txid)
65
-
66
- yield txid
67
-
68
- sdk._test_start_txid = -1
69
- await aidbox.post(
70
- "/$psql",
71
- json={"query": f"select drop_before_all({txid});"},
72
- params={"execute": "true"},
73
- raise_for_status=True,
74
- )
75
-
76
-
77
- @pytest.fixture
78
- def sdk(client):
79
- return cast(web.Application, client.server.app)[ak.sdk]
80
-
81
-
82
- @pytest.fixture
83
- def aidbox_client(client):
84
- return cast(web.Application, client.server.app)[ak.client]
85
-
86
-
87
- @pytest.fixture
88
- def aidbox_db(client):
89
- return cast(web.Application, client.server.app)[ak.db]