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.
- filingstudio-0.4.0/.gitignore +7 -0
- filingstudio-0.4.0/LICENSE +21 -0
- filingstudio-0.4.0/PKG-INFO +132 -0
- filingstudio-0.4.0/README.md +99 -0
- filingstudio-0.4.0/filingstudio/__init__.py +48 -0
- filingstudio-0.4.0/filingstudio/client.py +333 -0
- filingstudio-0.4.0/filingstudio/errors.py +38 -0
- filingstudio-0.4.0/filingstudio/models.py +194 -0
- filingstudio-0.4.0/filingstudio/research.py +411 -0
- filingstudio-0.4.0/pyproject.toml +48 -0
- filingstudio-0.4.0/tests/test_client.py +209 -0
- filingstudio-0.4.0/tests/test_research.py +136 -0
|
@@ -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")
|