involve-benchling 0.1.0a1__py3-none-any.whl
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.
- involve_benchling/__init__.py +49 -0
- involve_benchling/cache/__init__.py +160 -0
- involve_benchling/cache/backends.py +164 -0
- involve_benchling/cache/policy.py +39 -0
- involve_benchling/core/__init__.py +6 -0
- involve_benchling/core/client.py +78 -0
- involve_benchling/core/config.py +80 -0
- involve_benchling/core/readonly.py +106 -0
- involve_benchling/dataframes.py +180 -0
- involve_benchling/errors.py +48 -0
- involve_benchling/py.typed +0 -0
- involve_benchling/resolve/__init__.py +111 -0
- involve_benchling/testing/__init__.py +14 -0
- involve_benchling/testing/plugin.py +49 -0
- involve_benchling/testing/tracker.py +59 -0
- involve_benchling/tools/__init__.py +8 -0
- involve_benchling/version.py +1 -0
- involve_benchling-0.1.0a1.dist-info/METADATA +192 -0
- involve_benchling-0.1.0a1.dist-info/RECORD +22 -0
- involve_benchling-0.1.0a1.dist-info/WHEEL +4 -0
- involve_benchling-0.1.0a1.dist-info/entry_points.txt +2 -0
- involve_benchling-0.1.0a1.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""involve-benchling: the toolkit the official Benchling SDK doesn't give you.
|
|
2
|
+
|
|
3
|
+
A thin, composable layer over ``benchling_v3_sdk`` -- it never hides the
|
|
4
|
+
SDK. Pillars:
|
|
5
|
+
|
|
6
|
+
- :mod:`involve_benchling.cache` -- tiered, pickle-free response caching.
|
|
7
|
+
- :mod:`involve_benchling.resolve` -- generic name->object resolution with
|
|
8
|
+
typed errors, over any V3 resource.
|
|
9
|
+
- :mod:`involve_benchling.dataframes` -- pandas adapters (optional extra).
|
|
10
|
+
- :mod:`involve_benchling.testing` -- pytest fixtures and resource cleanup.
|
|
11
|
+
- :mod:`involve_benchling.tools` -- cross-resource workflows (roadmap).
|
|
12
|
+
|
|
13
|
+
Quick start::
|
|
14
|
+
|
|
15
|
+
from involve_benchling import BenchlingConfig, connect, resolve
|
|
16
|
+
|
|
17
|
+
config = BenchlingConfig.from_env()
|
|
18
|
+
benchling = connect(config)
|
|
19
|
+
plasmid = resolve.get_by_name(benchling.custom_entity, "pEX-001")
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from . import resolve
|
|
23
|
+
from .cache import BenchlingCache, CachePolicy
|
|
24
|
+
from .core import BenchlingConfig, connect, connect_async
|
|
25
|
+
from .errors import (
|
|
26
|
+
AmbiguousError,
|
|
27
|
+
CacheError,
|
|
28
|
+
ConfigError,
|
|
29
|
+
InvolveBenchlingError,
|
|
30
|
+
NotFoundError,
|
|
31
|
+
ReadOnlyViolationError,
|
|
32
|
+
)
|
|
33
|
+
from .version import __version__
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"AmbiguousError",
|
|
37
|
+
"BenchlingCache",
|
|
38
|
+
"BenchlingConfig",
|
|
39
|
+
"CacheError",
|
|
40
|
+
"CachePolicy",
|
|
41
|
+
"ConfigError",
|
|
42
|
+
"InvolveBenchlingError",
|
|
43
|
+
"NotFoundError",
|
|
44
|
+
"ReadOnlyViolationError",
|
|
45
|
+
"__version__",
|
|
46
|
+
"connect",
|
|
47
|
+
"connect_async",
|
|
48
|
+
"resolve",
|
|
49
|
+
]
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Caching for Benchling API responses.
|
|
2
|
+
|
|
3
|
+
The official SDK does no caching; reference data (schemas, dropdowns,
|
|
4
|
+
registries, users) rarely changes but is fetched constantly. A
|
|
5
|
+
:class:`BenchlingCache` turns those ~500ms calls into sub-millisecond
|
|
6
|
+
lookups.
|
|
7
|
+
|
|
8
|
+
Values round-trip through JSON (never pickle). Benchling SDK models are
|
|
9
|
+
Pydantic models and are serialised losslessly. A cached model is rebuilt
|
|
10
|
+
only from a ``benchling_v3_sdk`` class or the ``model`` passed to
|
|
11
|
+
:meth:`BenchlingCache.get_or_fetch`, so a tampered cache file cannot make
|
|
12
|
+
the cache import arbitrary modules::
|
|
13
|
+
|
|
14
|
+
from involve_benchling.cache import BenchlingCache
|
|
15
|
+
from benchling_v3_sdk.types.custom_entity import CustomEntity
|
|
16
|
+
|
|
17
|
+
cache = BenchlingCache.from_policy(policy, namespace=config.base_url)
|
|
18
|
+
|
|
19
|
+
entities = cache.get_or_fetch(
|
|
20
|
+
"entities:by-schema:ts_abc123",
|
|
21
|
+
category="entity",
|
|
22
|
+
model=CustomEntity,
|
|
23
|
+
fetch=lambda: list(benchling.custom_entity.list(schema_eq="ts_abc123")),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# after a write:
|
|
27
|
+
cache.invalidate("entities:")
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import hashlib
|
|
33
|
+
import importlib
|
|
34
|
+
from collections.abc import Callable, Sequence
|
|
35
|
+
from typing import Any, TypeVar
|
|
36
|
+
|
|
37
|
+
from pydantic import BaseModel
|
|
38
|
+
|
|
39
|
+
from .backends import MISSING, CacheBackend, DiskBackend, MemoryBackend, TieredBackend
|
|
40
|
+
from .policy import DEFAULT_TTLS, CachePolicy
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"DEFAULT_TTLS",
|
|
44
|
+
"MISSING",
|
|
45
|
+
"BenchlingCache",
|
|
46
|
+
"CacheBackend",
|
|
47
|
+
"CachePolicy",
|
|
48
|
+
"DiskBackend",
|
|
49
|
+
"MemoryBackend",
|
|
50
|
+
"TieredBackend",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
M = TypeVar("M", bound=BaseModel)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _encode(value: Any) -> Any:
|
|
57
|
+
"""Encode a value (possibly a Pydantic model or list of them) to JSON-safe form."""
|
|
58
|
+
if isinstance(value, BaseModel):
|
|
59
|
+
return {"__model__": _model_ref(type(value)), "data": value.model_dump_json()}
|
|
60
|
+
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
|
|
61
|
+
return {"__list__": [_encode(item) for item in value]}
|
|
62
|
+
return {"__json__": value}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _decode(doc: Any, model: type[BaseModel] | None = None) -> Any:
|
|
66
|
+
if not isinstance(doc, dict):
|
|
67
|
+
return doc
|
|
68
|
+
if "__model__" in doc:
|
|
69
|
+
ref = doc["__model__"]
|
|
70
|
+
model_cls = model if model and ref == _model_ref(model) else _import_model(ref)
|
|
71
|
+
return model_cls.model_validate_json(doc["data"])
|
|
72
|
+
if "__list__" in doc:
|
|
73
|
+
return [_decode(item, model) for item in doc["__list__"]]
|
|
74
|
+
return doc.get("__json__")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _model_ref(cls: type[BaseModel]) -> str:
|
|
78
|
+
return f"{cls.__module__}:{cls.__qualname__}"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
#: Packages whose models the cache imports by name when rehydrating.
|
|
82
|
+
_TRUSTED_MODEL_PACKAGES = ("benchling_v3_sdk",)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _import_model(ref: str) -> type[BaseModel]:
|
|
86
|
+
module_name, _, qualname = ref.partition(":")
|
|
87
|
+
if module_name.partition(".")[0] not in _TRUSTED_MODEL_PACKAGES:
|
|
88
|
+
raise TypeError(f"{ref} is not a Benchling SDK model; pass it as model=")
|
|
89
|
+
obj: Any = importlib.import_module(module_name)
|
|
90
|
+
for part in qualname.split("."):
|
|
91
|
+
obj = getattr(obj, part)
|
|
92
|
+
if not (isinstance(obj, type) and issubclass(obj, BaseModel)):
|
|
93
|
+
raise TypeError(f"{ref} is not a Pydantic model")
|
|
94
|
+
return obj
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class BenchlingCache:
|
|
98
|
+
"""High-level cache facade with stats and model-aware serialisation."""
|
|
99
|
+
|
|
100
|
+
def __init__(self, backend: CacheBackend, policy: CachePolicy | None = None) -> None:
|
|
101
|
+
self._backend = backend
|
|
102
|
+
self._policy = policy or CachePolicy()
|
|
103
|
+
self.hits = 0
|
|
104
|
+
self.misses = 0
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def from_policy(cls, policy: CachePolicy, *, namespace: str = "") -> BenchlingCache:
|
|
108
|
+
"""Build the standard tiered cache described by ``policy``.
|
|
109
|
+
|
|
110
|
+
``namespace`` (typically the tenant base URL) isolates cache
|
|
111
|
+
directories between tenants.
|
|
112
|
+
"""
|
|
113
|
+
memory = MemoryBackend(policy.max_memory_entries)
|
|
114
|
+
if policy.directory is None:
|
|
115
|
+
return cls(memory, policy)
|
|
116
|
+
subdir = hashlib.sha256(namespace.encode()).hexdigest()[:12] if namespace else "default"
|
|
117
|
+
disk = DiskBackend(policy.directory / subdir)
|
|
118
|
+
return cls(TieredBackend(memory, disk), policy)
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def hit_rate(self) -> float:
|
|
122
|
+
total = self.hits + self.misses
|
|
123
|
+
return self.hits / total if total else 0.0
|
|
124
|
+
|
|
125
|
+
def get_or_fetch(
|
|
126
|
+
self,
|
|
127
|
+
key: str,
|
|
128
|
+
*,
|
|
129
|
+
fetch: Callable[[], Any],
|
|
130
|
+
category: str = "",
|
|
131
|
+
ttl: float | None = None,
|
|
132
|
+
model: type[BaseModel] | None = None,
|
|
133
|
+
) -> Any:
|
|
134
|
+
"""Return the cached value for ``key``, calling ``fetch`` on a miss.
|
|
135
|
+
|
|
136
|
+
``ttl`` overrides the policy TTL for ``category``. ``model`` names a
|
|
137
|
+
Pydantic class outside ``benchling_v3_sdk`` that the cached value may
|
|
138
|
+
hold; SDK models need no ``model``. An entry that cannot be decoded
|
|
139
|
+
counts as a miss.
|
|
140
|
+
"""
|
|
141
|
+
if not self._policy.enabled:
|
|
142
|
+
return fetch()
|
|
143
|
+
doc = self._backend.get(key)
|
|
144
|
+
if doc is not MISSING:
|
|
145
|
+
try:
|
|
146
|
+
value = _decode(doc, model)
|
|
147
|
+
except (ImportError, AttributeError, TypeError, ValueError):
|
|
148
|
+
pass
|
|
149
|
+
else:
|
|
150
|
+
self.hits += 1
|
|
151
|
+
return value
|
|
152
|
+
self.misses += 1
|
|
153
|
+
value = fetch()
|
|
154
|
+
resolved_ttl = ttl if ttl is not None else self._policy.ttl_for(category)
|
|
155
|
+
self._backend.set(key, _encode(value), resolved_ttl)
|
|
156
|
+
return value
|
|
157
|
+
|
|
158
|
+
def invalidate(self, prefix: str = "") -> int:
|
|
159
|
+
"""Drop every entry whose key starts with ``prefix``. Returns the count."""
|
|
160
|
+
return self._backend.invalidate(prefix)
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Cache backends.
|
|
2
|
+
|
|
3
|
+
Values are stored as JSON documents, never pickle. Pydantic models from
|
|
4
|
+
the Benchling SDK are serialised with ``model_dump_json`` and rehydrated
|
|
5
|
+
with ``model_validate_json``.
|
|
6
|
+
|
|
7
|
+
The disk tier creates its directory and files readable only by the current
|
|
8
|
+
user, because cached responses can hold sensitive tenant data.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import contextlib
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import tempfile
|
|
18
|
+
import time
|
|
19
|
+
from collections import OrderedDict
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Protocol, runtime_checkable
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _Missing:
|
|
25
|
+
def __repr__(self) -> str: # pragma: no cover
|
|
26
|
+
return "<MISSING>"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
#: Sentinel returned by :meth:`CacheBackend.get` on a miss.
|
|
30
|
+
MISSING: Any = _Missing()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@runtime_checkable
|
|
34
|
+
class CacheBackend(Protocol):
|
|
35
|
+
"""Minimal interface a cache backend must implement.
|
|
36
|
+
|
|
37
|
+
Values are JSON-serialisable documents (dict/list/str/num/bool/None).
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def get(self, key: str) -> Any: ...
|
|
41
|
+
|
|
42
|
+
def set(self, key: str, value: Any, ttl: float) -> None: ...
|
|
43
|
+
|
|
44
|
+
def invalidate(self, prefix: str = "") -> int: ...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class MemoryBackend:
|
|
48
|
+
"""In-process LRU cache with per-entry TTL."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, max_entries: int = 1024) -> None:
|
|
51
|
+
self._max = max_entries
|
|
52
|
+
self._data: OrderedDict[str, tuple[float, Any]] = OrderedDict()
|
|
53
|
+
|
|
54
|
+
def get(self, key: str) -> Any:
|
|
55
|
+
entry = self._data.get(key)
|
|
56
|
+
if entry is None:
|
|
57
|
+
return MISSING
|
|
58
|
+
expires_at, value = entry
|
|
59
|
+
if time.monotonic() >= expires_at:
|
|
60
|
+
del self._data[key]
|
|
61
|
+
return MISSING
|
|
62
|
+
self._data.move_to_end(key)
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
def set(self, key: str, value: Any, ttl: float) -> None:
|
|
66
|
+
self._data[key] = (time.monotonic() + ttl, value)
|
|
67
|
+
self._data.move_to_end(key)
|
|
68
|
+
while len(self._data) > self._max:
|
|
69
|
+
self._data.popitem(last=False)
|
|
70
|
+
|
|
71
|
+
def invalidate(self, prefix: str = "") -> int:
|
|
72
|
+
keys = [k for k in self._data if k.startswith(prefix)]
|
|
73
|
+
for k in keys:
|
|
74
|
+
del self._data[k]
|
|
75
|
+
return len(keys)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class DiskBackend:
|
|
79
|
+
"""Persistent cache: one JSON file per key, shared across processes.
|
|
80
|
+
|
|
81
|
+
Writes are atomic (write to a unique temp file, then rename). Corrupt or
|
|
82
|
+
unreadable entries are treated as misses, never as errors. The directory
|
|
83
|
+
holds a ``.gitignore`` so a cache inside a repository is never committed.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(self, directory: Path) -> None:
|
|
87
|
+
self._dir = directory
|
|
88
|
+
self._index_dir = directory / "entries"
|
|
89
|
+
self._index_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
90
|
+
gitignore = directory / ".gitignore"
|
|
91
|
+
if not gitignore.exists():
|
|
92
|
+
with contextlib.suppress(OSError):
|
|
93
|
+
gitignore.write_text("*\n")
|
|
94
|
+
|
|
95
|
+
def _path(self, key: str) -> Path:
|
|
96
|
+
digest = hashlib.sha256(key.encode()).hexdigest()[:40]
|
|
97
|
+
return self._index_dir / f"{digest}.json"
|
|
98
|
+
|
|
99
|
+
def get(self, key: str) -> Any:
|
|
100
|
+
path = self._path(key)
|
|
101
|
+
try:
|
|
102
|
+
doc = json.loads(path.read_text())
|
|
103
|
+
except (OSError, ValueError):
|
|
104
|
+
return MISSING
|
|
105
|
+
if doc.get("key") != key or time.time() >= doc.get("expires_at", 0):
|
|
106
|
+
return MISSING
|
|
107
|
+
return doc.get("value")
|
|
108
|
+
|
|
109
|
+
def set(self, key: str, value: Any, ttl: float) -> None:
|
|
110
|
+
path = self._path(key)
|
|
111
|
+
doc = {"key": key, "expires_at": time.time() + ttl, "value": value}
|
|
112
|
+
try:
|
|
113
|
+
# mkstemp creates the file with mode 0600.
|
|
114
|
+
fd, tmp_name = tempfile.mkstemp(dir=self._index_dir, suffix=".tmp")
|
|
115
|
+
except OSError:
|
|
116
|
+
return
|
|
117
|
+
tmp = Path(tmp_name)
|
|
118
|
+
try:
|
|
119
|
+
with os.fdopen(fd, "w") as f:
|
|
120
|
+
f.write(json.dumps(doc))
|
|
121
|
+
tmp.replace(path)
|
|
122
|
+
except OSError:
|
|
123
|
+
# Cache failures must never break the caller.
|
|
124
|
+
tmp.unlink(missing_ok=True)
|
|
125
|
+
|
|
126
|
+
def invalidate(self, prefix: str = "") -> int:
|
|
127
|
+
count = 0
|
|
128
|
+
for path in self._index_dir.glob("*.json"):
|
|
129
|
+
try:
|
|
130
|
+
doc = json.loads(path.read_text())
|
|
131
|
+
except (OSError, ValueError):
|
|
132
|
+
path.unlink(missing_ok=True)
|
|
133
|
+
continue
|
|
134
|
+
if doc.get("key", "").startswith(prefix):
|
|
135
|
+
path.unlink(missing_ok=True)
|
|
136
|
+
count += 1
|
|
137
|
+
return count
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class TieredBackend:
|
|
141
|
+
"""Memory tier in front of a persistent tier."""
|
|
142
|
+
|
|
143
|
+
def __init__(self, memory: MemoryBackend, disk: DiskBackend) -> None:
|
|
144
|
+
self._memory = memory
|
|
145
|
+
self._disk = disk
|
|
146
|
+
|
|
147
|
+
def get(self, key: str) -> Any:
|
|
148
|
+
value = self._memory.get(key)
|
|
149
|
+
if value is not MISSING:
|
|
150
|
+
return value
|
|
151
|
+
value = self._disk.get(key)
|
|
152
|
+
if value is not MISSING:
|
|
153
|
+
# Re-warm the memory tier; TTL is enforced by the disk doc,
|
|
154
|
+
# so a short memory TTL keeps the tiers close.
|
|
155
|
+
self._memory.set(key, value, ttl=60)
|
|
156
|
+
return value
|
|
157
|
+
|
|
158
|
+
def set(self, key: str, value: Any, ttl: float) -> None:
|
|
159
|
+
self._memory.set(key, value, ttl)
|
|
160
|
+
self._disk.set(key, value, ttl)
|
|
161
|
+
|
|
162
|
+
def invalidate(self, prefix: str = "") -> int:
|
|
163
|
+
self._memory.invalidate(prefix)
|
|
164
|
+
return self._disk.invalidate(prefix)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Cache policy: what to cache, where, and for how long."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
#: Default TTLs (seconds) per category. Categories are free-form strings;
|
|
9
|
+
#: these defaults reflect how often each kind of Benchling data changes.
|
|
10
|
+
DEFAULT_TTLS: dict[str, float] = {
|
|
11
|
+
"schema": 24 * 3600,
|
|
12
|
+
"registry": 12 * 3600,
|
|
13
|
+
"dropdown": 12 * 3600,
|
|
14
|
+
"folder": 12 * 3600,
|
|
15
|
+
"project": 12 * 3600,
|
|
16
|
+
"user": 12 * 3600,
|
|
17
|
+
"entity": 3600,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class CachePolicy:
|
|
23
|
+
"""Configuration for :class:`~involve_benchling.cache.BenchlingCache`."""
|
|
24
|
+
|
|
25
|
+
enabled: bool = True
|
|
26
|
+
#: Directory for the persistent tier. ``None`` disables the disk tier.
|
|
27
|
+
directory: Path | None = Path(".benchling-cache")
|
|
28
|
+
#: Maximum entries held in the in-memory LRU tier.
|
|
29
|
+
max_memory_entries: int = 1024
|
|
30
|
+
#: TTL (seconds) used when a category has no specific TTL.
|
|
31
|
+
default_ttl: float = 3600
|
|
32
|
+
#: Per-category TTL overrides, merged over :data:`DEFAULT_TTLS`.
|
|
33
|
+
ttls: dict[str, float] = field(default_factory=dict)
|
|
34
|
+
|
|
35
|
+
def ttl_for(self, category: str) -> float:
|
|
36
|
+
"""Resolve the TTL for a category."""
|
|
37
|
+
if category in self.ttls:
|
|
38
|
+
return self.ttls[category]
|
|
39
|
+
return DEFAULT_TTLS.get(category, self.default_ttl)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Thin construction helpers over the official ``benchling_v3_sdk``.
|
|
2
|
+
|
|
3
|
+
involve-benchling never hides the SDK: :func:`connect` returns the plain
|
|
4
|
+
``benchling_v3_sdk.Benchling`` client, and every helper in this library
|
|
5
|
+
accepts SDK clients and resources directly.
|
|
6
|
+
|
|
7
|
+
When ``config.read_only`` is set, the client is built on an ``httpx``
|
|
8
|
+
transport that blocks every request that could change tenant data (see
|
|
9
|
+
:mod:`~involve_benchling.core.readonly`).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
from ..errors import ConfigError
|
|
17
|
+
from .config import BenchlingConfig
|
|
18
|
+
from .readonly import read_only_async_http_client, read_only_http_client
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from benchling_v3_sdk import AsyncBenchling, Benchling
|
|
22
|
+
|
|
23
|
+
#: Request timeout (seconds) for read-only clients, matching the SDK default.
|
|
24
|
+
_DEFAULT_TIMEOUT = 60.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _client_kwargs(config: BenchlingConfig) -> dict[str, Any]:
|
|
28
|
+
kwargs: dict[str, Any] = {"base_url": config.base_url}
|
|
29
|
+
if config.early_access:
|
|
30
|
+
kwargs["early_access"] = "true"
|
|
31
|
+
if config.api_key:
|
|
32
|
+
kwargs["api_key"] = config.api_key
|
|
33
|
+
else:
|
|
34
|
+
kwargs["client_id"] = config.oauth_client_id
|
|
35
|
+
kwargs["client_secret"] = config.oauth_client_secret
|
|
36
|
+
return kwargs
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _read_only_http_kwargs(extra: dict[str, Any]) -> dict[str, Any]:
|
|
40
|
+
if "httpx_client" in extra:
|
|
41
|
+
raise ConfigError(
|
|
42
|
+
"A read-only config builds its own httpx client; do not pass httpx_client."
|
|
43
|
+
)
|
|
44
|
+
return {
|
|
45
|
+
"timeout": extra.get("timeout") or _DEFAULT_TIMEOUT,
|
|
46
|
+
"follow_redirects": extra.get("follow_redirects", True),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def connect(config: BenchlingConfig, **extra: Any) -> Benchling:
|
|
51
|
+
"""Create a synchronous ``benchling_v3_sdk.Benchling`` client.
|
|
52
|
+
|
|
53
|
+
Extra keyword arguments (``timeout``, ``max_retries``, ``httpx_client``,
|
|
54
|
+
...) are forwarded to the SDK constructor.
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
ConfigError: ``config.read_only`` is set and ``httpx_client`` was passed.
|
|
58
|
+
"""
|
|
59
|
+
from benchling_v3_sdk import Benchling
|
|
60
|
+
|
|
61
|
+
if config.read_only:
|
|
62
|
+
http = _read_only_http_kwargs(extra)
|
|
63
|
+
extra["httpx_client"] = read_only_http_client(config.base_url, **http)
|
|
64
|
+
return Benchling(**_client_kwargs(config), **extra)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def connect_async(config: BenchlingConfig, **extra: Any) -> AsyncBenchling:
|
|
68
|
+
"""Create an asynchronous ``benchling_v3_sdk.AsyncBenchling`` client.
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
ConfigError: ``config.read_only`` is set and ``httpx_client`` was passed.
|
|
72
|
+
"""
|
|
73
|
+
from benchling_v3_sdk import AsyncBenchling
|
|
74
|
+
|
|
75
|
+
if config.read_only:
|
|
76
|
+
http = _read_only_http_kwargs(extra)
|
|
77
|
+
extra["httpx_client"] = read_only_async_http_client(config.base_url, **http)
|
|
78
|
+
return AsyncBenchling(**_client_kwargs(config), **extra)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Immutable configuration for connecting to Benchling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field, replace
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ..cache.policy import CachePolicy
|
|
10
|
+
from ..errors import ConfigError
|
|
11
|
+
|
|
12
|
+
_ENV_PREFIX = "BENCHLING_"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _env_flag(name: str) -> bool:
|
|
16
|
+
return os.getenv(f"{_ENV_PREFIX}{name}", "").lower() in ("1", "true", "yes")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class BenchlingConfig:
|
|
21
|
+
"""Connection settings for a Benchling tenant.
|
|
22
|
+
|
|
23
|
+
Authenticate with either an API key or OAuth client credentials.
|
|
24
|
+
All fields are immutable; use :meth:`with_options` to derive variants.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
base_url: str
|
|
28
|
+
api_key: str | None = None
|
|
29
|
+
oauth_client_id: str | None = None
|
|
30
|
+
oauth_client_secret: str | None = None
|
|
31
|
+
early_access: bool = False
|
|
32
|
+
registry_name: str | None = None
|
|
33
|
+
#: Clients built from this config send only reads (see :mod:`~involve_benchling.core.readonly`).
|
|
34
|
+
read_only: bool = False
|
|
35
|
+
cache: CachePolicy = field(default_factory=CachePolicy)
|
|
36
|
+
|
|
37
|
+
def __post_init__(self) -> None:
|
|
38
|
+
if not self.base_url:
|
|
39
|
+
raise ConfigError("base_url is required (e.g. https://yourtenant.benchling.com)")
|
|
40
|
+
has_key = bool(self.api_key)
|
|
41
|
+
has_oauth = bool(self.oauth_client_id and self.oauth_client_secret)
|
|
42
|
+
if not has_key and not has_oauth:
|
|
43
|
+
raise ConfigError(
|
|
44
|
+
"Provide api_key or oauth_client_id + oauth_client_secret "
|
|
45
|
+
f"(or set {_ENV_PREFIX}API_KEY / {_ENV_PREFIX}OAUTH_CLIENT_ID "
|
|
46
|
+
f"and {_ENV_PREFIX}OAUTH_CLIENT_SECRET)."
|
|
47
|
+
)
|
|
48
|
+
if has_key and has_oauth:
|
|
49
|
+
raise ConfigError("Provide api_key or OAuth credentials, not both.")
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def auth_method(self) -> str:
|
|
53
|
+
"""``"api_key"`` or ``"oauth"``."""
|
|
54
|
+
return "api_key" if self.api_key else "oauth"
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_env(cls, **overrides: Any) -> BenchlingConfig:
|
|
58
|
+
"""Build a config from ``BENCHLING_*`` environment variables.
|
|
59
|
+
|
|
60
|
+
Recognised variables: ``BENCHLING_BASE_URL``, ``BENCHLING_API_KEY``,
|
|
61
|
+
``BENCHLING_OAUTH_CLIENT_ID``, ``BENCHLING_OAUTH_CLIENT_SECRET``,
|
|
62
|
+
``BENCHLING_EARLY_ACCESS``, ``BENCHLING_REGISTRY_NAME``,
|
|
63
|
+
``BENCHLING_READ_ONLY``.
|
|
64
|
+
Keyword arguments override environment values.
|
|
65
|
+
"""
|
|
66
|
+
env: dict[str, Any] = {
|
|
67
|
+
"base_url": os.getenv(f"{_ENV_PREFIX}BASE_URL", ""),
|
|
68
|
+
"api_key": os.getenv(f"{_ENV_PREFIX}API_KEY") or None,
|
|
69
|
+
"oauth_client_id": os.getenv(f"{_ENV_PREFIX}OAUTH_CLIENT_ID") or None,
|
|
70
|
+
"oauth_client_secret": os.getenv(f"{_ENV_PREFIX}OAUTH_CLIENT_SECRET") or None,
|
|
71
|
+
"early_access": _env_flag("EARLY_ACCESS"),
|
|
72
|
+
"registry_name": os.getenv(f"{_ENV_PREFIX}REGISTRY_NAME") or None,
|
|
73
|
+
"read_only": _env_flag("READ_ONLY"),
|
|
74
|
+
}
|
|
75
|
+
env.update(overrides)
|
|
76
|
+
return cls(**env)
|
|
77
|
+
|
|
78
|
+
def with_options(self, **changes: Any) -> BenchlingConfig:
|
|
79
|
+
"""Return a copy of this config with the given fields replaced."""
|
|
80
|
+
return replace(self, **changes)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Read-only enforcement at the HTTP transport.
|
|
2
|
+
|
|
3
|
+
A read-only client sends only ``GET``, ``HEAD`` and ``OPTIONS`` requests,
|
|
4
|
+
plus ``POST`` to a short list of endpoints that read without changing
|
|
5
|
+
tenant data (:data:`READ_POSTS`):
|
|
6
|
+
|
|
7
|
+
- ``/oauth/token`` issues a credential.
|
|
8
|
+
- ``/api/v3/search`` runs a search query.
|
|
9
|
+
|
|
10
|
+
Any other request raises :class:`~involve_benchling.errors.ReadOnlyViolationError`
|
|
11
|
+
before it leaves the process.
|
|
12
|
+
|
|
13
|
+
The check sits below every SDK method, so it covers raw-response calls and
|
|
14
|
+
methods added in future SDK releases without a list of write verbs to
|
|
15
|
+
maintain. Build read-only clients with ``BenchlingConfig(read_only=True)``
|
|
16
|
+
and :func:`~involve_benchling.connect`; use these helpers directly only when
|
|
17
|
+
constructing an ``httpx`` client yourself.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
|
|
26
|
+
from ..errors import ReadOnlyViolationError
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"READ_POSTS",
|
|
30
|
+
"SAFE_METHODS",
|
|
31
|
+
"AsyncReadOnlyTransport",
|
|
32
|
+
"ReadOnlyTransport",
|
|
33
|
+
"read_only_async_http_client",
|
|
34
|
+
"read_only_http_client",
|
|
35
|
+
"token_url",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
#: HTTP methods a read-only client may send to any URL.
|
|
39
|
+
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
|
40
|
+
|
|
41
|
+
#: Paths, relative to the tenant base URL, that accept a read-only ``POST``.
|
|
42
|
+
READ_POSTS: tuple[str, ...] = ("oauth/token", "api/v3/search")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _url(base_url: str, path: str) -> httpx.URL:
|
|
46
|
+
return httpx.URL(f"{base_url.rstrip('/')}/{path}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def token_url(base_url: str) -> httpx.URL:
|
|
50
|
+
"""The OAuth token endpoint the SDK posts to for ``base_url``."""
|
|
51
|
+
return _url(base_url, "oauth/token")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _check(request: httpx.Request, read_posts: frozenset[httpx.URL]) -> None:
|
|
55
|
+
if request.method in SAFE_METHODS:
|
|
56
|
+
return
|
|
57
|
+
url = request.url.copy_with(query=None, fragment=None)
|
|
58
|
+
if request.method == "POST" and url in read_posts:
|
|
59
|
+
return
|
|
60
|
+
raise ReadOnlyViolationError(request.method, str(url))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _read_posts(base_url: str) -> frozenset[httpx.URL]:
|
|
64
|
+
return frozenset(_url(base_url, path) for path in READ_POSTS)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ReadOnlyTransport(httpx.BaseTransport):
|
|
68
|
+
"""Sync transport that rejects every request a read-only client must not send."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, base_url: str, inner: httpx.BaseTransport | None = None) -> None:
|
|
71
|
+
self._read_posts = _read_posts(base_url)
|
|
72
|
+
self._inner = inner or httpx.HTTPTransport()
|
|
73
|
+
|
|
74
|
+
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
|
75
|
+
_check(request, self._read_posts)
|
|
76
|
+
return self._inner.handle_request(request)
|
|
77
|
+
|
|
78
|
+
def close(self) -> None:
|
|
79
|
+
self._inner.close()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class AsyncReadOnlyTransport(httpx.AsyncBaseTransport):
|
|
83
|
+
"""Async counterpart of :class:`ReadOnlyTransport`."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, base_url: str, inner: httpx.AsyncBaseTransport | None = None) -> None:
|
|
86
|
+
self._read_posts = _read_posts(base_url)
|
|
87
|
+
self._inner = inner or httpx.AsyncHTTPTransport()
|
|
88
|
+
|
|
89
|
+
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
|
90
|
+
_check(request, self._read_posts)
|
|
91
|
+
return await self._inner.handle_async_request(request)
|
|
92
|
+
|
|
93
|
+
async def aclose(self) -> None:
|
|
94
|
+
await self._inner.aclose()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def read_only_http_client(base_url: str, **kwargs: Any) -> httpx.Client:
|
|
98
|
+
"""An ``httpx.Client`` whose transport enforces read-only access."""
|
|
99
|
+
kwargs.setdefault("follow_redirects", True)
|
|
100
|
+
return httpx.Client(transport=ReadOnlyTransport(base_url), **kwargs)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def read_only_async_http_client(base_url: str, **kwargs: Any) -> httpx.AsyncClient:
|
|
104
|
+
"""An ``httpx.AsyncClient`` whose transport enforces read-only access."""
|
|
105
|
+
kwargs.setdefault("follow_redirects", True)
|
|
106
|
+
return httpx.AsyncClient(transport=AsyncReadOnlyTransport(base_url), **kwargs)
|