clous 0.1.0__py3-none-any.whl
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.
- clous/__init__.py +56 -0
- clous/_client.py +240 -0
- clous/_models.py +121 -0
- clous/_version.py +1 -0
- clous/client.py +204 -0
- clous/exceptions.py +103 -0
- clous/py.typed +0 -0
- clous/resources.py +506 -0
- clous-0.1.0.dist-info/METADATA +273 -0
- clous-0.1.0.dist-info/RECORD +12 -0
- clous-0.1.0.dist-info/WHEEL +4 -0
- clous-0.1.0.dist-info/licenses/LICENSE +21 -0
clous/__init__.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Clous — the official Python SDK for the Clous SEC/EDGAR API.
|
|
2
|
+
|
|
3
|
+
Quickstart::
|
|
4
|
+
|
|
5
|
+
from clous import Clous
|
|
6
|
+
|
|
7
|
+
client = Clous() # reads CLOUS_API_KEY from the environment
|
|
8
|
+
|
|
9
|
+
# Search filings
|
|
10
|
+
page = client.filings.search(form_type="8-K", limit=10)
|
|
11
|
+
for filing in page:
|
|
12
|
+
print(filing["accession"])
|
|
13
|
+
|
|
14
|
+
# Structured XBRL financials for one company
|
|
15
|
+
facts = client.financials.get("0000320193", concept="Revenues")
|
|
16
|
+
|
|
17
|
+
# Grounded Q&A
|
|
18
|
+
ans = client.answer("What did Apple report as revenue last quarter?", ticker="AAPL")
|
|
19
|
+
|
|
20
|
+
# Auto-paginate every record
|
|
21
|
+
for ev in client.events.iterate(ticker="NVDA", importance="high", max_items=500):
|
|
22
|
+
print(ev["event_type"])
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from ._models import Page, PageInfo
|
|
26
|
+
from ._version import __version__
|
|
27
|
+
from .client import Clous
|
|
28
|
+
from .exceptions import (
|
|
29
|
+
APIError,
|
|
30
|
+
AuthenticationError,
|
|
31
|
+
ClousConnectionError,
|
|
32
|
+
ClousError,
|
|
33
|
+
ClousTimeoutError,
|
|
34
|
+
InvalidRequestError,
|
|
35
|
+
NotFoundError,
|
|
36
|
+
PermissionDeniedError,
|
|
37
|
+
RateLimitError,
|
|
38
|
+
ServerError,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"Clous",
|
|
43
|
+
"Page",
|
|
44
|
+
"PageInfo",
|
|
45
|
+
"ClousError",
|
|
46
|
+
"APIError",
|
|
47
|
+
"AuthenticationError",
|
|
48
|
+
"PermissionDeniedError",
|
|
49
|
+
"NotFoundError",
|
|
50
|
+
"RateLimitError",
|
|
51
|
+
"InvalidRequestError",
|
|
52
|
+
"ServerError",
|
|
53
|
+
"ClousConnectionError",
|
|
54
|
+
"ClousTimeoutError",
|
|
55
|
+
"__version__",
|
|
56
|
+
]
|
clous/_client.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""The low-level HTTP transport for the Clous SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json as _json
|
|
6
|
+
import os
|
|
7
|
+
import random
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any, Dict, Iterator, Mapping, Optional, Union
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from ._models import Page
|
|
14
|
+
from ._version import __version__
|
|
15
|
+
from .exceptions import (
|
|
16
|
+
APIError,
|
|
17
|
+
ClousConnectionError,
|
|
18
|
+
ClousError,
|
|
19
|
+
ClousTimeoutError,
|
|
20
|
+
RateLimitError,
|
|
21
|
+
ServerError,
|
|
22
|
+
error_from_status,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
DEFAULT_BASE_URL = "https://api.clous.ai"
|
|
26
|
+
DEFAULT_TIMEOUT = 30.0
|
|
27
|
+
DEFAULT_MAX_RETRIES = 3
|
|
28
|
+
|
|
29
|
+
# Status codes we retry (in addition to network errors).
|
|
30
|
+
_RETRY_STATUS = {429, 500, 502, 503, 504}
|
|
31
|
+
|
|
32
|
+
ParamValue = Union[str, int, float, bool, None]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _coerce_params(params: Optional[Mapping[str, Any]]) -> Dict[str, str]:
|
|
36
|
+
"""Drop ``None``/empty values and stringify the rest (booleans -> true/false).
|
|
37
|
+
|
|
38
|
+
``output_schema`` is JSON-encoded if a dict/list is passed.
|
|
39
|
+
"""
|
|
40
|
+
out: Dict[str, str] = {}
|
|
41
|
+
if not params:
|
|
42
|
+
return out
|
|
43
|
+
for key, value in params.items():
|
|
44
|
+
if value is None or value == "":
|
|
45
|
+
continue
|
|
46
|
+
if key == "output_schema" and isinstance(value, (dict, list)):
|
|
47
|
+
out[key] = _json.dumps(value, separators=(",", ":"))
|
|
48
|
+
elif isinstance(value, bool):
|
|
49
|
+
out[key] = "true" if value else "false"
|
|
50
|
+
elif isinstance(value, (list, tuple)):
|
|
51
|
+
out[key] = ",".join(str(v) for v in value)
|
|
52
|
+
else:
|
|
53
|
+
out[key] = str(value)
|
|
54
|
+
return out
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class HTTPClient:
|
|
58
|
+
"""Thin wrapper over :class:`httpx.Client` that speaks the Clous envelope."""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
api_key: Optional[str] = None,
|
|
63
|
+
base_url: Optional[str] = None,
|
|
64
|
+
*,
|
|
65
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
66
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
67
|
+
http_client: Optional[httpx.Client] = None,
|
|
68
|
+
default_headers: Optional[Mapping[str, str]] = None,
|
|
69
|
+
) -> None:
|
|
70
|
+
api_key = api_key or os.environ.get("CLOUS_API_KEY")
|
|
71
|
+
base_url = base_url or os.environ.get("CLOUS_BASE_URL") or DEFAULT_BASE_URL
|
|
72
|
+
# The OpenAI-compatible base may be passed with a trailing /v1; strip it
|
|
73
|
+
# so resource paths (which include /v1) resolve correctly.
|
|
74
|
+
base_url = base_url.rstrip("/")
|
|
75
|
+
if base_url.endswith("/v1"):
|
|
76
|
+
base_url = base_url[: -len("/v1")]
|
|
77
|
+
|
|
78
|
+
self.api_key = api_key
|
|
79
|
+
self.base_url = base_url
|
|
80
|
+
self.max_retries = max_retries
|
|
81
|
+
|
|
82
|
+
headers = {
|
|
83
|
+
"Accept": "application/json",
|
|
84
|
+
"User-Agent": f"clous-python/{__version__}",
|
|
85
|
+
}
|
|
86
|
+
if api_key:
|
|
87
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
88
|
+
if default_headers:
|
|
89
|
+
headers.update(default_headers)
|
|
90
|
+
|
|
91
|
+
self._owns_client = http_client is None
|
|
92
|
+
self._client = http_client or httpx.Client(timeout=timeout, follow_redirects=True)
|
|
93
|
+
self._headers = headers
|
|
94
|
+
|
|
95
|
+
# ------------------------------------------------------------------ core
|
|
96
|
+
def request(
|
|
97
|
+
self,
|
|
98
|
+
method: str,
|
|
99
|
+
path: str,
|
|
100
|
+
*,
|
|
101
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
102
|
+
body: Any = None,
|
|
103
|
+
raw: bool = False,
|
|
104
|
+
) -> Any:
|
|
105
|
+
"""Issue a request and return a :class:`Page` (or raw dict if ``raw``)."""
|
|
106
|
+
url = self.base_url + path
|
|
107
|
+
query = _coerce_params(params)
|
|
108
|
+
headers = dict(self._headers)
|
|
109
|
+
content = None
|
|
110
|
+
if body is not None:
|
|
111
|
+
headers["Content-Type"] = "application/json"
|
|
112
|
+
content = _json.dumps(body)
|
|
113
|
+
|
|
114
|
+
response = self._send_with_retries(method, url, params=query, headers=headers, content=content)
|
|
115
|
+
return self._parse(response, raw=raw)
|
|
116
|
+
|
|
117
|
+
def _send_with_retries(
|
|
118
|
+
self,
|
|
119
|
+
method: str,
|
|
120
|
+
url: str,
|
|
121
|
+
*,
|
|
122
|
+
params: Dict[str, str],
|
|
123
|
+
headers: Dict[str, str],
|
|
124
|
+
content: Optional[str],
|
|
125
|
+
) -> httpx.Response:
|
|
126
|
+
attempt = 0
|
|
127
|
+
while True:
|
|
128
|
+
try:
|
|
129
|
+
response = self._client.request(
|
|
130
|
+
method, url, params=params, headers=headers, content=content
|
|
131
|
+
)
|
|
132
|
+
except httpx.TimeoutException as exc:
|
|
133
|
+
if attempt >= self.max_retries:
|
|
134
|
+
raise ClousTimeoutError(f"Request to {url} timed out: {exc}") from exc
|
|
135
|
+
except httpx.HTTPError as exc:
|
|
136
|
+
if attempt >= self.max_retries:
|
|
137
|
+
raise ClousConnectionError(f"Network error calling {url}: {exc}") from exc
|
|
138
|
+
else:
|
|
139
|
+
if response.status_code not in _RETRY_STATUS or attempt >= self.max_retries:
|
|
140
|
+
return response
|
|
141
|
+
|
|
142
|
+
# Honor Retry-After when present (only available if we got a response).
|
|
143
|
+
sleep_for = self._backoff(attempt)
|
|
144
|
+
try:
|
|
145
|
+
retry_after = response.headers.get("retry-after") # type: ignore[name-defined]
|
|
146
|
+
if retry_after:
|
|
147
|
+
sleep_for = max(sleep_for, float(retry_after))
|
|
148
|
+
except (NameError, ValueError):
|
|
149
|
+
pass
|
|
150
|
+
time.sleep(sleep_for)
|
|
151
|
+
attempt += 1
|
|
152
|
+
|
|
153
|
+
@staticmethod
|
|
154
|
+
def _backoff(attempt: int) -> float:
|
|
155
|
+
# Exponential backoff with jitter: 0.5, 1, 2, ... capped at 8s.
|
|
156
|
+
base = min(0.5 * (2 ** attempt), 8.0)
|
|
157
|
+
return base + random.uniform(0, 0.25)
|
|
158
|
+
|
|
159
|
+
def _parse(self, response: httpx.Response, *, raw: bool) -> Any:
|
|
160
|
+
request_id = response.headers.get("x-request-id")
|
|
161
|
+
if not response.is_success:
|
|
162
|
+
message, parsed = self._extract_error(response)
|
|
163
|
+
raise error_from_status(
|
|
164
|
+
response.status_code,
|
|
165
|
+
message,
|
|
166
|
+
request_id=request_id,
|
|
167
|
+
body=parsed,
|
|
168
|
+
response=response,
|
|
169
|
+
)
|
|
170
|
+
try:
|
|
171
|
+
payload = response.json()
|
|
172
|
+
except ValueError as exc:
|
|
173
|
+
raise APIError(
|
|
174
|
+
f"Could not decode JSON response: {exc}",
|
|
175
|
+
status_code=response.status_code,
|
|
176
|
+
request_id=request_id,
|
|
177
|
+
body=response.text,
|
|
178
|
+
response=response,
|
|
179
|
+
) from exc
|
|
180
|
+
|
|
181
|
+
if raw:
|
|
182
|
+
return payload
|
|
183
|
+
return Page(payload, headers=dict(response.headers))
|
|
184
|
+
|
|
185
|
+
@staticmethod
|
|
186
|
+
def _extract_error(response: httpx.Response) -> Any:
|
|
187
|
+
parsed: Any = None
|
|
188
|
+
message = f"Clous API request failed with status {response.status_code}"
|
|
189
|
+
try:
|
|
190
|
+
parsed = response.json()
|
|
191
|
+
if isinstance(parsed, dict):
|
|
192
|
+
detail = parsed.get("error") or parsed.get("detail") or parsed.get("message")
|
|
193
|
+
if isinstance(detail, dict):
|
|
194
|
+
detail = detail.get("message") or detail.get("detail")
|
|
195
|
+
if detail:
|
|
196
|
+
message = str(detail)
|
|
197
|
+
elif parsed.get("warnings"):
|
|
198
|
+
message = "; ".join(str(w) for w in parsed["warnings"])
|
|
199
|
+
except ValueError:
|
|
200
|
+
text = response.text.strip()
|
|
201
|
+
if text:
|
|
202
|
+
message = text[:2000]
|
|
203
|
+
parsed = text
|
|
204
|
+
return message, parsed
|
|
205
|
+
|
|
206
|
+
# ------------------------------------------------------------ pagination
|
|
207
|
+
def paginate(
|
|
208
|
+
self,
|
|
209
|
+
path: str,
|
|
210
|
+
*,
|
|
211
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
212
|
+
max_items: Optional[int] = None,
|
|
213
|
+
) -> Iterator[Any]:
|
|
214
|
+
"""Yield every record across all pages, following ``page.next_cursor``."""
|
|
215
|
+
params = dict(params or {})
|
|
216
|
+
yielded = 0
|
|
217
|
+
cursor: Optional[str] = params.get("cursor")
|
|
218
|
+
while True:
|
|
219
|
+
if cursor is not None:
|
|
220
|
+
params["cursor"] = cursor
|
|
221
|
+
page = self.request("GET", path, params=params)
|
|
222
|
+
for record in page:
|
|
223
|
+
yield record
|
|
224
|
+
yielded += 1
|
|
225
|
+
if max_items is not None and yielded >= max_items:
|
|
226
|
+
return
|
|
227
|
+
if not page.has_more or not page.next_cursor:
|
|
228
|
+
return
|
|
229
|
+
cursor = page.next_cursor
|
|
230
|
+
|
|
231
|
+
# --------------------------------------------------------------- cleanup
|
|
232
|
+
def close(self) -> None:
|
|
233
|
+
if self._owns_client:
|
|
234
|
+
self._client.close()
|
|
235
|
+
|
|
236
|
+
def __enter__(self) -> "HTTPClient":
|
|
237
|
+
return self
|
|
238
|
+
|
|
239
|
+
def __exit__(self, *exc: Any) -> None:
|
|
240
|
+
self.close()
|
clous/_models.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Lightweight response models for the Clous envelope.
|
|
2
|
+
|
|
3
|
+
Every Clous endpoint returns a JSON envelope::
|
|
4
|
+
|
|
5
|
+
{
|
|
6
|
+
"data": [...] | {...},
|
|
7
|
+
"page": {"limit": int, "next_cursor": str | None, "has_more": bool},
|
|
8
|
+
"as_of": "...",
|
|
9
|
+
"source": "...",
|
|
10
|
+
"query_echo": {...},
|
|
11
|
+
"warnings": [...]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
:class:`Page` wraps that envelope. It behaves like the ``data`` list for the
|
|
15
|
+
common case (iteration, indexing, ``len``) while still exposing the pagination
|
|
16
|
+
metadata and response headers.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Any, Dict, Iterator, List, Optional
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class PageInfo:
|
|
27
|
+
"""The ``page`` block of the envelope."""
|
|
28
|
+
|
|
29
|
+
limit: Optional[int] = None
|
|
30
|
+
next_cursor: Optional[str] = None
|
|
31
|
+
has_more: bool = False
|
|
32
|
+
raw: Dict[str, Any] = field(default_factory=dict)
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_dict(cls, d: Optional[Dict[str, Any]]) -> "PageInfo":
|
|
36
|
+
d = d or {}
|
|
37
|
+
return cls(
|
|
38
|
+
limit=d.get("limit"),
|
|
39
|
+
next_cursor=d.get("next_cursor"),
|
|
40
|
+
has_more=bool(d.get("has_more", False)),
|
|
41
|
+
raw=d,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Page:
|
|
46
|
+
"""A single page of results plus envelope metadata.
|
|
47
|
+
|
|
48
|
+
Iterating, indexing and ``len()`` operate over the ``data`` payload, so for
|
|
49
|
+
list endpoints you can treat a :class:`Page` like a list of records::
|
|
50
|
+
|
|
51
|
+
page = client.filings.search(form_type="8-K")
|
|
52
|
+
for filing in page:
|
|
53
|
+
print(filing["accession"])
|
|
54
|
+
first = page[0]
|
|
55
|
+
|
|
56
|
+
The envelope metadata stays available via attributes: ``page.page_info``,
|
|
57
|
+
``page.as_of``, ``page.source``, ``page.warnings``, ``page.query_echo``,
|
|
58
|
+
and the response headers via ``page.request_id`` / ``page.credits_cost`` /
|
|
59
|
+
``page.credits_remaining``.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, envelope: Dict[str, Any], headers: Optional[Dict[str, str]] = None) -> None:
|
|
63
|
+
self._envelope = envelope or {}
|
|
64
|
+
self._headers = headers or {}
|
|
65
|
+
self.data: Any = self._envelope.get("data")
|
|
66
|
+
self.page_info: PageInfo = PageInfo.from_dict(self._envelope.get("page"))
|
|
67
|
+
self.as_of: Optional[str] = self._envelope.get("as_of")
|
|
68
|
+
self.source: Optional[str] = self._envelope.get("source")
|
|
69
|
+
self.query_echo: Any = self._envelope.get("query_echo")
|
|
70
|
+
self.warnings: List[Any] = self._envelope.get("warnings") or []
|
|
71
|
+
|
|
72
|
+
# --- envelope helpers --------------------------------------------------
|
|
73
|
+
@property
|
|
74
|
+
def next_cursor(self) -> Optional[str]:
|
|
75
|
+
return self.page_info.next_cursor
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def has_more(self) -> bool:
|
|
79
|
+
return self.page_info.has_more
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def raw(self) -> Dict[str, Any]:
|
|
83
|
+
"""The full, unmodified envelope dict."""
|
|
84
|
+
return self._envelope
|
|
85
|
+
|
|
86
|
+
# --- response headers --------------------------------------------------
|
|
87
|
+
@property
|
|
88
|
+
def request_id(self) -> Optional[str]:
|
|
89
|
+
return self._headers.get("x-request-id")
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def credits_cost(self) -> Optional[str]:
|
|
93
|
+
return self._headers.get("x-credits-cost")
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def credits_remaining(self) -> Optional[str]:
|
|
97
|
+
return self._headers.get("x-credits-remaining")
|
|
98
|
+
|
|
99
|
+
# --- list-like access over data ---------------------------------------
|
|
100
|
+
def _as_list(self) -> List[Any]:
|
|
101
|
+
if isinstance(self.data, list):
|
|
102
|
+
return self.data
|
|
103
|
+
if self.data is None:
|
|
104
|
+
return []
|
|
105
|
+
return [self.data]
|
|
106
|
+
|
|
107
|
+
def __iter__(self) -> Iterator[Any]:
|
|
108
|
+
return iter(self._as_list())
|
|
109
|
+
|
|
110
|
+
def __len__(self) -> int:
|
|
111
|
+
return len(self._as_list())
|
|
112
|
+
|
|
113
|
+
def __getitem__(self, index: int) -> Any:
|
|
114
|
+
return self._as_list()[index]
|
|
115
|
+
|
|
116
|
+
def __bool__(self) -> bool:
|
|
117
|
+
return bool(self._as_list())
|
|
118
|
+
|
|
119
|
+
def __repr__(self) -> str: # pragma: no cover - cosmetic
|
|
120
|
+
n = len(self._as_list())
|
|
121
|
+
return f"<Page data={n} has_more={self.has_more} next_cursor={self.next_cursor!r}>"
|
clous/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
clous/client.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""The top-level :class:`Clous` client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Mapping, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from ._client import DEFAULT_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT, HTTPClient
|
|
10
|
+
from ._models import Page
|
|
11
|
+
from .resources import (
|
|
12
|
+
AdvisersResource,
|
|
13
|
+
BoardResource,
|
|
14
|
+
BrokerDealersResource,
|
|
15
|
+
CompensationResource,
|
|
16
|
+
CyberIncidentsResource,
|
|
17
|
+
EntitiesResource,
|
|
18
|
+
EnforcementResource,
|
|
19
|
+
EventsResource,
|
|
20
|
+
FilingsResource,
|
|
21
|
+
FinancialStatementsResource,
|
|
22
|
+
FinancialsResource,
|
|
23
|
+
FormCRSResource,
|
|
24
|
+
FullTextResource,
|
|
25
|
+
FundsResource,
|
|
26
|
+
HoldingsResource,
|
|
27
|
+
IAPDIndividualsResource,
|
|
28
|
+
InsiderResource,
|
|
29
|
+
LitigationResource,
|
|
30
|
+
ManagersResource,
|
|
31
|
+
MonitorsResource,
|
|
32
|
+
NTLateResource,
|
|
33
|
+
OwnershipResource,
|
|
34
|
+
PatentsResource,
|
|
35
|
+
PrivateFundStatsResource,
|
|
36
|
+
PrivateFundsResource,
|
|
37
|
+
ProxyResource,
|
|
38
|
+
RaisesResource,
|
|
39
|
+
TradingSuspensionsResource,
|
|
40
|
+
WebhooksResource,
|
|
41
|
+
WhistleblowerResource,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Clous:
|
|
46
|
+
"""Client for the Clous SEC/EDGAR API.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
api_key: Your Clous API key. Falls back to the ``CLOUS_API_KEY`` env var.
|
|
50
|
+
base_url: API base URL. Defaults to ``https://api.clous.ai`` (or the
|
|
51
|
+
``CLOUS_BASE_URL`` env var). The OpenAI-compatible base
|
|
52
|
+
``https://api.clous.ai/v1`` is also accepted — a trailing ``/v1`` is
|
|
53
|
+
normalized away so resource paths resolve correctly.
|
|
54
|
+
timeout: Per-request timeout in seconds (default 30).
|
|
55
|
+
max_retries: Retries on 429/5xx and transient network errors (default 3).
|
|
56
|
+
http_client: An existing ``httpx.Client`` to reuse (optional).
|
|
57
|
+
default_headers: Extra headers to send on every request.
|
|
58
|
+
|
|
59
|
+
Example::
|
|
60
|
+
|
|
61
|
+
from clous import Clous
|
|
62
|
+
|
|
63
|
+
client = Clous() # reads CLOUS_API_KEY
|
|
64
|
+
page = client.filings.search(form_type="8-K", limit=10)
|
|
65
|
+
for filing in page:
|
|
66
|
+
print(filing["accession"])
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
api_key: Optional[str] = None,
|
|
72
|
+
base_url: Optional[str] = None,
|
|
73
|
+
*,
|
|
74
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
75
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
76
|
+
http_client: Optional[httpx.Client] = None,
|
|
77
|
+
default_headers: Optional[Mapping[str, str]] = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
self._http = HTTPClient(
|
|
80
|
+
api_key=api_key,
|
|
81
|
+
base_url=base_url,
|
|
82
|
+
timeout=timeout,
|
|
83
|
+
max_retries=max_retries,
|
|
84
|
+
http_client=http_client,
|
|
85
|
+
default_headers=default_headers,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Filings & search
|
|
89
|
+
self.filings = FilingsResource(self._http)
|
|
90
|
+
self.full_text = FullTextResource(self._http)
|
|
91
|
+
self.entities = EntitiesResource(self._http)
|
|
92
|
+
|
|
93
|
+
# Ownership / institutional
|
|
94
|
+
self.insider = InsiderResource(self._http)
|
|
95
|
+
self.ownership = OwnershipResource(self._http)
|
|
96
|
+
self.holdings = HoldingsResource(self._http)
|
|
97
|
+
self.managers = ManagersResource(self._http)
|
|
98
|
+
self.funds = FundsResource(self._http)
|
|
99
|
+
|
|
100
|
+
# Advisers / private funds / brokers
|
|
101
|
+
self.advisers = AdvisersResource(self._http)
|
|
102
|
+
self.private_funds = PrivateFundsResource(self._http)
|
|
103
|
+
self.private_fund_stats = PrivateFundStatsResource(self._http)
|
|
104
|
+
self.broker_dealers = BrokerDealersResource(self._http)
|
|
105
|
+
self.form_crs = FormCRSResource(self._http)
|
|
106
|
+
self.iapd_individuals = IAPDIndividualsResource(self._http)
|
|
107
|
+
|
|
108
|
+
# Capital / financials
|
|
109
|
+
self.raises = RaisesResource(self._http)
|
|
110
|
+
self.financials = FinancialsResource(self._http)
|
|
111
|
+
self.financial_statements = FinancialStatementsResource(self._http)
|
|
112
|
+
|
|
113
|
+
# Governance & people
|
|
114
|
+
self.board = BoardResource(self._http)
|
|
115
|
+
self.compensation = CompensationResource(self._http)
|
|
116
|
+
self.proxy = ProxyResource(self._http)
|
|
117
|
+
|
|
118
|
+
# Enforcement / status / misc datasets
|
|
119
|
+
self.enforcement = EnforcementResource(self._http)
|
|
120
|
+
self.litigation = LitigationResource(self._http)
|
|
121
|
+
self.nt_late = NTLateResource(self._http)
|
|
122
|
+
self.trading_suspensions = TradingSuspensionsResource(self._http)
|
|
123
|
+
self.whistleblower = WhistleblowerResource(self._http)
|
|
124
|
+
self.cyber_incidents = CyberIncidentsResource(self._http)
|
|
125
|
+
self.patents = PatentsResource(self._http)
|
|
126
|
+
|
|
127
|
+
# Monitoring
|
|
128
|
+
self.events = EventsResource(self._http)
|
|
129
|
+
self.monitors = MonitorsResource(self._http)
|
|
130
|
+
self.webhooks = WebhooksResource(self._http)
|
|
131
|
+
|
|
132
|
+
# ------------------------------------------------------------------ meta
|
|
133
|
+
@property
|
|
134
|
+
def base_url(self) -> str:
|
|
135
|
+
return self._http.base_url
|
|
136
|
+
|
|
137
|
+
def account(self) -> Page:
|
|
138
|
+
"""Plan and remaining credits for the configured API key (``/v1/account``)."""
|
|
139
|
+
return self._http.request("GET", "/v1/account")
|
|
140
|
+
|
|
141
|
+
def sources(self) -> Page:
|
|
142
|
+
"""Dataset catalog + freshness (``/v1/sources``; no auth required)."""
|
|
143
|
+
return self._http.request("GET", "/v1/sources")
|
|
144
|
+
|
|
145
|
+
# ----------------------------------------------------------- grounded Q&A
|
|
146
|
+
def answer(
|
|
147
|
+
self,
|
|
148
|
+
q: str,
|
|
149
|
+
*,
|
|
150
|
+
cik: Optional[str] = None,
|
|
151
|
+
ticker: Optional[str] = None,
|
|
152
|
+
accession: Optional[str] = None,
|
|
153
|
+
forms: Optional[str] = None,
|
|
154
|
+
max_sources: Optional[int] = None,
|
|
155
|
+
output_schema: Optional[Mapping[str, Any]] = None,
|
|
156
|
+
**extra: Any,
|
|
157
|
+
) -> dict:
|
|
158
|
+
"""Grounded Q&A over SEC filings (``POST /v1/answer``).
|
|
159
|
+
|
|
160
|
+
Returns the raw JSON answer envelope (answer text + cited sources).
|
|
161
|
+
"""
|
|
162
|
+
body = {
|
|
163
|
+
k: v
|
|
164
|
+
for k, v in dict(
|
|
165
|
+
q=q, cik=cik, ticker=ticker, accession=accession, forms=forms,
|
|
166
|
+
max_sources=max_sources, output_schema=output_schema, **extra,
|
|
167
|
+
).items()
|
|
168
|
+
if v is not None
|
|
169
|
+
}
|
|
170
|
+
return self._http.request("POST", "/v1/answer", body=body, raw=True)
|
|
171
|
+
|
|
172
|
+
def briefing(self, accession: str, **extra: Any) -> Page:
|
|
173
|
+
"""AI briefing for one filing (``/v1/filings/{accession}/briefing``)."""
|
|
174
|
+
return self.filings.briefing(accession, **extra)
|
|
175
|
+
|
|
176
|
+
# -------------------------------------------------- OpenAI-compatible chat
|
|
177
|
+
def chat(self, *, model: str = "clous", messages: list, **kwargs: Any) -> dict:
|
|
178
|
+
"""OpenAI-compatible chat completion (``POST /v1/chat/completions``).
|
|
179
|
+
|
|
180
|
+
Returns the raw OpenAI-style completion JSON. For drop-in use with the
|
|
181
|
+
``openai`` SDK instead, point it at ``base_url="https://api.clous.ai/v1"``
|
|
182
|
+
with ``model="clous"``.
|
|
183
|
+
"""
|
|
184
|
+
body = {"model": model, "messages": messages, **kwargs}
|
|
185
|
+
return self._http.request("POST", "/v1/chat/completions", body=body, raw=True)
|
|
186
|
+
|
|
187
|
+
# --------------------------------------------------------------- low-level
|
|
188
|
+
def get(self, path: str, *, params: Optional[Mapping[str, Any]] = None, raw: bool = False) -> Any:
|
|
189
|
+
"""Escape hatch: issue a raw GET against any API path."""
|
|
190
|
+
return self._http.request("GET", path, params=params, raw=raw)
|
|
191
|
+
|
|
192
|
+
def post(self, path: str, *, body: Any = None, raw: bool = False) -> Any:
|
|
193
|
+
"""Escape hatch: issue a raw POST against any API path."""
|
|
194
|
+
return self._http.request("POST", path, body=body, raw=raw)
|
|
195
|
+
|
|
196
|
+
# --------------------------------------------------------------- lifecycle
|
|
197
|
+
def close(self) -> None:
|
|
198
|
+
self._http.close()
|
|
199
|
+
|
|
200
|
+
def __enter__(self) -> "Clous":
|
|
201
|
+
return self
|
|
202
|
+
|
|
203
|
+
def __exit__(self, *exc: Any) -> None:
|
|
204
|
+
self.close()
|