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 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()