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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: filingstudio
3
- Version: 0.5.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.
@@ -21,7 +21,7 @@ from .models import (
21
21
  VerifyResult,
22
22
  )
23
23
 
24
- __version__ = "0.5.0"
24
+ __version__ = "0.6.0"
25
25
  __all__ = [
26
26
  "AsyncFilingStudio",
27
27
  "ContextRow",
@@ -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 three research doors (kpi_tables, kpi_series, statement) that answer
5
- the research JSON as a plain dict.
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 _request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None,
224
- json: Any = None) -> Any:
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 body if body is not None else {}
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 _request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None,
322
- json: Any = None) -> Any:
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 body if body is not None else {}
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.5.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