Skip to content

Commit fafa20a

Browse files
KakudouguzmudMegafredo
authored
feat(signature): ExpectationSignature, SignatureManager and SignatureAPIManager (#206)
Co-authored-by: guzmud <ferdinand.bellissime@filigran.io> Co-authored-by: Frédéric Basler <frederic.basler@filigran.io>
1 parent 1e083ed commit fafa20a

24 files changed

Lines changed: 2875 additions & 19 deletions

.circleci/config.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ jobs:
3636
command: pip3 install --user .
3737
- run:
3838
name: run tests
39-
command: python -m unittest
39+
command: python -m pytest
4040
linter:
4141
docker:
4242
- image: alpine/flake8

.github/workflows/coverage.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ jobs:
2727
uv venv && uv pip install -e '.[dev]' coverage
2828
2929
- name: Run tests with coverage
30-
run: uv run coverage run -m unittest
30+
run: uv run coverage run -m pytest
3131

3232
- name: Print coverage summary
3333
run: uv run coverage report -m --fail-under=70

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ $ python3 -m pip install -e .[dev,doc]
3636
$ pre-commit install
3737
# Create your feature/fix
3838
# Create tests for your changes
39-
$ python -m unittest
39+
$ python -m pytest
4040
# Push you feature/fix on Github
4141
$ git add [file(s)]
4242
$ git commit -m "[descriptive message]"
@@ -62,10 +62,10 @@ To learn about the methods available for executing queries and retrieving their
6262

6363
## Tests
6464

65-
The standard `unittest` library is used for running the tests.
65+
The `pytest` library is used for running the tests.
6666

6767
```bash
68-
$ python -m unittest
68+
$ python -m pytest
6969
```
7070

7171
## Code Coverage

pyoaev/apis/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from .organization import * # noqa: F401,F403
1414
from .payload import * # noqa: F401,F403
1515
from .security_platform import * # noqa: F401,F403
16+
from .signature import * # noqa: F401,F403
1617
from .tag import * # noqa: F401,F403
1718
from .team import * # noqa: F401,F403
1819
from .user import * # noqa: F401,F403

pyoaev/apis/signature.py

Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
"""Signature callback API — transport layer for compiled signature payloads."""
2+
3+
import json
4+
import logging
5+
import time
6+
from typing import Any
7+
8+
from pydantic import ValidationError
9+
10+
from pyoaev import exceptions as exc
11+
from pyoaev.base import RESTManager, RESTObject
12+
from pyoaev.exceptions import SignatureTransmissionError
13+
from pyoaev.signatures.models import SignatureCallbackPayload
14+
15+
16+
class Signature(RESTObject):
17+
"""REST object placeholder for signature callback responses."""
18+
19+
_id_attr = None
20+
21+
22+
class SignatureApiManager(RESTManager):
23+
"""Manage signature callback transport to the OpenAEV backend.
24+
25+
Handles payload validation, auto-chunking, and retry with exponential backoff.
26+
"""
27+
28+
_path = "/injects"
29+
_obj_cls = Signature
30+
31+
DEFAULT_MAX_PAYLOAD_SIZE = 1_048_576 # 1 MiB
32+
MAX_RETRIES = 3
33+
RETRY_DELAYS = (1, 2, 4)
34+
35+
_CHUNK_METADATA_RESERVE = len(
36+
',"chunk_index":99999,"total_chunks":99999,"phase":"execution_complete_extended"'
37+
)
38+
39+
def __init__(self, openaev: "Any", parent: "Any" = None) -> None:
40+
"""Initialize the signature API manager.
41+
42+
Args:
43+
openaev: The OpenAEV client instance.
44+
parent: Optional parent REST object for nested managers.
45+
"""
46+
super().__init__(openaev, parent)
47+
self._max_payload_size = self.DEFAULT_MAX_PAYLOAD_SIZE
48+
self._logger = logging.getLogger(__name__)
49+
50+
@property
51+
def max_payload_size(self) -> int:
52+
"""Maximum payload size in bytes before auto-chunking triggers."""
53+
return self._max_payload_size
54+
55+
@max_payload_size.setter
56+
def max_payload_size(self, value: int) -> None:
57+
self._max_payload_size = value
58+
59+
@property
60+
def logger(self) -> logging.Logger:
61+
"""Logger instance used for transmission diagnostics."""
62+
return self._logger
63+
64+
@logger.setter
65+
def logger(self, value: logging.Logger) -> None:
66+
self._logger = value
67+
68+
def send_signatures(
69+
self,
70+
inject_id: str,
71+
phase: str,
72+
signatures: dict[str, Any],
73+
) -> None:
74+
"""Send compiled signatures to the inject callback endpoint.
75+
76+
Auto-chunks payloads exceeding max_payload_size and retries on 5xx errors.
77+
78+
Args:
79+
inject_id: Inject UUID.
80+
phase: Execution phase (e.g. 'execution_complete').
81+
signatures: Full signatures dict (canonical or flat, grouped on the fly).
82+
83+
Raises:
84+
SignatureTransmissionError: Validation failed, 4xx hit, or retries exhausted.
85+
"""
86+
self._logger.debug("send_signatures inject_id=%s phase=%s", inject_id, phase)
87+
signatures = self._normalize_signature_payload(signatures)
88+
payload = self._build_callback_payload(signatures, phase=phase)
89+
90+
serialized = json.dumps(payload, separators=(",", ":")).encode()
91+
92+
if len(serialized) <= self._max_payload_size:
93+
self._send_with_retry(inject_id, payload)
94+
else:
95+
self._send_chunked(inject_id, payload["expectation_signature"], phase=phase)
96+
97+
def _build_callback_payload(
98+
self,
99+
signatures: dict[str, Any],
100+
*,
101+
phase: str | None = None,
102+
chunk_index: int | None = None,
103+
total_chunks: int | None = None,
104+
) -> dict[str, Any]:
105+
"""Validate and wrap signatures in the strict callback envelope.
106+
107+
Args:
108+
signatures: The inner signatures body, already normalised.
109+
phase: Execution phase string (e.g. 'execution_complete').
110+
chunk_index: 0-based index when chunking, None for single POSTs.
111+
total_chunks: Chunk count when chunking, None for single POSTs.
112+
113+
Returns:
114+
The validated dict ready for wire transmission.
115+
116+
Raises:
117+
SignatureTransmissionError: Envelope failed Pydantic validation.
118+
"""
119+
try:
120+
envelope = SignatureCallbackPayload.model_validate(
121+
{
122+
"expectation_signature": signatures,
123+
"phase": phase,
124+
"chunk_index": chunk_index,
125+
"total_chunks": total_chunks,
126+
}
127+
)
128+
except ValidationError as ve:
129+
raise SignatureTransmissionError(
130+
error_message=f"Invalid signatures payload: {ve}",
131+
) from ve
132+
return envelope.model_dump(mode="json", exclude_none=True)
133+
134+
def _normalize_signature_payload(
135+
self, signatures: dict[str, Any]
136+
) -> dict[str, Any]:
137+
"""Regroup signature_values by expectation_type within each target.
138+
139+
Accepts flat or pre-grouped input and returns canonical grouped form.
140+
141+
Args:
142+
signatures: Raw signatures dict with any mix of flat and grouped entries.
143+
144+
Returns:
145+
New dict where every signature_values list is in canonical grouped form.
146+
"""
147+
targets = signatures.get("targets")
148+
if not targets:
149+
return signatures
150+
151+
normalized_targets: list[dict[str, Any]] = []
152+
for target in targets:
153+
sig_values = target.get("signature_values")
154+
if not sig_values:
155+
normalized_targets.append(target)
156+
continue
157+
158+
grouped: dict[str, list[dict[str, Any]]] = {}
159+
order: list[str] = []
160+
161+
for entry in sig_values:
162+
etype = entry.get("expectation_type")
163+
if etype not in grouped:
164+
grouped[etype] = []
165+
order.append(etype)
166+
167+
if "values" in entry and isinstance(entry["values"], list):
168+
grouped[etype].extend(entry["values"])
169+
else:
170+
grouped[etype].append(
171+
{k: v for k, v in entry.items() if k != "expectation_type"}
172+
)
173+
174+
normalized_target = dict(target)
175+
normalized_target["signature_values"] = [
176+
{"expectation_type": etype, "values": grouped[etype]} for etype in order
177+
]
178+
normalized_targets.append(normalized_target)
179+
180+
normalized = dict(signatures)
181+
normalized["targets"] = normalized_targets
182+
return normalized
183+
184+
def _send_chunked(
185+
self, inject_id: str, signatures: dict[str, Any], phase: str | None = None
186+
) -> None:
187+
"""Split targets across sequential POSTs, each tagged with chunk metadata.
188+
189+
Args:
190+
inject_id: Inject UUID for the callback path.
191+
signatures: Normalised inner signatures body to partition.
192+
phase: Execution phase forwarded to each chunk envelope.
193+
194+
Raises:
195+
SignatureTransmissionError: A single target alone exceeds max_payload_size.
196+
"""
197+
targets = signatures.get("targets", [])
198+
if not targets:
199+
payload = self._build_callback_payload(signatures, phase=phase)
200+
size = len(json.dumps(payload, separators=(",", ":")).encode())
201+
if size > self._max_payload_size:
202+
self._logger.warning(
203+
"Payload of %d bytes exceeds max_payload_size %d but has no "
204+
"'targets' key to chunk on; sending unchunked",
205+
size,
206+
self._max_payload_size,
207+
)
208+
self._send_with_retry(inject_id, payload)
209+
return
210+
211+
budget = max(self._max_payload_size - self._CHUNK_METADATA_RESERVE, 0)
212+
chunks: list[list[Any]] = []
213+
current_chunk: list[Any] = []
214+
215+
for target in targets:
216+
candidate = current_chunk + [target]
217+
size = len(
218+
json.dumps(
219+
{"expectation_signature": {"targets": candidate}},
220+
separators=(",", ":"),
221+
).encode()
222+
)
223+
224+
if size <= budget:
225+
current_chunk.append(target)
226+
continue
227+
228+
if not current_chunk:
229+
raise SignatureTransmissionError(
230+
error_message=(
231+
f"Single target payload of {size} bytes exceeds "
232+
f"max_payload_size {self._max_payload_size}; cannot chunk further"
233+
),
234+
)
235+
236+
chunks.append(current_chunk)
237+
current_chunk = [target]
238+
solo_size = len(
239+
json.dumps(
240+
{"expectation_signature": {"targets": [target]}},
241+
separators=(",", ":"),
242+
).encode()
243+
)
244+
if solo_size > budget:
245+
raise SignatureTransmissionError(
246+
error_message=(
247+
f"Single target payload of {solo_size} bytes exceeds "
248+
f"max_payload_size {self._max_payload_size}; cannot chunk further"
249+
),
250+
)
251+
252+
if current_chunk:
253+
chunks.append(current_chunk)
254+
255+
total_chunks = len(chunks)
256+
for idx, chunk_targets in enumerate(chunks):
257+
chunk_payload = self._build_callback_payload(
258+
{"targets": chunk_targets},
259+
phase=phase,
260+
chunk_index=idx,
261+
total_chunks=total_chunks,
262+
)
263+
self._send_with_retry(inject_id, chunk_payload)
264+
265+
@exc.on_http_error(exc.OpenAEVUpdateError)
266+
def callback(
267+
self, inject_id: str, data: dict[str, Any], **kwargs: Any
268+
) -> dict[str, Any]:
269+
"""Post signature payload to the inject callback endpoint.
270+
271+
Args:
272+
inject_id: Inject UUID.
273+
data: Validated payload dict to send.
274+
**kwargs: Additional arguments forwarded to http_post.
275+
276+
Returns:
277+
The parsed response from the backend.
278+
"""
279+
path = f"{self.path}/{inject_id}/callback"
280+
result = self.openaev.http_post(path, post_data=data, **kwargs)
281+
return result
282+
283+
def _send_with_retry(
284+
self, inject_id: str, payload: dict[str, Any]
285+
) -> dict[str, Any]:
286+
"""Retry callback() with exponential backoff on 5xx, immediate raise on 4xx.
287+
288+
Args:
289+
inject_id: Inject UUID for the callback path.
290+
payload: Validated payload dict to send.
291+
292+
Returns:
293+
The successful response from callback().
294+
295+
Raises:
296+
SignatureTransmissionError: 4xx error or all retries exhausted.
297+
"""
298+
from pyoaev.exceptions import OpenAEVError
299+
300+
last_error: Exception | None = None
301+
302+
for attempt in range(self.MAX_RETRIES + 1):
303+
try:
304+
return self.callback(inject_id, payload)
305+
except OpenAEVError as ex:
306+
status = ex.response_code
307+
if status and 400 <= status < 500:
308+
body_str = ""
309+
if ex.response_body:
310+
body_str = ex.response_body.decode(errors="replace")
311+
self._logger.error(
312+
"Client error %d sending signatures: %s",
313+
status,
314+
body_str or ex.error_message,
315+
)
316+
raise SignatureTransmissionError(
317+
error_message=f"Client error {status}: {ex.error_message}",
318+
response_code=status,
319+
response_body=ex.response_body,
320+
) from ex
321+
322+
last_error = ex
323+
if attempt < self.MAX_RETRIES:
324+
delay = self.RETRY_DELAYS[attempt]
325+
self._logger.warning(
326+
"Retry %d/%d after %ds (HTTP %s): %s",
327+
attempt + 1,
328+
self.MAX_RETRIES,
329+
delay,
330+
status,
331+
ex.error_message,
332+
)
333+
time.sleep(delay)
334+
335+
raise SignatureTransmissionError(
336+
error_message=f"All {self.MAX_RETRIES} retries exhausted",
337+
response_code=getattr(last_error, "response_code", None),
338+
response_body=getattr(last_error, "response_body", None),
339+
)

pyoaev/client.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ def __init__(
7575
self.payload = apis.PayloadManager(self)
7676
self.security_platform = apis.SecurityPlatformManager(self)
7777
self.inject_expectation_trace = apis.InjectExpectationTraceManager(self)
78+
self.signature = apis.SignatureApiManager(self)
7879
self.tag = apis.TagManager(self)
7980

8081
@staticmethod

0 commit comments

Comments
 (0)