apical 0.2.0__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.
apical/__init__.py ADDED
@@ -0,0 +1,88 @@
1
+ """Apical Labs — Zero-Retention Continual Memory & Embedding Router.
2
+
3
+ Client SDK providing:
4
+ - ApicalClient: Main client connection pool
5
+ - Classifier: Continual classification with instant GDPR Art. 17 unlearning
6
+ - MemoryRouter: Real-time dynamic embedding routing and reinforcement
7
+ - OrthogonalPrivacyProjector: Client-side mathematical input de-identification
8
+ - verify_zero_retention_receipt: Offline Ed25519 receipt verification
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from apical.classifier import (
14
+ Classifier,
15
+ LearnResult,
16
+ PredictionResult,
17
+ UnlearnResult,
18
+ )
19
+ from apical.client import ApicalClient, ArborNamespace, BridgeResult
20
+
21
+ # Backward-compatible convenience alias
22
+ Client = ApicalClient
23
+ from apical.exceptions import (
24
+ APIError,
25
+ ApicalError,
26
+ AuthenticationError,
27
+ FixedByteInvariantError,
28
+ ModelNotFoundError,
29
+ ProductNotEntitledError,
30
+ RateLimitError,
31
+ SparseProbeError,
32
+ SweepDetectedError,
33
+ VerificationError,
34
+ )
35
+ from apical.orthogonal import OrthogonalPrivacyProjector
36
+ from apical.plexus import (
37
+ PlexusClassifier,
38
+ PlexusLearnResult,
39
+ PlexusNetwork,
40
+ PlexusPredictionResult,
41
+ PlexusRouteResult,
42
+ PlexusRouter,
43
+ PlexusUnlearnResult,
44
+ )
45
+ from apical.router import (
46
+ MemoryRouter,
47
+ ReinforceResult,
48
+ RouteItem,
49
+ RouteResult,
50
+ )
51
+ from apical.verifier import verify_zero_retention_receipt
52
+
53
+
54
+ __version__ = "0.2.0"
55
+
56
+ __all__ = [
57
+ "APIError",
58
+ "ApicalClient",
59
+ "ApicalError",
60
+ "ArborNamespace",
61
+ "AuthenticationError",
62
+ "BridgeResult",
63
+ "Classifier",
64
+ "Client",
65
+ "FixedByteInvariantError",
66
+ "LearnResult",
67
+ "MemoryRouter",
68
+ "ModelNotFoundError",
69
+ "OrthogonalPrivacyProjector",
70
+ "PlexusClassifier",
71
+ "PlexusLearnResult",
72
+ "PlexusNetwork",
73
+ "PlexusPredictionResult",
74
+ "PlexusRouteResult",
75
+ "PlexusRouter",
76
+ "PlexusUnlearnResult",
77
+ "PredictionResult",
78
+ "ProductNotEntitledError",
79
+ "RateLimitError",
80
+ "ReinforceResult",
81
+ "RouteItem",
82
+ "RouteResult",
83
+ "SparseProbeError",
84
+ "SweepDetectedError",
85
+ "UnlearnResult",
86
+ "VerificationError",
87
+ "verify_zero_retention_receipt",
88
+ ]
apical/classifier.py ADDED
@@ -0,0 +1,205 @@
1
+ """Classifier Surface for the Apical Labs Platform."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, NamedTuple, Optional, Sequence, Union
6
+ import numpy as np
7
+
8
+ from apical.orthogonal import OrthogonalPrivacyProjector
9
+ from apical.verifier import verify_zero_retention_receipt
10
+
11
+
12
+ class PredictionResult(NamedTuple):
13
+ """Result of a classifier inference pass."""
14
+ prediction: int
15
+ confidence: float
16
+ receipt: Dict[str, Any]
17
+ crystallization_pct: Optional[float] = None
18
+ mean_plasticity: Optional[float] = None
19
+
20
+ @property
21
+ def signature(self) -> str:
22
+ """Return the Ed25519 signature from the cryptographic attestation receipt."""
23
+ return str(self.receipt.get("ed25519_signature", ""))
24
+
25
+
26
+ class LearnResult(NamedTuple):
27
+ """Result of a single continual update."""
28
+ status: str
29
+ target_class: int
30
+ winner_class: int
31
+ confidence: float
32
+ update_count: int
33
+ persisted_bytes_delta: int
34
+ receipt: Dict[str, Any]
35
+
36
+ @property
37
+ def signature(self) -> str:
38
+ """Return the Ed25519 signature from the cryptographic attestation receipt."""
39
+ return str(self.receipt.get("ed25519_signature", ""))
40
+
41
+
42
+ class UnlearnResult(NamedTuple):
43
+ """Result of an instant GDPR Article 17 class erasure."""
44
+ status: str
45
+ erased_class_id: int
46
+ persisted_bytes_delta: int
47
+ receipt: Dict[str, Any]
48
+
49
+ @property
50
+ def signature(self) -> str:
51
+ """Return the Ed25519 signature from the cryptographic attestation receipt."""
52
+ return str(self.receipt.get("ed25519_signature", ""))
53
+
54
+
55
+ class Classifier:
56
+ """Client interface for continuous classification models."""
57
+
58
+ def __init__(
59
+ self,
60
+ client: Any,
61
+ model_id: str,
62
+ in_features: Optional[int] = None,
63
+ num_classes: Optional[int] = None,
64
+ privacy_secret: Optional[str] = None,
65
+ ) -> None:
66
+ self.client = client
67
+ self.model_id = model_id
68
+ self.in_features = in_features
69
+ self.num_classes = num_classes
70
+ self.privacy_secret = privacy_secret
71
+
72
+ self._projector: Optional[OrthogonalPrivacyProjector] = None
73
+ if self.privacy_secret and self.in_features:
74
+ self._projector = OrthogonalPrivacyProjector(self.privacy_secret, self.in_features)
75
+
76
+ def _prepare_vector(self, vector: Union[Sequence[float], np.ndarray]) -> list[float]:
77
+ """Optionally apply orthogonal privacy projection, returning a JSON-serializable list."""
78
+ arr = np.asarray(vector, dtype=np.float32).ravel()
79
+
80
+ if self._projector is not None:
81
+ arr = self._projector.project(arr)
82
+ elif self.privacy_secret and not self._projector:
83
+ # Lazy initialize projector once vector dimension is known
84
+ self.in_features = arr.size
85
+ self._projector = OrthogonalPrivacyProjector(self.privacy_secret, self.in_features)
86
+ arr = self._projector.project(arr)
87
+
88
+ return arr.tolist()
89
+
90
+ def create(self, in_features: int, num_classes: int) -> Dict[str, Any]:
91
+ """Create and initialize a new fixed-byte model container on Apical Cloud."""
92
+ self.in_features = in_features
93
+ self.num_classes = num_classes
94
+
95
+ if self.privacy_secret and not self._projector:
96
+ self._projector = OrthogonalPrivacyProjector(self.privacy_secret, self.in_features)
97
+
98
+ payload = {
99
+ "model_id": self.model_id,
100
+ "in_features": in_features,
101
+ "out_features": num_classes,
102
+ }
103
+ resp = self.client.request("POST", f"/api/v1/models", payload)
104
+ data = resp.get("data", resp)
105
+
106
+ if "receipt" in data:
107
+ verify_zero_retention_receipt(data["receipt"])
108
+
109
+ return data
110
+
111
+ def predict(
112
+ self,
113
+ vector: Union[Sequence[float], np.ndarray],
114
+ tier: int = 0,
115
+ verify_receipt: bool = True,
116
+ ) -> PredictionResult:
117
+ """Execute directional classification, returning class prediction and signed receipt."""
118
+ prep_vec = self._prepare_vector(vector)
119
+ payload = {
120
+ "model_id": self.model_id,
121
+ "vector": prep_vec,
122
+ "tier": tier,
123
+ }
124
+ resp = self.client.request("POST", f"/api/v1/predict", payload)
125
+ data = resp.get("data", resp)
126
+
127
+ receipt = data.get("receipt", {})
128
+ if verify_receipt and receipt:
129
+ verify_zero_retention_receipt(receipt)
130
+
131
+ return PredictionResult(
132
+ prediction=int(data["prediction"]),
133
+ confidence=float(data["confidence"]),
134
+ receipt=receipt,
135
+ crystallization_pct=data.get("crystallization_pct"),
136
+ mean_plasticity=data.get("mean_plasticity"),
137
+ )
138
+
139
+ def learn(
140
+ self,
141
+ vector: Union[Sequence[float], np.ndarray],
142
+ target_class: int,
143
+ tier: int = 0,
144
+ verify_receipt: bool = True,
145
+ ) -> LearnResult:
146
+ """Stream a single continual update, updating model state under zero retention."""
147
+ prep_vec = self._prepare_vector(vector)
148
+ payload = {
149
+ "model_id": self.model_id,
150
+ "vector": prep_vec,
151
+ "target_class": target_class,
152
+ "tier": tier,
153
+ }
154
+ resp = self.client.request("POST", f"/api/v1/learn", payload)
155
+ data = resp.get("data", resp)
156
+
157
+ receipt = data.get("receipt", {})
158
+ if verify_receipt and receipt:
159
+ verify_zero_retention_receipt(receipt)
160
+
161
+ return LearnResult(
162
+ status=str(data.get("status", "updated")),
163
+ target_class=int(data["target_class"]),
164
+ winner_class=int(data["winner_class"]),
165
+ confidence=float(data["confidence"]),
166
+ update_count=int(data.get("update_count", 0)),
167
+ persisted_bytes_delta=int(data.get("persisted_bytes_delta", 0)),
168
+ receipt=receipt,
169
+ )
170
+
171
+ def unlearn(
172
+ self,
173
+ class_id: int,
174
+ verify_receipt: bool = True,
175
+ ) -> UnlearnResult:
176
+ """Execute instant GDPR Article 17 Machine Unlearning for a specific class coordinate."""
177
+ payload = {
178
+ "model_id": self.model_id,
179
+ "class_id": class_id,
180
+ }
181
+ resp = self.client.request("POST", f"/api/v1/unlearn", payload)
182
+ data = resp.get("data", resp)
183
+
184
+ receipt = data.get("receipt", {})
185
+ if verify_receipt and receipt:
186
+ verify_zero_retention_receipt(receipt)
187
+
188
+ return UnlearnResult(
189
+ status=str(data.get("status", "erased")),
190
+ erased_class_id=int(data.get("erased_class_id", class_id)),
191
+ persisted_bytes_delta=int(data.get("persisted_bytes_delta", 0)),
192
+ receipt=receipt,
193
+ )
194
+
195
+ def erase_class(
196
+ self,
197
+ target_class: Optional[int] = None,
198
+ class_id: Optional[int] = None,
199
+ verify_receipt: bool = True,
200
+ ) -> UnlearnResult:
201
+ """Execute instant GDPR Article 17 Machine Unlearning for a specific class coordinate (alias for unlearn)."""
202
+ target = target_class if target_class is not None else class_id
203
+ if target is None:
204
+ raise ValueError("Either target_class or class_id must be provided")
205
+ return self.unlearn(class_id=target, verify_receipt=verify_receipt)
apical/client.py ADDED
@@ -0,0 +1,371 @@
1
+ """Base HTTP Client for the Apical Labs Platform."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any, Dict, NamedTuple, Optional, Sequence, Union
7
+ import httpx
8
+
9
+ from apical.exceptions import (
10
+ APIError,
11
+ ApicalError,
12
+ AuthenticationError,
13
+ ModelNotFoundError,
14
+ ProductNotEntitledError,
15
+ RateLimitError,
16
+ SparseProbeError,
17
+ SweepDetectedError,
18
+ )
19
+
20
+
21
+ DEFAULT_BASE_URL = "https://api.apical-labs.com"
22
+ DEFAULT_TIMEOUT_SECONDS = 10.0
23
+ DEFAULT_MAX_RETRIES = 3
24
+ SDK_VERSION = "0.2.0"
25
+
26
+
27
+ class BridgeResult(NamedTuple):
28
+ """Result of an arbor.Bridge cross-backbone migration."""
29
+ status: str
30
+ model_id: str
31
+ alignment_score: float
32
+ persisted_bytes_delta: int
33
+ update_count: int
34
+ receipt: Dict[str, Any]
35
+ latency_ms: float = 0.0
36
+
37
+ @property
38
+ def signature(self) -> str:
39
+ """Return the Ed25519 signature from the cryptographic attestation receipt."""
40
+ return str(self.receipt.get("ed25519_signature", ""))
41
+
42
+
43
+ class ArborNamespace:
44
+ """Namespace for Arbor (SCPP v1) components matching the landing page and developer ergonomics."""
45
+
46
+ def __init__(self, client: "ApicalClient") -> None:
47
+ self._client = client
48
+
49
+ def create_model(
50
+ self,
51
+ model_id: str,
52
+ in_features: Optional[int] = None,
53
+ out_features: Optional[int] = None,
54
+ input_dim: Optional[int] = None,
55
+ num_classes: Optional[int] = None,
56
+ privacy_secret: Optional[str] = None,
57
+ ) -> Any:
58
+ return self._client.create_model(
59
+ model_id=model_id,
60
+ in_features=in_features,
61
+ out_features=out_features,
62
+ input_dim=input_dim,
63
+ num_classes=num_classes,
64
+ privacy_secret=privacy_secret,
65
+ )
66
+
67
+ def classifier(
68
+ self,
69
+ model_id: str,
70
+ in_features: Optional[int] = None,
71
+ num_classes: Optional[int] = None,
72
+ privacy_secret: Optional[str] = None,
73
+ ) -> Any:
74
+ return self._client.classifier(
75
+ model_id=model_id,
76
+ in_features=in_features,
77
+ num_classes=num_classes,
78
+ privacy_secret=privacy_secret,
79
+ )
80
+
81
+ def memory_router(
82
+ self,
83
+ router_id: str,
84
+ embedding_dim: Optional[int] = None,
85
+ num_rules: Optional[int] = None,
86
+ privacy_secret: Optional[str] = None,
87
+ ) -> Any:
88
+ return self._client.memory_router(
89
+ router_id=router_id,
90
+ embedding_dim=embedding_dim,
91
+ num_rules=num_rules,
92
+ privacy_secret=privacy_secret,
93
+ )
94
+
95
+ def bridge(
96
+ self,
97
+ model_id: str,
98
+ source_anchors: Optional[Any] = None,
99
+ target_anchors: Optional[Any] = None,
100
+ public_anchors: Optional[Any] = None,
101
+ tier: int = 0,
102
+ verify_receipt: bool = True,
103
+ ) -> BridgeResult:
104
+ return self._client.bridge(
105
+ model_id=model_id,
106
+ source_anchors=source_anchors,
107
+ target_anchors=target_anchors,
108
+ public_anchors=public_anchors,
109
+ tier=tier,
110
+ verify_receipt=verify_receipt,
111
+ )
112
+
113
+
114
+ class ApicalClient:
115
+ """Primary client for interacting with the Apical Labs API Gateway.
116
+
117
+ Manages HTTP/2 connection pooling, API key authentication, and retry policies.
118
+ """
119
+
120
+ def __init__(
121
+ self,
122
+ api_key: str,
123
+ privacy_secret: Optional[str] = None,
124
+ base_url: str = DEFAULT_BASE_URL,
125
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
126
+ max_retries: int = DEFAULT_MAX_RETRIES,
127
+ transport: Optional[httpx.BaseTransport] = None,
128
+ ) -> None:
129
+ if not api_key:
130
+ raise ValueError("api_key cannot be empty")
131
+
132
+ self.api_key = api_key
133
+ self.privacy_secret = privacy_secret
134
+ self.base_url = base_url.rstrip("/")
135
+ self.timeout = timeout
136
+ self.max_retries = max_retries
137
+
138
+ headers = {
139
+ "Authorization": f"Bearer {self.api_key}",
140
+ "X-Client-Version": SDK_VERSION,
141
+ "Accept": "application/json",
142
+ "Content-Type": "application/json",
143
+ }
144
+
145
+ self._client = httpx.Client(
146
+ base_url=self.base_url,
147
+ headers=headers,
148
+ timeout=self.timeout,
149
+ transport=transport,
150
+ )
151
+
152
+ @property
153
+ def arbor(self) -> ArborNamespace:
154
+ """Access the Arbor (SCPP v1) namespace (classifier, memory_router, bridge)."""
155
+ return ArborNamespace(self)
156
+
157
+ def create_model(
158
+ self,
159
+ model_id: str,
160
+ in_features: Optional[int] = None,
161
+ out_features: Optional[int] = None,
162
+ input_dim: Optional[int] = None,
163
+ num_classes: Optional[int] = None,
164
+ privacy_secret: Optional[str] = None,
165
+ ) -> Any:
166
+ """Create and initialize a fixed-byte model container on the Enclave, returning a Classifier."""
167
+ in_dim = in_features or input_dim
168
+ out_dim = out_features or num_classes
169
+ if in_dim is None or out_dim is None:
170
+ raise ValueError("Both input dimensions and output classes must be specified to create a model container.")
171
+
172
+ payload = {
173
+ "model_id": model_id,
174
+ "in_features": in_dim,
175
+ "out_features": out_dim,
176
+ }
177
+ self.request("POST", "/api/v1/models", payload)
178
+
179
+ return self.classifier(
180
+ model_id=model_id,
181
+ in_features=in_dim,
182
+ num_classes=out_dim,
183
+ privacy_secret=privacy_secret,
184
+ )
185
+
186
+ def classifier(
187
+ self,
188
+ model_id: str,
189
+ in_features: Optional[int] = None,
190
+ num_classes: Optional[int] = None,
191
+ privacy_secret: Optional[str] = None,
192
+ ) -> "Classifier":
193
+ """Return a Classifier wrapper for classification and continuous learning."""
194
+ from apical.classifier import Classifier
195
+
196
+ secret = privacy_secret or self.privacy_secret
197
+ return Classifier(
198
+ client=self,
199
+ model_id=model_id,
200
+ in_features=in_features,
201
+ num_classes=num_classes,
202
+ privacy_secret=secret,
203
+ )
204
+
205
+ def memory_router(
206
+ self,
207
+ router_id: str,
208
+ embedding_dim: Optional[int] = None,
209
+ num_rules: Optional[int] = None,
210
+ privacy_secret: Optional[str] = None,
211
+ ) -> "MemoryRouter":
212
+ """Return a MemoryRouter wrapper for embedding routing and reinforcement."""
213
+ from apical.router import MemoryRouter
214
+
215
+ secret = privacy_secret or self.privacy_secret
216
+ return MemoryRouter(
217
+ client=self,
218
+ router_id=router_id,
219
+ embedding_dim=embedding_dim,
220
+ num_rules=num_rules,
221
+ privacy_secret=secret,
222
+ )
223
+
224
+ def plexus(
225
+ self,
226
+ model_id: str,
227
+ in_features: Optional[int] = None,
228
+ hidden_dim: int = 2048,
229
+ out_features: Optional[int] = None,
230
+ assembly_k: int = 64,
231
+ privacy_secret: Optional[str] = None,
232
+ ) -> "PlexusNetwork":
233
+ """Return a PlexusNetwork wrapper for SCPP v2 multi-layer continual learning and routing."""
234
+ from apical.plexus import PlexusNetwork
235
+
236
+ secret = privacy_secret or self.privacy_secret
237
+ return PlexusNetwork(
238
+ client=self,
239
+ model_id=model_id,
240
+ in_features=in_features,
241
+ hidden_dim=hidden_dim,
242
+ out_features=out_features,
243
+ assembly_k=assembly_k,
244
+ privacy_secret=secret,
245
+ )
246
+
247
+ def bridge(
248
+ self,
249
+ model_id: str,
250
+ source_anchors: Optional[Any] = None,
251
+ target_anchors: Optional[Any] = None,
252
+ public_anchors: Optional[Any] = None,
253
+ tier: int = 0,
254
+ verify_receipt: bool = True,
255
+ ) -> BridgeResult:
256
+ """Execute cross-backbone model migration via Orthogonal Procrustes alignment."""
257
+ from apical.verifier import verify_zero_retention_receipt
258
+
259
+ # Unpack public_anchors if provided as dict or tuple
260
+ if public_anchors is not None:
261
+ if isinstance(public_anchors, dict):
262
+ source_anchors = public_anchors.get("source") or public_anchors.get("source_anchors")
263
+ target_anchors = public_anchors.get("target") or public_anchors.get("target_anchors")
264
+ elif isinstance(public_anchors, (list, tuple)) and len(public_anchors) == 2:
265
+ source_anchors, target_anchors = public_anchors[0], public_anchors[1]
266
+
267
+ if source_anchors is None or target_anchors is None:
268
+ raise ValueError("Both source_anchors and target_anchors must be provided (or public_anchors)")
269
+
270
+ def _to_list_matrix(m: Any) -> list[list[float]]:
271
+ if hasattr(m, "tolist"):
272
+ return m.tolist()
273
+ if isinstance(m, (list, tuple)):
274
+ return [list(row) if hasattr(row, "__iter__") else [float(row)] for row in m]
275
+ return list(m)
276
+
277
+ src_list = _to_list_matrix(source_anchors)
278
+ tgt_list = _to_list_matrix(target_anchors)
279
+
280
+ payload = {
281
+ "model_id": model_id,
282
+ "source_anchors": src_list,
283
+ "target_anchors": tgt_list,
284
+ "tier": tier,
285
+ }
286
+
287
+ resp = self.request("POST", "/api/v1/bridge", payload)
288
+ data = resp.get("data", resp)
289
+
290
+ receipt = data.get("receipt", {})
291
+ if verify_receipt and receipt:
292
+ verify_zero_retention_receipt(receipt)
293
+
294
+ return BridgeResult(
295
+ status=str(data.get("status", "bridged")),
296
+ model_id=str(data.get("model_id", model_id)),
297
+ alignment_score=float(data.get("alignment_score", 1.0)),
298
+ persisted_bytes_delta=int(data.get("persisted_bytes_delta", 0)),
299
+ update_count=int(data.get("update_count", 0)),
300
+ receipt=receipt,
301
+ latency_ms=float(data.get("latency_ms", 0.0)),
302
+ )
303
+
304
+ def request(
305
+ self,
306
+ method: str,
307
+ path: str,
308
+ json_body: Optional[Dict[str, Any]] = None,
309
+ ) -> Dict[str, Any]:
310
+ """Execute an HTTP request with automatic retry on transient network failures."""
311
+ clean_path = "/" + path.lstrip("/")
312
+ retries = 0
313
+
314
+ while True:
315
+ try:
316
+ response = self._client.request(
317
+ method=method,
318
+ url=clean_path,
319
+ json=json_body,
320
+ )
321
+
322
+ if response.status_code == 200 or response.status_code == 201:
323
+ return response.json()
324
+
325
+ self._handle_error_response(response)
326
+
327
+ except (httpx.ConnectError, httpx.TimeoutException) as e:
328
+ retries += 1
329
+ if retries >= self.max_retries:
330
+ raise APIError(
331
+ f"Network connection failed after {retries} retries: {e}",
332
+ status_code=None,
333
+ error_code="ERR_NETWORK_FAILURE",
334
+ ) from e
335
+ time.sleep(0.1 * (2 ** retries))
336
+
337
+ def _handle_error_response(self, response: httpx.Response) -> None:
338
+ """Parse structured API gateway errors and raise appropriate typed exceptions."""
339
+ try:
340
+ body = response.json()
341
+ except Exception:
342
+ body = {"error": response.text}
343
+
344
+ code = body.get("code", "")
345
+ message = body.get("error", response.text)
346
+ status = response.status_code
347
+
348
+ if status == 401:
349
+ raise AuthenticationError(message, status_code=status, error_code=code, details=body)
350
+ elif code == "ERR_PRODUCT_NOT_ENTITLED":
351
+ raise ProductNotEntitledError(message, status_code=status, error_code=code, details=body)
352
+ elif status == 404:
353
+ raise ModelNotFoundError(message, status_code=status, error_code=code, details=body)
354
+ elif status == 429:
355
+ raise RateLimitError(message, status_code=status, error_code=code, details=body)
356
+ elif code == "ERR_ORACLE_SPARSE_PROBE":
357
+ raise SparseProbeError(message, status_code=status, error_code=code, details=body)
358
+ elif code == "ERR_ORACLE_SWEEP_DETECTED":
359
+ raise SweepDetectedError(message, status_code=status, error_code=code, details=body)
360
+ else:
361
+ raise APIError(message, status_code=status, error_code=code, details=body)
362
+
363
+ def close(self) -> None:
364
+ """Close the underlying HTTP connection pool."""
365
+ self._client.close()
366
+
367
+ def __enter__(self) -> "ApicalClient":
368
+ return self
369
+
370
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
371
+ self.close()