arkleon 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.
arkleon/__init__.py ADDED
@@ -0,0 +1,48 @@
1
+ """Free EDGAR core imported by default; api and mcp are optional extras."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ from .edgar import (
8
+ AS_OF_WARNING,
9
+ Company,
10
+ EdgarClient,
11
+ Fact,
12
+ FactSet,
13
+ Filing,
14
+ MissingUserAgentError,
15
+ RateLimiter,
16
+ as_of,
17
+ build_ticker_map,
18
+ cik_to_int,
19
+ facts_to_dataframe,
20
+ normalize_cik,
21
+ parse_company_concept,
22
+ parse_company_facts,
23
+ parse_fsds_zip,
24
+ parse_submissions,
25
+ resolve_user_agent,
26
+ )
27
+
28
+ __all__ = [
29
+ "AS_OF_WARNING",
30
+ "Company",
31
+ "EdgarClient",
32
+ "Fact",
33
+ "FactSet",
34
+ "Filing",
35
+ "MissingUserAgentError",
36
+ "RateLimiter",
37
+ "as_of",
38
+ "build_ticker_map",
39
+ "cik_to_int",
40
+ "facts_to_dataframe",
41
+ "normalize_cik",
42
+ "parse_company_concept",
43
+ "parse_company_facts",
44
+ "parse_fsds_zip",
45
+ "parse_submissions",
46
+ "resolve_user_agent",
47
+ "__version__",
48
+ ]
@@ -0,0 +1,49 @@
1
+ """Optional paid /v1 client for the Arkleon Data API.
2
+
3
+ Component (b) of the arkleon package (spec section 6). This subpackage is
4
+ always importable because its HTTP dependency (httpx) is a base dependency;
5
+ there is no import gate. The paid gate is a RUNTIME requirement instead: the
6
+ client refuses to construct without an ak_-prefixed key (contract section 6.2),
7
+ resolved from the constructor argument or the ARKLEON_API_KEY environment
8
+ variable.
9
+
10
+ Nothing here activates without a key. Installing the client does not activate
11
+ the paid path; a key must still be supplied at runtime.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from .client import DataClient
17
+ from .errors import (
18
+ ArkleonAPIError,
19
+ AuthError,
20
+ ConflictError,
21
+ NotFoundError,
22
+ NotServedError,
23
+ RateLimitError,
24
+ RequestError,
25
+ ScopeError,
26
+ ServerError,
27
+ UnprocessableError,
28
+ error_from_envelope,
29
+ )
30
+ from .errors import NotImplementedError # noqa: A004 (contract alias, spec section 6.5)
31
+ from .pagination import Page
32
+
33
+ __all__ = [
34
+ "DataClient",
35
+ "Page",
36
+ "error_from_envelope",
37
+ "ArkleonAPIError",
38
+ "AuthError",
39
+ "ScopeError",
40
+ "RequestError",
41
+ "NotFoundError",
42
+ "ConflictError",
43
+ "UnprocessableError",
44
+ "RateLimitError",
45
+ "NotServedError",
46
+ "ServerError",
47
+ # Alias exported per spec section 6.5; primary name is NotServedError.
48
+ "NotImplementedError",
49
+ ]
arkleon/api/auth.py ADDED
@@ -0,0 +1,58 @@
1
+ """The paid gate: ak_ Bearer key resolution.
2
+
3
+ The key is read from an explicit argument or, failing that, the
4
+ ``ARKLEON_API_KEY`` environment variable (contract section 6.2). The client
5
+ refuses to operate without an ak_-prefixed key, raising locally before any
6
+ network contact so an unauthenticated request is never sent. Keys are
7
+ presented, never stored or logged: nothing here writes the key to a file, a
8
+ log line, or an exception message.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+
15
+ from .errors import AuthError
16
+
17
+ __all__ = ["resolve_api_key", "optional_api_key", "API_KEY_ENV_VAR", "API_KEY_PREFIX"]
18
+
19
+ API_KEY_ENV_VAR = "ARKLEON_API_KEY"
20
+ API_KEY_PREFIX = "ak_"
21
+
22
+
23
+ def resolve_api_key(explicit: str | None) -> str:
24
+ """Return an ak_-prefixed key or raise AuthError locally.
25
+
26
+ Resolution order: the explicit argument, then ``ARKLEON_API_KEY``. The key
27
+ is required and validated for the ak_ prefix here, before any request, so
28
+ the client fails closed rather than issuing a silent unauthenticated call
29
+ (contract section 6.2). The raised message never contains the key value.
30
+ """
31
+ key = explicit if explicit is not None else os.environ.get(API_KEY_ENV_VAR)
32
+ if not isinstance(key, str) or not key.strip():
33
+ raise AuthError(
34
+ "An Arkleon /v1 API key is required. Pass api_key=... or set the "
35
+ f"{API_KEY_ENV_VAR} environment variable to an {API_KEY_PREFIX}-prefixed key.",
36
+ error="unauthorized",
37
+ )
38
+ key = key.strip()
39
+ if not key.startswith(API_KEY_PREFIX):
40
+ raise AuthError(
41
+ f"Arkleon /v1 API keys must be {API_KEY_PREFIX}-prefixed. The supplied "
42
+ "value is not a valid key.",
43
+ error="unauthorized",
44
+ )
45
+ return key
46
+
47
+
48
+ def optional_api_key(explicit: str | None) -> str | None:
49
+ """Return a resolvable ak_ key, or None when none is available.
50
+
51
+ A soft companion to resolve_api_key for callers that must not raise when no
52
+ key is present, such as the MCP server deciding whether to register the paid
53
+ tools (spec section 7.1). It never raises and never logs the key.
54
+ """
55
+ try:
56
+ return resolve_api_key(explicit)
57
+ except AuthError:
58
+ return None
arkleon/api/client.py ADDED
@@ -0,0 +1,378 @@
1
+ """Thin, faithful client for the Arkleon /v1 Data API.
2
+
3
+ This client adds no semantics the contract at
4
+ ``docs/data-layer/api-v1-contract.md`` does not define. It is a transport
5
+ wrapper: it presents an ak_ key on every request (the paid gate, section 6.2),
6
+ enforces the caller-preventable validations the contract specifies locally,
7
+ maps the closed error alphabet to typed exceptions (section 4.2), paginates by
8
+ opaque cursor while keeping as_of stable (section 2.1.3), and exposes only the
9
+ public /v1 vocabulary on returned facts, never the internal FSDS or ingest
10
+ telemetry columns (section 3.1.1).
11
+
12
+ The value this client has over the free EDGAR core's best-effort as_of filter
13
+ is the corpus's permanent-reproducibility guarantee (contract section 3.2):
14
+ identical query plus identical as_of returns identical data, permanently. The
15
+ client does nothing to undermine that: it never caches then serves a stale
16
+ value as if it were authoritative, and it never substitutes "today" for a
17
+ missing as_of.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import time
23
+ from collections.abc import Iterator
24
+ from typing import Any
25
+
26
+ import httpx
27
+
28
+ from .auth import resolve_api_key
29
+ from .errors import RateLimitError, RequestError, error_from_envelope
30
+ from .pagination import Page, paginate
31
+
32
+ __all__ = ["DataClient"]
33
+
34
+ DEFAULT_BASE_URL = "https://api.arkleon.com/v1"
35
+
36
+ # Internal ingest telemetry the contract forbids on /v1 (section 3.1.1). The
37
+ # server MUST NOT emit these; the client strips them as defense in depth so a
38
+ # non-conforming server can never leak them to a caller.
39
+ _INTERNAL_FIELDS = frozenset({"created_via", "ingested_at", "run_id", "accepted"})
40
+
41
+ # Ceiling on a single honored Retry-After sleep, so a pathological or hostile
42
+ # Retry-After value cannot block a caller unboundedly.
43
+ _MAX_RETRY_AFTER_SLEEP_SECONDS = 120.0
44
+
45
+
46
+ def _strip_internal(record: Any) -> Any:
47
+ """Remove forbidden internal telemetry fields from one record."""
48
+ if isinstance(record, dict):
49
+ return {key: value for key, value in record.items() if key not in _INTERNAL_FIELDS}
50
+ return record
51
+
52
+
53
+ class DataClient:
54
+ """Client for the three /v1 endpoints: facts, filings, companies."""
55
+
56
+ def __init__(
57
+ self,
58
+ api_key: str | None = None,
59
+ *,
60
+ base_url: str = DEFAULT_BASE_URL,
61
+ timeout: float = 30.0,
62
+ client: httpx.Client | None = None,
63
+ max_rate_limit_retries: int = 2,
64
+ ) -> None:
65
+ """Construct the client and resolve the paid gate immediately.
66
+
67
+ ``api_key`` (or ARKLEON_API_KEY) must be an ak_-prefixed key or
68
+ construction raises AuthError locally, before any network contact
69
+ (contract section 6.2). ``base_url`` must be https. Pass ``client`` to
70
+ inject a preconfigured httpx.Client (for tests or custom transports);
71
+ otherwise one is created and owned by this instance.
72
+ ``max_rate_limit_retries`` bounds how many times a 429 is retried while
73
+ honoring the server's Retry-After (contract section 1.3).
74
+ """
75
+ # Fail closed at construction: no client exists without a valid key.
76
+ self._api_key = resolve_api_key(api_key)
77
+ if not base_url.lower().startswith("https://"):
78
+ raise ValueError("base_url must be https; /v1 is HTTPS only (contract section 6.1).")
79
+ self._base_url = base_url.rstrip("/")
80
+ self._max_rate_limit_retries = max(0, int(max_rate_limit_retries))
81
+ self._owns_client = client is None
82
+ self._client = client if client is not None else httpx.Client(timeout=timeout)
83
+
84
+ # -- lifecycle ---------------------------------------------------------
85
+
86
+ def close(self) -> None:
87
+ """Close the underlying httpx client if this instance owns it."""
88
+ if self._owns_client:
89
+ self._client.close()
90
+
91
+ def __enter__(self) -> DataClient:
92
+ return self
93
+
94
+ def __exit__(self, *exc: object) -> None:
95
+ self.close()
96
+
97
+ # -- transport ---------------------------------------------------------
98
+
99
+ def _headers(self) -> dict[str, str]:
100
+ # The key is presented on every request and never logged (section 6.2).
101
+ return {
102
+ "Authorization": f"Bearer {self._api_key}",
103
+ "Accept": "application/json",
104
+ }
105
+
106
+ @staticmethod
107
+ def _clean_params(params: dict[str, Any]) -> dict[str, Any]:
108
+ # Drop unset filters so the server sees only supplied values. The cursor
109
+ # is passed through untouched and never parsed (section 2.1.3).
110
+ return {key: value for key, value in params.items() if value is not None}
111
+
112
+ def _request(self, path: str, params: dict[str, Any]) -> dict[str, Any]:
113
+ url = f"{self._base_url}{path}"
114
+ cleaned = self._clean_params(params)
115
+ attempts = self._max_rate_limit_retries + 1
116
+ for attempt in range(attempts):
117
+ response = self._client.get(url, params=cleaned, headers=self._headers())
118
+ if response.status_code < 400:
119
+ return self._parse_success(response)
120
+ exception = self._exception_for(response)
121
+ if (
122
+ isinstance(exception, RateLimitError)
123
+ and attempt < attempts - 1
124
+ and exception.retry_after is not None
125
+ ):
126
+ # Honor the server's Retry-After rather than a fixed schedule
127
+ # (contract section 1.3), capped to avoid unbounded blocking.
128
+ time.sleep(min(float(exception.retry_after), _MAX_RETRY_AFTER_SLEEP_SECONDS))
129
+ continue
130
+ raise exception
131
+ # Unreachable: the loop either returns or raises on the final attempt.
132
+ raise RuntimeError("unreachable pagination retry loop")
133
+
134
+ def _exception_for(self, response: httpx.Response) -> Exception:
135
+ try:
136
+ body = response.json()
137
+ except ValueError:
138
+ body = {}
139
+ if not isinstance(body, dict):
140
+ body = {}
141
+ exception = error_from_envelope(response.status_code, body)
142
+ # If the 429 envelope did not carry retry_after, take it from the
143
+ # mandatory Retry-After header (contract section 1.3).
144
+ if isinstance(exception, RateLimitError) and exception.retry_after is None:
145
+ header = response.headers.get("Retry-After")
146
+ if header is not None:
147
+ try:
148
+ exception.retry_after = float(header)
149
+ except ValueError:
150
+ exception.retry_after = None
151
+ return exception
152
+
153
+ def _parse_success(self, response: httpx.Response) -> dict[str, Any]:
154
+ try:
155
+ payload = response.json()
156
+ except ValueError as error:
157
+ raise error_from_envelope(
158
+ 502, {"error": "internal_error", "message": "Malformed response body"}
159
+ ) from error
160
+ if not isinstance(payload, dict):
161
+ raise error_from_envelope(
162
+ 502, {"error": "internal_error", "message": "Malformed response body"}
163
+ )
164
+ data = payload.get("data", [])
165
+ if not isinstance(data, list):
166
+ data = []
167
+ payload["data"] = [_strip_internal(record) for record in data]
168
+ return payload
169
+
170
+ def _page(self, path: str, params: dict[str, Any], *, as_of: str | None) -> Page:
171
+ payload = self._request(path, params)
172
+ next_cursor = payload.get("next_cursor")
173
+ next_cursor = next_cursor if isinstance(next_cursor, str) else None
174
+ request_context = {
175
+ key: value for key, value in self._clean_params(params).items() if key != "cursor"
176
+ }
177
+ return Page(
178
+ data=list(payload["data"]),
179
+ next_cursor=next_cursor,
180
+ as_of=as_of,
181
+ request=request_context,
182
+ )
183
+
184
+ # -- /v1/facts ---------------------------------------------------------
185
+
186
+ def facts(
187
+ self,
188
+ *,
189
+ as_of: str,
190
+ cik: int | None = None,
191
+ tag: str | None = None,
192
+ taxonomy: str | None = None,
193
+ period_start: str | None = None,
194
+ period_end: str | None = None,
195
+ duration_quarters: int | None = None,
196
+ unit: str | None = None,
197
+ form: str | None = None,
198
+ ticker: str | None = None,
199
+ limit: int = 100,
200
+ cursor: str | None = None,
201
+ ) -> Page:
202
+ """GET /v1/facts: certified point-in-time numeric facts.
203
+
204
+ ``as_of`` is REQUIRED and has NO default. It filters on the FILING date
205
+ (``filed <= as_of``), never on the period a fact describes: a fact from
206
+ a filing submitted after as_of never appears, regardless of the period
207
+ it reports (contract section 2.1.1). Omitting as_of raises RequestError
208
+ locally, before any request: the client MUST NOT substitute "today,"
209
+ because the caller who forgot as_of is the caller who most needs to be
210
+ stopped. Use period_start / period_end to bound the fact's period.
211
+
212
+ ``cik`` and ``ticker`` MUST NOT both be supplied (contract section
213
+ 2.1.2); doing so raises RequestError locally. ``ticker`` is not served
214
+ in v1: the request is forwarded and the server's 501 surfaces as
215
+ NotServedError, never a silent free-core resolution (contract section
216
+ 2.3.1, spec section 6.4). Address facts by ``cik``.
217
+ """
218
+ if as_of is None:
219
+ raise RequestError(
220
+ "as_of is required on facts() and has no default; the client will "
221
+ "not substitute today (contract section 2.1.1).",
222
+ error="invalid_request",
223
+ )
224
+ if cik is not None and ticker is not None:
225
+ raise RequestError(
226
+ "cik and ticker must not both be supplied (contract section 2.1.2).",
227
+ error="invalid_request",
228
+ )
229
+ params: dict[str, Any] = {
230
+ "as_of": as_of,
231
+ "cik": cik,
232
+ "tag": tag,
233
+ "taxonomy": taxonomy,
234
+ "period_start": period_start,
235
+ "period_end": period_end,
236
+ "duration_quarters": duration_quarters,
237
+ "unit": unit,
238
+ "form": form,
239
+ "ticker": ticker,
240
+ "limit": limit,
241
+ "cursor": cursor,
242
+ }
243
+ return self._page("/facts", params, as_of=as_of)
244
+
245
+ def facts_iter(self, **kwargs: Any) -> Iterator[dict[str, Any]]:
246
+ """Iterate every fact across pages, resending the same as_of.
247
+
248
+ Accepts the same keyword arguments as facts(). The same as_of is
249
+ resent on every page so the result set stays as_of-stable; a mismatched
250
+ as_of on a cursor request is a 400 the iterator prevents by
251
+ construction (contract section 2.1.3). Any incoming ``cursor`` is
252
+ ignored: iteration always starts from the beginning.
253
+ """
254
+ kwargs.pop("cursor", None)
255
+
256
+ def fetch(cursor: str | None) -> Page:
257
+ return self.facts(cursor=cursor, **kwargs)
258
+
259
+ return paginate(fetch)
260
+
261
+ # -- /v1/filings -------------------------------------------------------
262
+
263
+ def filings(
264
+ self,
265
+ *,
266
+ as_of: str | None = None,
267
+ cik: int | None = None,
268
+ ticker: str | None = None,
269
+ form: str | None = None,
270
+ filed_start: str | None = None,
271
+ filed_end: str | None = None,
272
+ period_start: str | None = None,
273
+ period_end: str | None = None,
274
+ limit: int = 100,
275
+ cursor: str | None = None,
276
+ ) -> Page:
277
+ """GET /v1/filings: submissions metadata.
278
+
279
+ ``as_of`` is optional here and means the same thing: return only filings
280
+ filed on or before the date (contract section 2.2). ``ticker`` is not
281
+ served in v1; the server's 501 surfaces as NotServedError. ``cik`` and
282
+ ``ticker`` MUST NOT both be supplied.
283
+ """
284
+ if cik is not None and ticker is not None:
285
+ raise RequestError(
286
+ "cik and ticker must not both be supplied (contract section 2.1.2).",
287
+ error="invalid_request",
288
+ )
289
+ params: dict[str, Any] = {
290
+ "as_of": as_of,
291
+ "cik": cik,
292
+ "ticker": ticker,
293
+ "form": form,
294
+ "filed_start": filed_start,
295
+ "filed_end": filed_end,
296
+ "period_start": period_start,
297
+ "period_end": period_end,
298
+ "limit": limit,
299
+ "cursor": cursor,
300
+ }
301
+ return self._page("/filings", params, as_of=as_of)
302
+
303
+ def filings_iter(self, **kwargs: Any) -> Iterator[dict[str, Any]]:
304
+ """Iterate every filing across pages, resending the same as_of."""
305
+ kwargs.pop("cursor", None)
306
+
307
+ def fetch(cursor: str | None) -> Page:
308
+ return self.filings(cursor=cursor, **kwargs)
309
+
310
+ return paginate(fetch)
311
+
312
+ # -- /v1/companies -----------------------------------------------------
313
+
314
+ def companies(
315
+ self,
316
+ *,
317
+ cik: int | None = None,
318
+ name: str | None = None,
319
+ ticker: str | None = None,
320
+ limit: int = 100,
321
+ cursor: str | None = None,
322
+ ) -> Page:
323
+ """GET /v1/companies: identifier resolution, cik- and name-addressable.
324
+
325
+ In v1 the endpoint is cik- and name-addressable only. ``ticker`` is not
326
+ served and the server's 501 surfaces as NotServedError; resolution rules
327
+ 1 to 4 of the contract describe the endpoint's completed form and are
328
+ dormant until a persisted point-in-time ticker to CIK mapping exists
329
+ (contract section 2.3.1). ``cik`` and ``ticker`` MUST NOT both be
330
+ supplied.
331
+ """
332
+ if cik is not None and ticker is not None:
333
+ raise RequestError(
334
+ "cik and ticker must not both be supplied (contract section 2.1.2).",
335
+ error="invalid_request",
336
+ )
337
+ params: dict[str, Any] = {
338
+ "cik": cik,
339
+ "name": name,
340
+ "ticker": ticker,
341
+ "limit": limit,
342
+ "cursor": cursor,
343
+ }
344
+ return self._page("/companies", params, as_of=None)
345
+
346
+ # -- opt-in, non-point-in-time convenience -----------------------------
347
+
348
+ def facts_by_ticker_via_edgar_snapshot(
349
+ self,
350
+ *,
351
+ ticker: str,
352
+ as_of: str,
353
+ user_agent: str | None = None,
354
+ **facts_kwargs: Any,
355
+ ) -> Page:
356
+ """Resolve a ticker to a CIK via the free EDGAR snapshot, then query facts.
357
+
358
+ WARNING: OPT-IN, CURRENT-SNAPSHOT, NON-POINT-IN-TIME. This convenience
359
+ resolves ``ticker`` through SEC's current company_tickers.json snapshot,
360
+ which has no history: a ticker string is reassigned over time and one
361
+ CIK carries several symbols at once. The resolved CIK reflects TODAY's
362
+ assignment, not the assignment in effect on ``as_of``. Using it in a
363
+ backtest can silently hold the wrong company, which is exactly the
364
+ failure the contract's 501-on-ticker rule exists to prevent (contract
365
+ section 2.3.1, spec section 6.4).
366
+
367
+ The default facts() path NEVER does this. This method exists only for a
368
+ caller who asks for it by name and accepts the non-point-in-time
369
+ resolution. Prefer addressing facts by ``cik`` directly.
370
+ """
371
+ # arkleon.api MAY depend on arkleon.edgar; the boundary is one
372
+ # directional (edgar never imports api). Imported lazily so the paid
373
+ # client does not pull the free core unless this method is called.
374
+ from arkleon.edgar import EdgarClient
375
+
376
+ edgar = EdgarClient(user_agent=user_agent)
377
+ cik = edgar.resolve_cik(ticker)
378
+ return self.facts(as_of=as_of, cik=cik, **facts_kwargs)