filingstudio 0.5.0__tar.gz → 0.6.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.
- {filingstudio-0.5.0 → filingstudio-0.6.0}/PKG-INFO +8 -1
- {filingstudio-0.5.0 → filingstudio-0.6.0}/README.md +7 -0
- {filingstudio-0.5.0 → filingstudio-0.6.0}/filingstudio/__init__.py +1 -1
- {filingstudio-0.5.0 → filingstudio-0.6.0}/filingstudio/client.py +111 -11
- {filingstudio-0.5.0 → filingstudio-0.6.0}/pyproject.toml +1 -1
- {filingstudio-0.5.0 → filingstudio-0.6.0}/tests/test_client.py +64 -0
- {filingstudio-0.5.0 → filingstudio-0.6.0}/.gitignore +0 -0
- {filingstudio-0.5.0 → filingstudio-0.6.0}/LICENSE +0 -0
- {filingstudio-0.5.0 → filingstudio-0.6.0}/filingstudio/errors.py +0 -0
- {filingstudio-0.5.0 → filingstudio-0.6.0}/filingstudio/models.py +0 -0
- {filingstudio-0.5.0 → filingstudio-0.6.0}/filingstudio/research.py +0 -0
- {filingstudio-0.5.0 → filingstudio-0.6.0}/tests/test_research.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: filingstudio
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
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
5
|
Project-URL: Homepage, https://filingstudio.com
|
|
6
6
|
Project-URL: Documentation, https://filingstudio.com/docs#sdk
|
|
@@ -84,6 +84,13 @@ async with AsyncFilingStudio(api_key=key) as fs:
|
|
|
84
84
|
| `kpi_tables(ticker, quarterly=False)` | the newest 10-K's (10-Q's) non-statement tables, as a dict |
|
|
85
85
|
| `kpi_series(ticker, table_id, quarterly=False, years=10)` | one table across filings as rows x periods, as a dict |
|
|
86
86
|
| `statement(ticker, kind, quarterly=False, years=10)` | `income`, `balance` or `cashflow` as a time series, as a dict |
|
|
87
|
+
| `filing_html(ticker, accession)` | the whole filing as sanitized HTML (str) |
|
|
88
|
+
| `filing_tables(ticker, accession)` | every printed table in the filing (list) |
|
|
89
|
+
| `exhibits(ticker, accession)` | the exhibits that carry tables (list) |
|
|
90
|
+
| `similar(ticker, accession, table_id, targets)` | the same table in other filings (list) |
|
|
91
|
+
| `table_xlsx(ticker, accession, table_id)` | one printed table as an Excel file (bytes) |
|
|
92
|
+
| `compare_targets(ticker, accession)` | older same-form filings to compare against (list) |
|
|
93
|
+
| `compare_html(ticker, accession, target=None)` | a word-level redline against an older filing (str) |
|
|
87
94
|
|
|
88
95
|
Pass `value` to `verify` as the raw figure you hold (130497 or 130497000000
|
|
89
96
|
alike). The API tries every printed scale a filer could use. Do not pre-scale.
|
|
@@ -51,6 +51,13 @@ async with AsyncFilingStudio(api_key=key) as fs:
|
|
|
51
51
|
| `kpi_tables(ticker, quarterly=False)` | the newest 10-K's (10-Q's) non-statement tables, as a dict |
|
|
52
52
|
| `kpi_series(ticker, table_id, quarterly=False, years=10)` | one table across filings as rows x periods, as a dict |
|
|
53
53
|
| `statement(ticker, kind, quarterly=False, years=10)` | `income`, `balance` or `cashflow` as a time series, as a dict |
|
|
54
|
+
| `filing_html(ticker, accession)` | the whole filing as sanitized HTML (str) |
|
|
55
|
+
| `filing_tables(ticker, accession)` | every printed table in the filing (list) |
|
|
56
|
+
| `exhibits(ticker, accession)` | the exhibits that carry tables (list) |
|
|
57
|
+
| `similar(ticker, accession, table_id, targets)` | the same table in other filings (list) |
|
|
58
|
+
| `table_xlsx(ticker, accession, table_id)` | one printed table as an Excel file (bytes) |
|
|
59
|
+
| `compare_targets(ticker, accession)` | older same-form filings to compare against (list) |
|
|
60
|
+
| `compare_html(ticker, accession, target=None)` | a word-level redline against an older filing (str) |
|
|
54
61
|
|
|
55
62
|
Pass `value` to `verify` as the raw figure you hold (130497 or 130497000000
|
|
56
63
|
alike). The API tries every printed scale a filer could use. Do not pre-scale.
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"""
|
|
2
2
|
The Filing Studio client, sync and async. One method per /v1 door, the same
|
|
3
3
|
names as the Node `@filingstudio/client` and the browser `ProvenanceClient`,
|
|
4
|
-
plus
|
|
5
|
-
|
|
4
|
+
plus research doors (kpi_tables, kpi_series, statement) and reader doors
|
|
5
|
+
(filing_html, filing_tables, exhibits, similar, table_xlsx, compare_targets,
|
|
6
|
+
compare_html) that answer plain dicts, lists, text, or bytes.
|
|
6
7
|
|
|
7
8
|
from filingstudio import FilingStudio
|
|
8
9
|
fs = FilingStudio(api_key=os.environ["FILING_STUDIO_API_KEY"])
|
|
@@ -19,7 +20,7 @@ from __future__ import annotations
|
|
|
19
20
|
|
|
20
21
|
import random
|
|
21
22
|
import time
|
|
22
|
-
from typing import Any, Dict, Optional
|
|
23
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
23
24
|
from urllib.parse import quote
|
|
24
25
|
|
|
25
26
|
import anyio
|
|
@@ -195,6 +196,25 @@ class _Base:
|
|
|
195
196
|
def _kpi_series_path(ticker: str) -> str:
|
|
196
197
|
return f"/api/research/kpi/series/{quote(ticker.upper(), safe='')}"
|
|
197
198
|
|
|
199
|
+
@staticmethod
|
|
200
|
+
def _filing_path(ticker: str, accession: str) -> str:
|
|
201
|
+
return f"/{quote(ticker.upper(), safe='')}/{quote(accession, safe='')}"
|
|
202
|
+
|
|
203
|
+
@staticmethod
|
|
204
|
+
def _data_list(body: Any, key: str) -> List[Dict[str, Any]]:
|
|
205
|
+
env = body if isinstance(body, dict) else {}
|
|
206
|
+
data = env["data"] if isinstance(env.get("data"), dict) else env
|
|
207
|
+
out = data.get(key) or []
|
|
208
|
+
return [x for x in out if isinstance(x, dict)] if isinstance(out, list) else []
|
|
209
|
+
|
|
210
|
+
@staticmethod
|
|
211
|
+
def _similar(body: Any) -> List[Dict[str, Any]]:
|
|
212
|
+
env = body if isinstance(body, dict) else {}
|
|
213
|
+
data = env["data"] if isinstance(env.get("data"), dict) else env
|
|
214
|
+
hits = data.get("targets") or data.get("results") or []
|
|
215
|
+
return [{**h, "tableId": h.get("tableId", h.get("table_id"))}
|
|
216
|
+
for h in hits if isinstance(h, dict) and h.get("accession")]
|
|
217
|
+
|
|
198
218
|
@staticmethod
|
|
199
219
|
def _statement_path(ticker: str, kind: str) -> str:
|
|
200
220
|
if kind not in STATEMENT_KINDS:
|
|
@@ -220,8 +240,8 @@ class FilingStudio(_Base):
|
|
|
220
240
|
def __exit__(self, *exc: object) -> None:
|
|
221
241
|
self.close()
|
|
222
242
|
|
|
223
|
-
def
|
|
224
|
-
|
|
243
|
+
def _send(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None,
|
|
244
|
+
json: Any = None) -> httpx.Response:
|
|
225
245
|
last: Optional[BaseException] = None
|
|
226
246
|
for attempt in range(self._max_retries + 1):
|
|
227
247
|
if attempt:
|
|
@@ -232,9 +252,9 @@ class FilingStudio(_Base):
|
|
|
232
252
|
except httpx.TransportError as exc:
|
|
233
253
|
last = exc
|
|
234
254
|
continue
|
|
235
|
-
body = self._parse(res)
|
|
236
255
|
if res.is_success:
|
|
237
|
-
return
|
|
256
|
+
return res
|
|
257
|
+
body = self._parse(res)
|
|
238
258
|
if res.status_code >= 500:
|
|
239
259
|
last = FilingStudioError(res.status_code, _describe(res.status_code, body)[0], None, body)
|
|
240
260
|
continue
|
|
@@ -243,6 +263,11 @@ class FilingStudio(_Base):
|
|
|
243
263
|
raise last
|
|
244
264
|
raise self._unreachable(last)
|
|
245
265
|
|
|
266
|
+
def _request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None,
|
|
267
|
+
json: Any = None) -> Any:
|
|
268
|
+
body = self._parse(self._send(method, path, params=params, json=json))
|
|
269
|
+
return body if body is not None else {}
|
|
270
|
+
|
|
246
271
|
def search(self, ticker: str, q: str, *, type: Optional[str] = None, period: Optional[str] = None,
|
|
247
272
|
forms: Optional[str] = None, limit: Optional[int] = None,
|
|
248
273
|
offset: Optional[int] = None) -> SearchResult:
|
|
@@ -299,6 +324,46 @@ class FilingStudio(_Base):
|
|
|
299
324
|
return self._request("GET", self._statement_path(ticker, kind),
|
|
300
325
|
params={"quarterly": quarterly, "years": years})
|
|
301
326
|
|
|
327
|
+
# ---- the reader: one filing, its documents and tables ----------------------
|
|
328
|
+
|
|
329
|
+
def filing_html(self, ticker: str, accession: str) -> str:
|
|
330
|
+
"""The whole filing as sanitized HTML, ready for an iframe."""
|
|
331
|
+
return self._send("GET", f"/api/filings{self._filing_path(ticker, accession)}/html").text
|
|
332
|
+
|
|
333
|
+
def filing_tables(self, ticker: str, accession: str) -> List[Dict[str, Any]]:
|
|
334
|
+
"""Every printed table in one filing: id, heading, size, periods."""
|
|
335
|
+
return self._data_list(
|
|
336
|
+
self._request("GET", f"/v1/filings{self._filing_path(ticker, accession)}/tables"), "tables")
|
|
337
|
+
|
|
338
|
+
def exhibits(self, ticker: str, accession: str) -> List[Dict[str, Any]]:
|
|
339
|
+
"""The filing's exhibits that carry tables, with their table ids."""
|
|
340
|
+
return self._data_list(
|
|
341
|
+
self._request("GET", f"/v1/filings{self._filing_path(ticker, accession)}/exhibits"), "exhibits")
|
|
342
|
+
|
|
343
|
+
def similar(self, ticker: str, accession: str, table_id: str,
|
|
344
|
+
targets: Sequence[str]) -> List[Dict[str, Any]]:
|
|
345
|
+
"""The same printed table in other filings (target accessions). One entry per
|
|
346
|
+
target: `tableId` is None where nothing matched, `matchedBy` and `why` say how it did."""
|
|
347
|
+
if not targets:
|
|
348
|
+
return []
|
|
349
|
+
return self._similar(self._request("GET", self._table_path(ticker, accession, table_id) + "/same-in",
|
|
350
|
+
params={"targets": ",".join(targets)}))
|
|
351
|
+
|
|
352
|
+
def table_xlsx(self, ticker: str, accession: str, table_id: str) -> bytes:
|
|
353
|
+
"""One printed table as an Excel workbook."""
|
|
354
|
+
return self._send("GET", self._table_path(ticker, accession, table_id) + "/xlsx").content
|
|
355
|
+
|
|
356
|
+
def compare_targets(self, ticker: str, accession: str) -> List[Dict[str, Any]]:
|
|
357
|
+
"""Older filings of the same form this one can be compared against, newest first."""
|
|
358
|
+
return self._data_list(
|
|
359
|
+
self._request("GET", f"/api/filings{self._filing_path(ticker, accession)}/compare-targets"), "targets")
|
|
360
|
+
|
|
361
|
+
def compare_html(self, ticker: str, accession: str, target: Optional[str] = None) -> str:
|
|
362
|
+
"""A word-level redline of this filing against `target` (default: the prior
|
|
363
|
+
filing of the same form), as HTML."""
|
|
364
|
+
return self._send("GET", f"/api/filings{self._filing_path(ticker, accession)}/compare-filing",
|
|
365
|
+
params=_clean({"target": target})).text
|
|
366
|
+
|
|
302
367
|
|
|
303
368
|
class AsyncFilingStudio(_Base):
|
|
304
369
|
"""Asynchronous client with the same methods."""
|
|
@@ -318,8 +383,8 @@ class AsyncFilingStudio(_Base):
|
|
|
318
383
|
async def __aexit__(self, *exc: object) -> None:
|
|
319
384
|
await self.aclose()
|
|
320
385
|
|
|
321
|
-
async def
|
|
322
|
-
|
|
386
|
+
async def _send(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None,
|
|
387
|
+
json: Any = None) -> httpx.Response:
|
|
323
388
|
last: Optional[BaseException] = None
|
|
324
389
|
for attempt in range(self._max_retries + 1):
|
|
325
390
|
if attempt:
|
|
@@ -330,9 +395,9 @@ class AsyncFilingStudio(_Base):
|
|
|
330
395
|
except httpx.TransportError as exc:
|
|
331
396
|
last = exc
|
|
332
397
|
continue
|
|
333
|
-
body = self._parse(res)
|
|
334
398
|
if res.is_success:
|
|
335
|
-
return
|
|
399
|
+
return res
|
|
400
|
+
body = self._parse(res)
|
|
336
401
|
if res.status_code >= 500:
|
|
337
402
|
last = FilingStudioError(res.status_code, _describe(res.status_code, body)[0], None, body)
|
|
338
403
|
continue
|
|
@@ -341,6 +406,11 @@ class AsyncFilingStudio(_Base):
|
|
|
341
406
|
raise last
|
|
342
407
|
raise self._unreachable(last)
|
|
343
408
|
|
|
409
|
+
async def _request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None,
|
|
410
|
+
json: Any = None) -> Any:
|
|
411
|
+
body = self._parse(await self._send(method, path, params=params, json=json))
|
|
412
|
+
return body if body is not None else {}
|
|
413
|
+
|
|
344
414
|
async def search(self, ticker: str, q: str, *, type: Optional[str] = None,
|
|
345
415
|
period: Optional[str] = None, forms: Optional[str] = None,
|
|
346
416
|
limit: Optional[int] = None, offset: Optional[int] = None) -> SearchResult:
|
|
@@ -385,3 +455,33 @@ class AsyncFilingStudio(_Base):
|
|
|
385
455
|
years: int = 10) -> Dict[str, Any]:
|
|
386
456
|
return await self._request("GET", self._statement_path(ticker, kind),
|
|
387
457
|
params={"quarterly": quarterly, "years": years})
|
|
458
|
+
|
|
459
|
+
async def filing_html(self, ticker: str, accession: str) -> str:
|
|
460
|
+
return (await self._send("GET", f"/api/filings{self._filing_path(ticker, accession)}/html")).text
|
|
461
|
+
|
|
462
|
+
async def filing_tables(self, ticker: str, accession: str) -> List[Dict[str, Any]]:
|
|
463
|
+
return self._data_list(
|
|
464
|
+
await self._request("GET", f"/v1/filings{self._filing_path(ticker, accession)}/tables"), "tables")
|
|
465
|
+
|
|
466
|
+
async def exhibits(self, ticker: str, accession: str) -> List[Dict[str, Any]]:
|
|
467
|
+
return self._data_list(
|
|
468
|
+
await self._request("GET", f"/v1/filings{self._filing_path(ticker, accession)}/exhibits"), "exhibits")
|
|
469
|
+
|
|
470
|
+
async def similar(self, ticker: str, accession: str, table_id: str,
|
|
471
|
+
targets: Sequence[str]) -> List[Dict[str, Any]]:
|
|
472
|
+
if not targets:
|
|
473
|
+
return []
|
|
474
|
+
return self._similar(await self._request("GET", self._table_path(ticker, accession, table_id) + "/same-in",
|
|
475
|
+
params={"targets": ",".join(targets)}))
|
|
476
|
+
|
|
477
|
+
async def table_xlsx(self, ticker: str, accession: str, table_id: str) -> bytes:
|
|
478
|
+
return (await self._send("GET", self._table_path(ticker, accession, table_id) + "/xlsx")).content
|
|
479
|
+
|
|
480
|
+
async def compare_targets(self, ticker: str, accession: str) -> List[Dict[str, Any]]:
|
|
481
|
+
return self._data_list(
|
|
482
|
+
await self._request("GET", f"/api/filings{self._filing_path(ticker, accession)}/compare-targets"),
|
|
483
|
+
"targets")
|
|
484
|
+
|
|
485
|
+
async def compare_html(self, ticker: str, accession: str, target: Optional[str] = None) -> str:
|
|
486
|
+
return (await self._send("GET", f"/api/filings{self._filing_path(ticker, accession)}/compare-filing",
|
|
487
|
+
params=_clean({"target": target}))).text
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "filingstudio"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.6.0"
|
|
8
8
|
description = "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."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -251,3 +251,67 @@ async def test_async_research_doors():
|
|
|
251
251
|
"/api/research/kpi/series/NVDA",
|
|
252
252
|
"/api/research/kpi/tables/NVDA",
|
|
253
253
|
]
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
ACC = "0000882184-26-000123"
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def test_reader_doors_paths_and_shapes():
|
|
260
|
+
def h(r, n):
|
|
261
|
+
p = r.url.path
|
|
262
|
+
if p.endswith("/html") or p.endswith("/compare-filing"):
|
|
263
|
+
return httpx.Response(200, text="<html>filing</html>", headers={"content-type": "text/html"})
|
|
264
|
+
if p.endswith("/xlsx"):
|
|
265
|
+
return httpx.Response(200, content=b"PK\x03\x04")
|
|
266
|
+
if p.endswith("/tables"):
|
|
267
|
+
return ok({"data": {"tables": [{"id": "primary:1"}]}})
|
|
268
|
+
if p.endswith("/exhibits"):
|
|
269
|
+
return ok({"exhibits": [{"exhibit": "EX-99.1", "tableIds": ["exhibit:3"]}]})
|
|
270
|
+
if p.endswith("/same-in"):
|
|
271
|
+
return ok({"data": {"targets": [{"accession": "A1", "table_id": "primary:9"}, {"accession": "A2"}]}})
|
|
272
|
+
if p.endswith("/compare-targets"):
|
|
273
|
+
return ok({"targets": [{"accession": "A1"}]})
|
|
274
|
+
return httpx.Response(404, json={"detail": "nope"})
|
|
275
|
+
|
|
276
|
+
fs, seen = make(h)
|
|
277
|
+
assert fs.filing_html("dhi", ACC) == "<html>filing</html>"
|
|
278
|
+
assert fs.filing_tables("dhi", ACC) == [{"id": "primary:1"}]
|
|
279
|
+
assert fs.exhibits("dhi", ACC)[0]["exhibit"] == "EX-99.1"
|
|
280
|
+
hits = fs.similar("dhi", ACC, "primary:1", ["A1", "A2"])
|
|
281
|
+
assert [(x["accession"], x["tableId"]) for x in hits] == [("A1", "primary:9"), ("A2", None)]
|
|
282
|
+
assert fs.table_xlsx("dhi", ACC, "primary:1") == b"PK\x03\x04"
|
|
283
|
+
assert fs.compare_targets("dhi", ACC) == [{"accession": "A1"}]
|
|
284
|
+
assert fs.compare_html("dhi", ACC, target="A1") == "<html>filing</html>"
|
|
285
|
+
assert fs.similar("dhi", ACC, "primary:1", []) == []
|
|
286
|
+
paths = [r.url.path for r in seen]
|
|
287
|
+
assert paths == [
|
|
288
|
+
f"/api/filings/DHI/{ACC}/html",
|
|
289
|
+
f"/v1/filings/DHI/{ACC}/tables",
|
|
290
|
+
f"/v1/filings/DHI/{ACC}/exhibits",
|
|
291
|
+
f"/v1/tables/DHI/{ACC}/primary:1/same-in",
|
|
292
|
+
f"/v1/tables/DHI/{ACC}/primary:1/xlsx",
|
|
293
|
+
f"/api/filings/DHI/{ACC}/compare-targets",
|
|
294
|
+
f"/api/filings/DHI/{ACC}/compare-filing",
|
|
295
|
+
]
|
|
296
|
+
assert dict(seen[3].url.params) == {"targets": "A1,A2"}
|
|
297
|
+
assert dict(seen[6].url.params) == {"target": "A1"}
|
|
298
|
+
assert all(r.headers["x-api-key"] == KEY and KEY not in str(r.url) for r in seen)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def test_filing_html_404_raises():
|
|
302
|
+
fs, _ = make(lambda r, n: httpx.Response(404, json={"detail": "Filing not available"}))
|
|
303
|
+
with pytest.raises(FilingStudioError) as e:
|
|
304
|
+
fs.filing_html("DHI", ACC)
|
|
305
|
+
assert e.value.message == "Filing not available"
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@pytest.mark.anyio
|
|
309
|
+
async def test_async_reader_doors():
|
|
310
|
+
def h(r, n):
|
|
311
|
+
if r.url.path.endswith("/html"):
|
|
312
|
+
return httpx.Response(200, text="<p>x</p>")
|
|
313
|
+
return ok({"data": {"targets": [{"accession": "A1", "tableId": "primary:2"}]}})
|
|
314
|
+
|
|
315
|
+
fs, seen = amake(h)
|
|
316
|
+
assert await fs.filing_html("dhi", ACC) == "<p>x</p>"
|
|
317
|
+
assert (await fs.similar("dhi", ACC, "primary:1", ["A1"]))[0]["tableId"] == "primary:2"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|