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.
@@ -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,6 @@
1
+ """Core: configuration and SDK client construction."""
2
+
3
+ from .client import connect, connect_async
4
+ from .config import BenchlingConfig
5
+
6
+ __all__ = ["BenchlingConfig", "connect", "connect_async"]
@@ -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)