sediment-api 0.1.0__tar.gz → 0.3.0__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 (71) hide show
  1. {sediment_api-0.1.0 → sediment_api-0.3.0}/PKG-INFO +5 -5
  2. {sediment_api-0.1.0 → sediment_api-0.3.0}/pyproject.toml +5 -5
  3. {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/__init__.py +1 -1
  4. {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/config.py +84 -3
  5. {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/deps.py +84 -5
  6. {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/main.py +26 -0
  7. {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/routers/query.py +441 -71
  8. {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/routers/v1.py +8 -2
  9. {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/services/operational_reports.py +8 -2
  10. {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/worker.py +139 -8
  11. {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/workers.py +50 -3
  12. sediment_api-0.3.0/tests/test_context_discovery_authority.py +184 -0
  13. sediment_api-0.3.0/tests/test_context_discovery_query.py +454 -0
  14. sediment_api-0.3.0/tests/test_context_discovery_workers.py +93 -0
  15. sediment_api-0.3.0/tests/test_context_evidence_query.py +582 -0
  16. sediment_api-0.3.0/tests/test_context_query.py +310 -0
  17. {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_credential_authorities.py +3 -0
  18. sediment_api-0.3.0/tests/test_evidence_query.py +537 -0
  19. sediment_api-0.3.0/tests/test_evidence_worker_imports.py +81 -0
  20. sediment_api-0.3.0/tests/test_evidence_workers.py +330 -0
  21. {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_query.py +468 -0
  22. {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_report_attachment_scope.py +188 -7
  23. {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_report_routes.py +2 -1
  24. sediment_api-0.3.0/tests/test_retrieval_authority.py +138 -0
  25. {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_v1.py +1 -1
  26. {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_worker_routes.py +61 -38
  27. {sediment_api-0.1.0 → sediment_api-0.3.0}/.gitignore +0 -0
  28. {sediment_api-0.1.0 → sediment_api-0.3.0}/LICENSE +0 -0
  29. {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/database.py +0 -0
  30. {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/mirror_gc.py +0 -0
  31. {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/reports/__init__.py +0 -0
  32. {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/reports/abandonment_report.py +0 -0
  33. {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/reports/attribution_share_report.py +0 -0
  34. {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/reports/dataset_diagnostics.py +0 -0
  35. {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/reports/label_confidence_inspection.py +0 -0
  36. {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/reports/lifecycle_report.py +0 -0
  37. {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/reports/merge_retention_report.py +0 -0
  38. {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/reports/model_report.py +0 -0
  39. {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/reports/precision_report.py +0 -0
  40. {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/reports/recovery_yield_report.py +0 -0
  41. {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/routers/__init__.py +0 -0
  42. {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/routers/ci_vendor.py +0 -0
  43. {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/routers/forge.py +0 -0
  44. {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/routers/gateway.py +0 -0
  45. {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/routers/otlp.py +0 -0
  46. {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/routers/reports.py +0 -0
  47. {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/services/__init__.py +0 -0
  48. {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/conftest.py +0 -0
  49. {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_attribution_share_report.py +0 -0
  50. {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_baseline_gap_repro.py +0 -0
  51. {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_bug_repro_offset.py +0 -0
  52. {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_capture_record_isolation.py +0 -0
  53. {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_ci_vendor.py +0 -0
  54. {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_config.py +0 -0
  55. {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_database_lifecycle.py +0 -0
  56. {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_database_outage.py +0 -0
  57. {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_dataset_diagnostics_report.py +0 -0
  58. {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_e2e_smoke.py +0 -0
  59. {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_gateway_capture_receipt.py +0 -0
  60. {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_gateway_identity.py +0 -0
  61. {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_lifecycle_report_cli.py +0 -0
  62. {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_merge_retention_cli.py +0 -0
  63. {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_one_shot_database.py +0 -0
  64. {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_operational_reports.py +0 -0
  65. {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_operational_reports_service.py +0 -0
  66. {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_postgres_gateway_tracer.py +0 -0
  67. {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_push_mirror.py +0 -0
  68. {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_repository_identity_ingest.py +0 -0
  69. {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_repository_identity_model_service.py +0 -0
  70. {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_repository_identity_queries.py +0 -0
  71. {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_worker_processes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sediment-api
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
4
  Summary: Sediment ingest API: facts in through three doors
5
5
  Project-URL: Source, https://github.com/sediment-ai/sediment
6
6
  Project-URL: Documentation, https://github.com/sediment-ai/sediment/tree/main/docs
@@ -13,8 +13,8 @@ Requires-Dist: anyio>=4.15.1
13
13
  Requires-Dist: fastapi>=0.141.1
14
14
  Requires-Dist: httpx>=0.27
15
15
  Requires-Dist: pydantic-settings>=2.15.0
16
- Requires-Dist: sediment-capture==0.1.0
17
- Requires-Dist: sediment-core==0.1.0
18
- Requires-Dist: sediment-derive==0.1.0
19
- Requires-Dist: sediment-export==0.1.0
16
+ Requires-Dist: sediment-capture==0.3.0
17
+ Requires-Dist: sediment-core==0.3.0
18
+ Requires-Dist: sediment-derive==0.3.0
19
+ Requires-Dist: sediment-export==0.3.0
20
20
  Requires-Dist: uvicorn>=0.53.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sediment-api"
3
- version = "0.1.0"
3
+ version = "0.3.0"
4
4
  description = "Sediment ingest API: facts in through three doors"
5
5
  requires-python = ">=3.12"
6
6
  license = "AGPL-3.0-or-later"
@@ -12,10 +12,10 @@ dependencies = [
12
12
  "httpx>=0.27",
13
13
  "uvicorn>=0.53.0",
14
14
  "pydantic-settings>=2.15.0",
15
- "sediment-core==0.1.0",
16
- "sediment-capture==0.1.0",
17
- "sediment-derive==0.1.0",
18
- "sediment-export==0.1.0",
15
+ "sediment-core==0.3.0",
16
+ "sediment-capture==0.3.0",
17
+ "sediment-derive==0.3.0",
18
+ "sediment-export==0.3.0",
19
19
  ]
20
20
 
21
21
  [project.urls]
@@ -3,4 +3,4 @@
3
3
 
4
4
  # Single source for the API version string — FastAPI's ``app.version``
5
5
  # (reported by /health) and GET /v1/me both read it, so the two cannot drift.
6
- __version__ = "0.1.0"
6
+ __version__ = "0.3.0"
@@ -5,9 +5,10 @@ import json
5
5
  import re
6
6
  from typing import Annotated
7
7
 
8
- from pydantic import Field, SecretStr, field_validator
8
+ from pydantic import Field, SecretStr, TypeAdapter, field_validator, model_validator
9
9
  from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
10
- from sediment_core import ForgeHost, normalize_org_id
10
+ from sediment_core import ForgeHost, NonEmptyId, normalize_org_id
11
+ from sediment_core.evidence import CONTEXT_DISCOVERY_SESSION_LIMIT
11
12
 
12
13
  # Token values that mean "operator never configured a real secret".
13
14
  _INSECURE_DEFAULTS = {
@@ -27,6 +28,7 @@ _CLIENT_ID = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$")
27
28
  # unthrottled online guesser (see docs/adr/0018) meaningfully more than a
28
29
  # short word does.
29
30
  _MIN_SECRET_LENGTH = 24
31
+ _RETRIEVAL_GRANT_BYTES_LIMIT = 16 * 1024
30
32
 
31
33
 
32
34
  class Settings(BaseSettings):
@@ -63,6 +65,9 @@ class Settings(BaseSettings):
63
65
  # Auth
64
66
  api_bearer_token: str = Field(default="", repr=False)
65
67
  operator_token: SecretStr = SecretStr("")
68
+ retrieval_token: SecretStr | None = None
69
+ retrieval_session_id: NonEmptyId | None = None
70
+ retrieval_session_ids: Annotated[list[NonEmptyId] | None, NoDecode] = None
66
71
  ingest_tokens: Annotated[dict[str, SecretStr], NoDecode] = Field(
67
72
  default_factory=dict
68
73
  )
@@ -113,6 +118,48 @@ class Settings(BaseSettings):
113
118
  def _strip_operator_token(cls, value: SecretStr) -> SecretStr:
114
119
  return SecretStr(value.get_secret_value().strip())
115
120
 
121
+ @field_validator("retrieval_token")
122
+ @classmethod
123
+ def _strip_retrieval_token(cls, value: SecretStr | None) -> SecretStr | None:
124
+ return None if value is None else SecretStr(value.get_secret_value().strip())
125
+
126
+ @field_validator("retrieval_session_ids", mode="before")
127
+ @classmethod
128
+ def _parse_retrieval_sessions(cls, value):
129
+ if value is None:
130
+ return None
131
+ try:
132
+ encoded = (
133
+ value
134
+ if isinstance(value, str)
135
+ else json.dumps(value, ensure_ascii=False)
136
+ )
137
+ if len(encoded.encode("utf-8")) > _RETRIEVAL_GRANT_BYTES_LIMIT:
138
+ raise ValueError
139
+ if isinstance(value, str):
140
+ value = json.loads(value)
141
+ if (
142
+ not isinstance(value, list)
143
+ or not 1 <= len(value) <= CONTEXT_DISCOVERY_SESSION_LIMIT
144
+ or any(not isinstance(item, str) for item in value)
145
+ ):
146
+ raise ValueError
147
+ normalized = TypeAdapter(list[NonEmptyId]).validate_python(value)
148
+ if len(set(normalized)) != len(normalized):
149
+ raise ValueError
150
+ except (ValueError, TypeError, RecursionError):
151
+ raise ValueError(
152
+ "retrieval_session_ids must be a JSON array of 1–32 unique valid IDs within 16 KiB"
153
+ ) from None
154
+ return normalized
155
+
156
+ @property
157
+ def context_session_ids(self) -> tuple[NonEmptyId, ...]:
158
+ """The deployment grant, with no first-member singleton default."""
159
+ if self.retrieval_session_id is not None:
160
+ return (self.retrieval_session_id,)
161
+ return tuple(sorted(self.retrieval_session_ids or ()))
162
+
116
163
  @field_validator("ingest_tokens", mode="before")
117
164
  @classmethod
118
165
  def _parse_ingest_tokens(cls, value):
@@ -140,7 +187,7 @@ class Settings(BaseSettings):
140
187
  if (
141
188
  not isinstance(client_id, str)
142
189
  or not _CLIENT_ID.fullmatch(client_id)
143
- or client_id in {"operator", "legacy"}
190
+ or client_id in {"operator", "legacy", "retrieval"}
144
191
  ):
145
192
  raise ValueError(
146
193
  "ingest_tokens contains an invalid or reserved client identifier"
@@ -152,6 +199,40 @@ class Settings(BaseSettings):
152
199
  normalized[client_id] = SecretStr(secret.strip())
153
200
  return normalized
154
201
 
202
+ @model_validator(mode="after")
203
+ def _validate_retrieval(self) -> "Settings":
204
+ # Agent read authority never inherits development-mode exemptions.
205
+ source_count = sum(
206
+ source is not None
207
+ for source in (self.retrieval_session_id, self.retrieval_session_ids)
208
+ )
209
+ if source_count != int(self.retrieval_token is not None):
210
+ raise ValueError(
211
+ "retrieval_token requires exactly one of retrieval_session_id or retrieval_session_ids"
212
+ )
213
+ if self.retrieval_token is None:
214
+ return self
215
+ token = self.retrieval_token.get_secret_value()
216
+ if (
217
+ token.lower() in _INSECURE_DEFAULTS
218
+ or len(token) < _MIN_SECRET_LENGTH
219
+ or any(not 33 <= ord(char) <= 126 for char in token)
220
+ ):
221
+ raise ValueError(
222
+ "retrieval_token must be a strong printable ASCII secret of at least 24 characters"
223
+ )
224
+ other_secrets = {
225
+ self.operator_token.get_secret_value(),
226
+ self.api_bearer_token,
227
+ self.github_webhook_secret,
228
+ *(secret.get_secret_value() for secret in self.ingest_tokens.values()),
229
+ }
230
+ if token in other_secrets:
231
+ raise ValueError(
232
+ "retrieval_token must differ from all other configured secrets"
233
+ )
234
+ return self
235
+
155
236
  def validate_production_security(self) -> list[str]:
156
237
  """Return human-readable security problems for a production boot.
157
238
 
@@ -19,7 +19,7 @@ from typing import Any
19
19
 
20
20
  from fastapi import Depends, Header, HTTPException, Request
21
21
  from sediment_capture import verify_signature
22
- from sediment_core import FactStore
22
+ from sediment_core import EVIDENCE_REQUEST_BYTES_LIMIT, FactStore, NonEmptyId
23
23
 
24
24
  from .config import settings
25
25
 
@@ -31,6 +31,7 @@ from .config import settings
31
31
  # body before its auth dependency runs, so every door's read is pre-auth.
32
32
  # ponytail: fixed constant; make it a setting only if a non-GitHub forge needs it
33
33
  MAX_BODY_BYTES = 25 * 1024 * 1024
34
+ CONTEXT_REQUEST_BYTES_LIMIT = 16 * 1024
34
35
 
35
36
 
36
37
  class BodySizeLimitMiddleware:
@@ -57,6 +58,24 @@ class BodySizeLimitMiddleware:
57
58
  await self.app(scope, receive, send)
58
59
  return
59
60
 
61
+ limit = MAX_BODY_BYTES
62
+ path = scope["path"]
63
+ root_path = scope.get("root_path", "")
64
+ if root_path and path.startswith(root_path + "/"):
65
+ path = path[len(root_path) :]
66
+ if scope["method"] == "POST" and path in {
67
+ "/query/evidence/read",
68
+ "/query/context/evidence/read",
69
+ }:
70
+ # FastAPI parses model envelopes before running dependencies. Keep
71
+ # this operation's smaller bound ahead of that allocation too.
72
+ limit = min(limit, EVIDENCE_REQUEST_BYTES_LIMIT)
73
+ elif scope["method"] == "POST" and path in {
74
+ "/query/context",
75
+ "/query/context/discover",
76
+ "/query/context/selected",
77
+ }:
78
+ limit = min(limit, CONTEXT_REQUEST_BYTES_LIMIT)
60
79
  received = 0
61
80
 
62
81
  async def capped_receive() -> Any:
@@ -66,25 +85,47 @@ class BodySizeLimitMiddleware:
66
85
  received += len(message.get("body", b""))
67
86
  # Module-global lookup on purpose: tests lower the cap by
68
87
  # patching MAX_BODY_BYTES after the app is constructed.
69
- if received > MAX_BODY_BYTES:
88
+ if received > limit:
70
89
  raise HTTPException(
71
90
  status_code=413, detail="request body too large"
72
91
  )
73
92
  return message
74
93
 
75
- await self.app(scope, capped_receive, send)
94
+ async def context_send(message: Any) -> None:
95
+ if message["type"] == "http.response.start":
96
+ message["headers"] = [
97
+ (key, value)
98
+ for key, value in message.get("headers", [])
99
+ if key.lower() != b"cache-control"
100
+ ] + [(b"cache-control", b"no-store")]
101
+ await send(message)
102
+
103
+ await self.app(
104
+ scope,
105
+ capped_receive,
106
+ context_send
107
+ if path
108
+ in {
109
+ "/query/context/discover",
110
+ "/query/context/selected",
111
+ "/query/context/evidence",
112
+ "/query/context/evidence/manifest",
113
+ "/query/context/evidence/read",
114
+ }
115
+ else send,
116
+ )
76
117
 
77
118
 
78
119
  @dataclass(frozen=True)
79
120
  class CredentialIdentity:
80
121
  """Configured authority only; never a tenant or captured developer identity."""
81
122
 
82
- authority: Literal["ingest", "operator"]
123
+ authority: Literal["ingest", "operator", "retrieval"]
83
124
  client_id: str
84
125
 
85
126
 
86
127
  def verify_token(authorization: str | None = Header(None)) -> CredentialIdentity:
87
- """Authenticate either fixed authority without logging credential material."""
128
+ """Authenticate configured authorities without logging credential material."""
88
129
  scheme, _, credential = (authorization or "").partition(" ")
89
130
  credential = credential.strip()
90
131
  if scheme.lower() != "bearer" or not credential:
@@ -96,6 +137,12 @@ def verify_token(authorization: str | None = Header(None)) -> CredentialIdentity
96
137
  settings.operator_token.get_secret_value(),
97
138
  CredentialIdentity("operator", "operator"),
98
139
  ),
140
+ (
141
+ settings.retrieval_token.get_secret_value()
142
+ if settings.retrieval_token is not None
143
+ else "",
144
+ CredentialIdentity("retrieval", "retrieval"),
145
+ ),
99
146
  (settings.api_bearer_token, CredentialIdentity("ingest", "legacy")),
100
147
  *(
101
148
  (token.get_secret_value(), CredentialIdentity("ingest", client_id))
@@ -114,6 +161,8 @@ def verify_ingest_token(
114
161
  identity: CredentialIdentity = Depends(verify_token),
115
162
  ) -> CredentialIdentity:
116
163
  """Capture accepts ingest clients and explicit operator demonstrations."""
164
+ if identity.authority not in {"ingest", "operator"}:
165
+ raise HTTPException(status_code=403, detail="Ingest authority required")
117
166
  return identity
118
167
 
119
168
 
@@ -126,6 +175,36 @@ def verify_operator_token(
126
175
  return identity
127
176
 
128
177
 
178
+ def verify_retrieval_token(
179
+ identity: CredentialIdentity = Depends(verify_token),
180
+ ) -> CredentialIdentity:
181
+ """Read the deployment's fixed source without broadening operator reads."""
182
+ if identity.authority not in {"retrieval", "operator"}:
183
+ raise HTTPException(status_code=403, detail="Retrieval authority required")
184
+ if settings.retrieval_session_id is None:
185
+ raise HTTPException(status_code=404, detail="Context retrieval is disabled")
186
+ return identity
187
+
188
+
189
+ def verify_context_grant_token(
190
+ identity: CredentialIdentity = Depends(verify_token),
191
+ ) -> CredentialIdentity:
192
+ """Discovery and selection share the configured Session grant."""
193
+ if identity.authority not in {"retrieval", "operator"}:
194
+ raise HTTPException(status_code=403, detail="Retrieval authority required")
195
+ if not settings.context_session_ids:
196
+ raise HTTPException(status_code=404, detail="Context retrieval is disabled")
197
+ return identity
198
+
199
+
200
+ def require_context_session(session_id: NonEmptyId) -> None:
201
+ """Refuse before any storage lookup, independent of Session existence."""
202
+ if session_id not in settings.context_session_ids:
203
+ raise HTTPException(
204
+ status_code=403, detail="Session is outside the context grant"
205
+ )
206
+
207
+
129
208
  def get_store(request: Request) -> FactStore:
130
209
  """Borrow the lifespan-owned PostgreSQL fact store for one request."""
131
210
  return request.app.state.fact_store
@@ -185,6 +185,32 @@ async def validation_error_without_input(
185
185
  400/422, never 500. type/loc/msg are what a caller needs to fix the
186
186
  request; the offending input is theirs already.
187
187
  """
188
+ endpoint = getattr(request.scope.get("route"), "endpoint", None)
189
+ if endpoint in {
190
+ query.query_context_discover,
191
+ query.query_context_selected,
192
+ query.query_context_evidence_read,
193
+ } and any(error.get("type") == "json_invalid" for error in exc.errors()):
194
+ return JSONResponse(status_code=400, content={"detail": "Malformed JSON body"})
195
+ if endpoint in {
196
+ query.query_context_evidence_inventory,
197
+ query.query_context_evidence_manifest,
198
+ query.query_context_evidence_read,
199
+ }:
200
+ # Exact references reuse the operator envelope. Its error locations can
201
+ # contain arbitrary extra keys; keep the scoped boundary content-free.
202
+ return JSONResponse(
203
+ status_code=422,
204
+ content={
205
+ "detail": [
206
+ {
207
+ "type": "value_error",
208
+ "loc": [],
209
+ "msg": "Invalid evidence request",
210
+ }
211
+ ]
212
+ },
213
+ )
188
214
  detail = [
189
215
  {"type": e.get("type", ""), "loc": e.get("loc", ()), "msg": e.get("msg", "")}
190
216
  for e in exc.errors()