filingstudio 0.4.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.
@@ -0,0 +1,7 @@
1
+ node_modules/
2
+ dist/
3
+ __pycache__/
4
+ *.pyc
5
+ .pytest_cache/
6
+ packages/proxy-python/dist/
7
+ *.egg-info/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Filing Studio
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,132 @@
1
+ Metadata-Version: 2.5
2
+ Name: filingstudio
3
+ Version: 0.4.0
4
+ Summary: Client for the Filing Studio API: search SEC filings, resolve traces, verify claims. One call per door, typed results, your key never in a URL.
5
+ Project-URL: Homepage, https://filingstudio.com
6
+ Project-URL: Documentation, https://filingstudio.com/docs#sdk
7
+ Author-email: Filing Studio <support@filingstudio.com>
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: 10-K,10-Q,api-client,citations,edgar,filings,finance,provenance,sec
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Financial and Insurance Industry
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Office/Business :: Financial
24
+ Classifier: Topic :: Software Development :: Libraries
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.9
27
+ Requires-Dist: httpx>=0.24
28
+ Requires-Dist: pydantic>=2.0
29
+ Provides-Extra: test
30
+ Requires-Dist: anyio>=3; extra == 'test'
31
+ Requires-Dist: pytest>=7; extra == 'test'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # filingstudio
35
+
36
+ Python client for the [Filing Studio](https://filingstudio.com) API. Search
37
+ SEC filings exactly as printed, resolve any number back to the line that
38
+ printed it, and verify claims deterministically. One call per door, typed
39
+ results, and your key never in a URL.
40
+
41
+ ```bash
42
+ pip install filingstudio
43
+ ```
44
+
45
+ ```python
46
+ import os
47
+ from filingstudio import FilingStudio
48
+
49
+ fs = FilingStudio(api_key=os.environ["FILING_STUDIO_API_KEY"])
50
+
51
+ hits = fs.search("NVDA", "purchase commitments", type="prose")
52
+ for p in hits.passages:
53
+ print(p.text, p.trace_id)
54
+
55
+ v = fs.verify("NVDA", metric="Revenue", value=130497, period="FY2025")
56
+ print(v.verdict) # supported | unsupported | ambiguous | unavailable
57
+ print(v.receipts[0].printed_text) # "130,497"
58
+ print(v.receipts[0].links.highlight) # opens the filing with that cell marked
59
+
60
+ t = fs.trace(v.receipts[0].trace_id) # the printed line plus its neighbours
61
+ for row in t.context:
62
+ print(row.label, row.printed_text, "<- source" if row.is_source else "")
63
+ ```
64
+
65
+ Async is the same API:
66
+
67
+ ```python
68
+ from filingstudio import AsyncFilingStudio
69
+
70
+ async with AsyncFilingStudio(api_key=key) as fs:
71
+ cov = await fs.coverage("NVDA")
72
+ ```
73
+
74
+ ## The six doors
75
+
76
+ | method | what it answers |
77
+ |---|---|
78
+ | `search(ticker, q, type=, period=, forms=, limit=, offset=)` | printed rows, tables, and prose matching plain words |
79
+ | `verify(ticker, metric=, value=, period=, claim=)` | is this claim what the filing prints? |
80
+ | `trace(trace_id, include_context=True)` | the exact printed line behind a traceId, with neighbours |
81
+ | `filings(ticker, form=, year=, limit=)` | a company's indexed filings |
82
+ | `coverage(ticker)` | is anything indexed, and how fresh |
83
+ | `table(ticker, accession, table_id, format="records")` | one printed table, as filed |
84
+
85
+ Pass `value` to `verify` as the raw figure you hold (130497 or 130497000000
86
+ alike). The API tries every printed scale a filer could use. Do not pre-scale.
87
+
88
+ ## Honest answers
89
+
90
+ Every result carries `index_state`. `coverage` is one of:
91
+
92
+ - `indexed`: a populated index answered, with results
93
+ - `empty`: a populated index answered and had nothing. The only real negative.
94
+ - `incomplete`: the index could not fully answer. Says nothing about the filing.
95
+ - `unavailable`: the service or your quota could not answer. Same.
96
+
97
+ `RateLimitError` (HTTP 429) carries `index_state.note`, a sentence safe to
98
+ show a user. Other non-2xx answers raise `FilingStudioError` with `status`,
99
+ `code`, and the API's `message`. 5xx and network failures are retried with
100
+ backoff; 4xx are not. No error ever contains your key.
101
+
102
+ ## Research with receipts (experimental)
103
+
104
+ ```python
105
+ from filingstudio.research import answer
106
+
107
+ def llm(system: str, user: str, json_mode: bool) -> str:
108
+ ... # any chat model; return the assistant text
109
+
110
+ res = answer("How is Data Center revenue trending?", "NVDA", llm=llm, client=fs,
111
+ on_step=lambda label, detail: print(label, detail))
112
+ res.answer, res.sources, res.hard_stop
113
+ ```
114
+
115
+ Plan, search, assess, write, with hard stops. Every `[n]` in the answer is a
116
+ search hit you can trace; citations to nothing are stripped; with no evidence
117
+ the answer says so. `llm=None` runs one search and returns the evidence only.
118
+
119
+ ## Options
120
+
121
+ `FilingStudio(api_key, base_url=None, timeout=30.0, max_retries=2, transport=None)`
122
+
123
+ `transport` accepts an `httpx` transport, for tests (`httpx.MockTransport`).
124
+
125
+ ## Develop
126
+
127
+ ```bash
128
+ pip install -e .[test]
129
+ pytest
130
+ ```
131
+
132
+ MIT
@@ -0,0 +1,99 @@
1
+ # filingstudio
2
+
3
+ Python client for the [Filing Studio](https://filingstudio.com) API. Search
4
+ SEC filings exactly as printed, resolve any number back to the line that
5
+ printed it, and verify claims deterministically. One call per door, typed
6
+ results, and your key never in a URL.
7
+
8
+ ```bash
9
+ pip install filingstudio
10
+ ```
11
+
12
+ ```python
13
+ import os
14
+ from filingstudio import FilingStudio
15
+
16
+ fs = FilingStudio(api_key=os.environ["FILING_STUDIO_API_KEY"])
17
+
18
+ hits = fs.search("NVDA", "purchase commitments", type="prose")
19
+ for p in hits.passages:
20
+ print(p.text, p.trace_id)
21
+
22
+ v = fs.verify("NVDA", metric="Revenue", value=130497, period="FY2025")
23
+ print(v.verdict) # supported | unsupported | ambiguous | unavailable
24
+ print(v.receipts[0].printed_text) # "130,497"
25
+ print(v.receipts[0].links.highlight) # opens the filing with that cell marked
26
+
27
+ t = fs.trace(v.receipts[0].trace_id) # the printed line plus its neighbours
28
+ for row in t.context:
29
+ print(row.label, row.printed_text, "<- source" if row.is_source else "")
30
+ ```
31
+
32
+ Async is the same API:
33
+
34
+ ```python
35
+ from filingstudio import AsyncFilingStudio
36
+
37
+ async with AsyncFilingStudio(api_key=key) as fs:
38
+ cov = await fs.coverage("NVDA")
39
+ ```
40
+
41
+ ## The six doors
42
+
43
+ | method | what it answers |
44
+ |---|---|
45
+ | `search(ticker, q, type=, period=, forms=, limit=, offset=)` | printed rows, tables, and prose matching plain words |
46
+ | `verify(ticker, metric=, value=, period=, claim=)` | is this claim what the filing prints? |
47
+ | `trace(trace_id, include_context=True)` | the exact printed line behind a traceId, with neighbours |
48
+ | `filings(ticker, form=, year=, limit=)` | a company's indexed filings |
49
+ | `coverage(ticker)` | is anything indexed, and how fresh |
50
+ | `table(ticker, accession, table_id, format="records")` | one printed table, as filed |
51
+
52
+ Pass `value` to `verify` as the raw figure you hold (130497 or 130497000000
53
+ alike). The API tries every printed scale a filer could use. Do not pre-scale.
54
+
55
+ ## Honest answers
56
+
57
+ Every result carries `index_state`. `coverage` is one of:
58
+
59
+ - `indexed`: a populated index answered, with results
60
+ - `empty`: a populated index answered and had nothing. The only real negative.
61
+ - `incomplete`: the index could not fully answer. Says nothing about the filing.
62
+ - `unavailable`: the service or your quota could not answer. Same.
63
+
64
+ `RateLimitError` (HTTP 429) carries `index_state.note`, a sentence safe to
65
+ show a user. Other non-2xx answers raise `FilingStudioError` with `status`,
66
+ `code`, and the API's `message`. 5xx and network failures are retried with
67
+ backoff; 4xx are not. No error ever contains your key.
68
+
69
+ ## Research with receipts (experimental)
70
+
71
+ ```python
72
+ from filingstudio.research import answer
73
+
74
+ def llm(system: str, user: str, json_mode: bool) -> str:
75
+ ... # any chat model; return the assistant text
76
+
77
+ res = answer("How is Data Center revenue trending?", "NVDA", llm=llm, client=fs,
78
+ on_step=lambda label, detail: print(label, detail))
79
+ res.answer, res.sources, res.hard_stop
80
+ ```
81
+
82
+ Plan, search, assess, write, with hard stops. Every `[n]` in the answer is a
83
+ search hit you can trace; citations to nothing are stripped; with no evidence
84
+ the answer says so. `llm=None` runs one search and returns the evidence only.
85
+
86
+ ## Options
87
+
88
+ `FilingStudio(api_key, base_url=None, timeout=30.0, max_retries=2, transport=None)`
89
+
90
+ `transport` accepts an `httpx` transport, for tests (`httpx.MockTransport`).
91
+
92
+ ## Develop
93
+
94
+ ```bash
95
+ pip install -e .[test]
96
+ pytest
97
+ ```
98
+
99
+ MIT
@@ -0,0 +1,48 @@
1
+ """Filing Studio client: search SEC filings, resolve traces, verify claims."""
2
+
3
+ from . import research
4
+ from .client import AsyncFilingStudio, FilingStudio
5
+ from .errors import FilingStudioError, NotConfigured, RateLimitError
6
+ from .models import (
7
+ ContextRow,
8
+ CoverageResult,
9
+ EvidenceLinks,
10
+ EvidenceSource,
11
+ FilingRef,
12
+ FilingsResult,
13
+ IndexState,
14
+ Pagination,
15
+ SearchCell,
16
+ SearchPassage,
17
+ SearchResult,
18
+ TableResult,
19
+ TraceDetail,
20
+ VerifyReceipt,
21
+ VerifyResult,
22
+ )
23
+
24
+ __version__ = "0.4.0"
25
+ __all__ = [
26
+ "AsyncFilingStudio",
27
+ "ContextRow",
28
+ "CoverageResult",
29
+ "EvidenceLinks",
30
+ "EvidenceSource",
31
+ "FilingRef",
32
+ "FilingStudio",
33
+ "FilingStudioError",
34
+ "FilingsResult",
35
+ "IndexState",
36
+ "NotConfigured",
37
+ "Pagination",
38
+ "RateLimitError",
39
+ "SearchCell",
40
+ "SearchPassage",
41
+ "SearchResult",
42
+ "TableResult",
43
+ "TraceDetail",
44
+ "VerifyReceipt",
45
+ "VerifyResult",
46
+ "__version__",
47
+ "research",
48
+ ]
@@ -0,0 +1,333 @@
1
+ """
2
+ The Filing Studio client, sync and async. One method per /v1 door, the same
3
+ names as the Node `@filingstudio/client` and the browser `ProvenanceClient`.
4
+
5
+ from filingstudio import FilingStudio
6
+ fs = FilingStudio(api_key=os.environ["FILING_STUDIO_API_KEY"])
7
+ hits = fs.search("NVDA", "purchase commitments", type="prose")
8
+ v = fs.verify("NVDA", metric="Revenue", value=130497, period="FY2025")
9
+
10
+ The key travels only in the X-API-Key header, never in a URL, never in an
11
+ error message. 5xx and transport failures are retried with backoff; 4xx are
12
+ not. Pass `value` to verify as the raw figure you hold: the API tries every
13
+ printed scale a filer could use.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import random
19
+ import time
20
+ from typing import Any, Dict, Optional
21
+ from urllib.parse import quote
22
+
23
+ import anyio
24
+ import httpx
25
+
26
+ from .errors import FilingStudioError, NotConfigured, RateLimitError
27
+ from .models import (
28
+ CoverageResult,
29
+ FilingsResult,
30
+ IndexState,
31
+ SearchResult,
32
+ TableResult,
33
+ TraceDetail,
34
+ VerifyResult,
35
+ parse_hit,
36
+ )
37
+
38
+ DEFAULT_BASE = "https://api.filingstudio.com"
39
+
40
+
41
+ def _clean(params: Dict[str, Any]) -> Dict[str, Any]:
42
+ return {k: v for k, v in params.items() if v is not None and v != ""}
43
+
44
+
45
+ def _describe(status: int, body: Any) -> "tuple[str, Optional[str]]":
46
+ err = body.get("error") if isinstance(body, dict) else None
47
+ if isinstance(err, str):
48
+ return err, None
49
+ if isinstance(err, dict):
50
+ msg = err.get("message") if isinstance(err.get("message"), str) else f"HTTP {status}"
51
+ code = err.get("code") if isinstance(err.get("code"), str) else None
52
+ return msg, code
53
+ return f"HTTP {status}", None
54
+
55
+
56
+ def _raise_for(status: int, body: Any) -> None:
57
+ """Map a non-2xx answer to an exception. Never touches the key."""
58
+ if status == 429:
59
+ st = body.get("indexState") if isinstance(body, dict) else None
60
+ index_state = IndexState.model_validate(st) if isinstance(st, dict) else IndexState(
61
+ coverage="unavailable", note=_describe(429, body)[0]
62
+ )
63
+ raise RateLimitError(index_state, body)
64
+ msg, code = _describe(status, body)
65
+ raise FilingStudioError(status, msg, code, body)
66
+
67
+
68
+ class _Base:
69
+ def __init__(
70
+ self,
71
+ api_key: Optional[str],
72
+ *,
73
+ base_url: Optional[str] = None,
74
+ timeout: float = 30.0,
75
+ max_retries: int = 2,
76
+ ) -> None:
77
+ key = (api_key or "").strip()
78
+ if not key:
79
+ raise NotConfigured()
80
+ self._key = key
81
+ self._base = (base_url or DEFAULT_BASE).rstrip("/")
82
+ self._timeout = timeout
83
+ self._max_retries = max(0, int(max_retries))
84
+
85
+ def _headers(self, json_body: bool) -> Dict[str, str]:
86
+ h = {"X-API-Key": self._key, "Accept": "application/json"}
87
+ if json_body:
88
+ h["Content-Type"] = "application/json"
89
+ return h
90
+
91
+ def _url(self, path: str) -> str:
92
+ return f"{self._base}{path}"
93
+
94
+ @staticmethod
95
+ def _backoff(attempt: int) -> float:
96
+ return 0.2 * (2 ** (attempt - 1)) + random.random() * 0.1
97
+
98
+ @staticmethod
99
+ def _parse(res: httpx.Response) -> Any:
100
+ try:
101
+ return res.json()
102
+ except ValueError:
103
+ return None
104
+
105
+ @staticmethod
106
+ def _unreachable(exc: Optional[BaseException]) -> FilingStudioError:
107
+ reason = type(exc).__name__ if exc else "network error"
108
+ return FilingStudioError(0, f"Filing Studio could not be reached ({reason}).", "unreachable")
109
+
110
+ # ---- result shaping, shared by sync and async ---------------------------
111
+
112
+ @staticmethod
113
+ def _search(body: Any, default_type: str) -> SearchResult:
114
+ env = body if isinstance(body, dict) else {}
115
+ data = env.get("data") or {}
116
+ return SearchResult(
117
+ type=data.get("type") or default_type,
118
+ results=[parse_hit(h) for h in (data.get("results") or [])],
119
+ indexState=env.get("indexState") or {},
120
+ pagination=env.get("pagination"),
121
+ note=env.get("note"),
122
+ )
123
+
124
+ @staticmethod
125
+ def _verify(body: Any) -> VerifyResult:
126
+ env = body if isinstance(body, dict) else {}
127
+ data = dict(env.get("data") or {})
128
+ data["indexState"] = env.get("indexState") or {}
129
+ return VerifyResult.model_validate(data)
130
+
131
+ @staticmethod
132
+ def _trace(body: Any) -> Optional[TraceDetail]:
133
+ env = body if isinstance(body, dict) else {}
134
+ traces = (env.get("data") or {}).get("traces") or []
135
+ return TraceDetail.model_validate(traces[0]) if traces else None
136
+
137
+ @staticmethod
138
+ def _filings(body: Any, ticker: str) -> FilingsResult:
139
+ env = body if isinstance(body, dict) else {}
140
+ data = env.get("data") or {}
141
+ return FilingsResult(
142
+ ticker=data.get("ticker") or ticker,
143
+ filings=data.get("filings") or [],
144
+ indexState=env.get("indexState") or {},
145
+ pagination=env.get("pagination"),
146
+ note=env.get("note"),
147
+ )
148
+
149
+ @staticmethod
150
+ def _coverage(body: Any, ticker: str) -> CoverageResult:
151
+ env = body if isinstance(body, dict) else {}
152
+ data = dict(env.get("data") or {})
153
+ data.setdefault("ticker", ticker)
154
+ data["note"] = env.get("note")
155
+ return CoverageResult.model_validate(data)
156
+
157
+ @staticmethod
158
+ def _table(body: Any) -> TableResult:
159
+ env = body if isinstance(body, dict) else {}
160
+ return TableResult(
161
+ data=env.get("data") or {},
162
+ provenance=env.get("provenance") or {},
163
+ links=env.get("links") or {},
164
+ )
165
+
166
+ # ---- paths ----------------------------------------------------------------
167
+
168
+ @staticmethod
169
+ def _search_params(ticker, q, type, period, forms, limit, offset) -> Dict[str, Any]:
170
+ return _clean({"ticker": ticker.upper(), "q": q, "type": type, "period": period,
171
+ "forms": forms, "limit": limit, "offset": offset})
172
+
173
+ @staticmethod
174
+ def _trace_path(trace_id: str, include_context: bool) -> str:
175
+ return f"/v1/trace/{quote(trace_id, safe='')}" + ("?include=context" if include_context else "")
176
+
177
+ @staticmethod
178
+ def _table_path(ticker: str, accession: str, table_id: str) -> str:
179
+ return (f"/v1/tables/{quote(ticker.upper(), safe='')}/{quote(accession, safe='')}/"
180
+ f"{quote(table_id, safe='')}")
181
+
182
+
183
+ class FilingStudio(_Base):
184
+ """Synchronous client. Use as a context manager to reuse one connection."""
185
+
186
+ def __init__(self, api_key: Optional[str], *, base_url: Optional[str] = None,
187
+ timeout: float = 30.0, max_retries: int = 2,
188
+ transport: Optional[httpx.BaseTransport] = None) -> None:
189
+ super().__init__(api_key, base_url=base_url, timeout=timeout, max_retries=max_retries)
190
+ self._http = httpx.Client(timeout=timeout, transport=transport)
191
+
192
+ def close(self) -> None:
193
+ self._http.close()
194
+
195
+ def __enter__(self) -> "FilingStudio":
196
+ return self
197
+
198
+ def __exit__(self, *exc: object) -> None:
199
+ self.close()
200
+
201
+ def _request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None,
202
+ json: Any = None) -> Any:
203
+ last: Optional[BaseException] = None
204
+ for attempt in range(self._max_retries + 1):
205
+ if attempt:
206
+ time.sleep(self._backoff(attempt))
207
+ try:
208
+ res = self._http.request(method, self._url(path), params=params, json=json,
209
+ headers=self._headers(json is not None))
210
+ except httpx.TransportError as exc:
211
+ last = exc
212
+ continue
213
+ body = self._parse(res)
214
+ if res.is_success:
215
+ return body if body is not None else {}
216
+ if res.status_code >= 500:
217
+ last = FilingStudioError(res.status_code, _describe(res.status_code, body)[0], None, body)
218
+ continue
219
+ _raise_for(res.status_code, body)
220
+ if isinstance(last, FilingStudioError):
221
+ raise last
222
+ raise self._unreachable(last)
223
+
224
+ def search(self, ticker: str, q: str, *, type: Optional[str] = None, period: Optional[str] = None,
225
+ forms: Optional[str] = None, limit: Optional[int] = None,
226
+ offset: Optional[int] = None) -> SearchResult:
227
+ """Search a company's printed rows, tables, and prose by plain words."""
228
+ body = self._request("GET", "/v1/search",
229
+ params=self._search_params(ticker, q, type, period, forms, limit, offset))
230
+ return self._search(body, type or "all")
231
+
232
+ def verify(self, ticker: str, *, metric: Optional[str] = None, value: Optional[float] = None,
233
+ period: Optional[str] = None, claim: Optional[str] = None) -> VerifyResult:
234
+ """Check a claim against what the filings print. Deterministic."""
235
+ payload = _clean({"ticker": ticker.upper(), "metric": metric, "value": value,
236
+ "period": period, "claim": claim})
237
+ return self._verify(self._request("POST", "/v1/verify", json=payload))
238
+
239
+ def trace(self, trace_id: str, *, include_context: bool = True) -> Optional[TraceDetail]:
240
+ """Resolve a traceId to the exact printed line, with neighbouring rows."""
241
+ return self._trace(self._request("GET", self._trace_path(trace_id, include_context)))
242
+
243
+ def filings(self, ticker: str, *, form: Optional[str] = None, year: Optional[str] = None,
244
+ limit: Optional[int] = None) -> FilingsResult:
245
+ """A company's indexed filings."""
246
+ t = ticker.upper()
247
+ body = self._request("GET", f"/v1/filings/{quote(t, safe='')}",
248
+ params=_clean({"form": form, "year": year, "limit": limit}))
249
+ return self._filings(body, t)
250
+
251
+ def coverage(self, ticker: str) -> CoverageResult:
252
+ """Is there anything indexed for this ticker, and how fresh?"""
253
+ t = ticker.upper()
254
+ return self._coverage(self._request("GET", "/v1/coverage", params={"ticker": t}), t)
255
+
256
+ def table(self, ticker: str, accession: str, table_id: str, *, format: str = "records") -> TableResult:
257
+ """One printed table, as filed: heading, row order, period headers, traces."""
258
+ return self._table(self._request("GET", self._table_path(ticker, accession, table_id),
259
+ params={"format": format}))
260
+
261
+
262
+ class AsyncFilingStudio(_Base):
263
+ """Asynchronous client with the same methods."""
264
+
265
+ def __init__(self, api_key: Optional[str], *, base_url: Optional[str] = None,
266
+ timeout: float = 30.0, max_retries: int = 2,
267
+ transport: Optional[httpx.AsyncBaseTransport] = None) -> None:
268
+ super().__init__(api_key, base_url=base_url, timeout=timeout, max_retries=max_retries)
269
+ self._http = httpx.AsyncClient(timeout=timeout, transport=transport)
270
+
271
+ async def aclose(self) -> None:
272
+ await self._http.aclose()
273
+
274
+ async def __aenter__(self) -> "AsyncFilingStudio":
275
+ return self
276
+
277
+ async def __aexit__(self, *exc: object) -> None:
278
+ await self.aclose()
279
+
280
+ async def _request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None,
281
+ json: Any = None) -> Any:
282
+ last: Optional[BaseException] = None
283
+ for attempt in range(self._max_retries + 1):
284
+ if attempt:
285
+ await anyio.sleep(self._backoff(attempt))
286
+ try:
287
+ res = await self._http.request(method, self._url(path), params=params, json=json,
288
+ headers=self._headers(json is not None))
289
+ except httpx.TransportError as exc:
290
+ last = exc
291
+ continue
292
+ body = self._parse(res)
293
+ if res.is_success:
294
+ return body if body is not None else {}
295
+ if res.status_code >= 500:
296
+ last = FilingStudioError(res.status_code, _describe(res.status_code, body)[0], None, body)
297
+ continue
298
+ _raise_for(res.status_code, body)
299
+ if isinstance(last, FilingStudioError):
300
+ raise last
301
+ raise self._unreachable(last)
302
+
303
+ async def search(self, ticker: str, q: str, *, type: Optional[str] = None,
304
+ period: Optional[str] = None, forms: Optional[str] = None,
305
+ limit: Optional[int] = None, offset: Optional[int] = None) -> SearchResult:
306
+ body = await self._request("GET", "/v1/search",
307
+ params=self._search_params(ticker, q, type, period, forms, limit, offset))
308
+ return self._search(body, type or "all")
309
+
310
+ async def verify(self, ticker: str, *, metric: Optional[str] = None, value: Optional[float] = None,
311
+ period: Optional[str] = None, claim: Optional[str] = None) -> VerifyResult:
312
+ payload = _clean({"ticker": ticker.upper(), "metric": metric, "value": value,
313
+ "period": period, "claim": claim})
314
+ return self._verify(await self._request("POST", "/v1/verify", json=payload))
315
+
316
+ async def trace(self, trace_id: str, *, include_context: bool = True) -> Optional[TraceDetail]:
317
+ return self._trace(await self._request("GET", self._trace_path(trace_id, include_context)))
318
+
319
+ async def filings(self, ticker: str, *, form: Optional[str] = None, year: Optional[str] = None,
320
+ limit: Optional[int] = None) -> FilingsResult:
321
+ t = ticker.upper()
322
+ body = await self._request("GET", f"/v1/filings/{quote(t, safe='')}",
323
+ params=_clean({"form": form, "year": year, "limit": limit}))
324
+ return self._filings(body, t)
325
+
326
+ async def coverage(self, ticker: str) -> CoverageResult:
327
+ t = ticker.upper()
328
+ return self._coverage(await self._request("GET", "/v1/coverage", params={"ticker": t}), t)
329
+
330
+ async def table(self, ticker: str, accession: str, table_id: str, *,
331
+ format: str = "records") -> TableResult:
332
+ return self._table(await self._request("GET", self._table_path(ticker, accession, table_id),
333
+ params={"format": format}))
@@ -0,0 +1,38 @@
1
+ """Errors. None of them ever carries the API key: it travels in a header, and
2
+ messages are built from the status and the API's own error body only."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any, Optional
7
+
8
+ from .models import IndexState
9
+
10
+
11
+ class FilingStudioError(Exception):
12
+ """Any non-2xx answer (or an unreachable API, status 0)."""
13
+
14
+ def __init__(self, status: int, message: str, code: Optional[str] = None, body: Any = None) -> None:
15
+ super().__init__(message)
16
+ self.status = status
17
+ self.message = message
18
+ self.code = code
19
+ self.body = body
20
+
21
+ def __str__(self) -> str: # pragma: no cover - trivial
22
+ return f"{self.message} (HTTP {self.status})" if self.status else self.message
23
+
24
+
25
+ class RateLimitError(FilingStudioError):
26
+ """429: the daily limit is spent. `index_state.note` is the honest sentence
27
+ to show a user: it says nothing about what the filings contain."""
28
+
29
+ def __init__(self, index_state: IndexState, body: Any = None) -> None:
30
+ super().__init__(429, index_state.note or "Daily request limit reached.", "rate_limited", body)
31
+ self.index_state = index_state
32
+
33
+
34
+ class NotConfigured(FilingStudioError):
35
+ """No API key was given."""
36
+
37
+ def __init__(self) -> None:
38
+ super().__init__(0, "FilingStudio requires an api_key.", "not_configured")