david-data 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.
david_data/__init__.py ADDED
@@ -0,0 +1,41 @@
1
+ """David Data — official Python client for the David Data financial-data API.
2
+
3
+ from david_data import DavidData
4
+
5
+ dd = DavidData(api_key="...")
6
+ prices = dd.prices.get("AAPL", start_date="2024-01-01")
7
+
8
+ See https://api.davidhf.com/docs for the full endpoint reference.
9
+ """
10
+
11
+ from ._pandas import to_df
12
+ from ._version import __version__
13
+ from .client import DavidData
14
+ from .errors import (
15
+ APIConnectionError,
16
+ APIStatusError,
17
+ APITimeoutError,
18
+ AuthenticationError,
19
+ BadRequestError,
20
+ DavidDataError,
21
+ NotFoundError,
22
+ PermissionDeniedError,
23
+ RateLimitError,
24
+ ServerError,
25
+ )
26
+
27
+ __all__ = [
28
+ "DavidData",
29
+ "to_df",
30
+ "__version__",
31
+ "DavidDataError",
32
+ "APIConnectionError",
33
+ "APITimeoutError",
34
+ "APIStatusError",
35
+ "BadRequestError",
36
+ "AuthenticationError",
37
+ "PermissionDeniedError",
38
+ "NotFoundError",
39
+ "RateLimitError",
40
+ "ServerError",
41
+ ]
david_data/_http.py ADDED
@@ -0,0 +1,172 @@
1
+ """Low-level transport: auth header, retries with backoff, error mapping.
2
+
3
+ This module is intentionally small and dependency-light (httpx only). The public
4
+ :class:`~david_data.client.DavidData` wraps it and exposes the resource groups.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import time
11
+ from typing import Any, Mapping, Optional, Union
12
+ from urllib.parse import urljoin
13
+
14
+ import httpx
15
+
16
+ from ._version import __version__
17
+ from .errors import (
18
+ APIConnectionError,
19
+ APITimeoutError,
20
+ DavidDataError,
21
+ error_for_status,
22
+ )
23
+
24
+ DEFAULT_BASE_URL = "https://api.davidhf.com"
25
+ ENV_API_KEY = "DAVID_DATA_API_KEY"
26
+ ENV_BASE_URL = "DAVID_DATA_BASE_URL"
27
+
28
+ # Status codes worth retrying: rate limits and transient server faults.
29
+ _RETRY_STATUS = frozenset({429, 500, 502, 503, 504})
30
+
31
+
32
+ class Transport:
33
+ """Owns the httpx client, applies auth, and retries idempotent failures."""
34
+
35
+ def __init__(
36
+ self,
37
+ api_key: Optional[str],
38
+ *,
39
+ base_url: Optional[str] = None,
40
+ timeout: float = 30.0,
41
+ max_retries: int = 3,
42
+ http_client: Optional[httpx.Client] = None,
43
+ ) -> None:
44
+ api_key = api_key or os.environ.get(ENV_API_KEY)
45
+ if not api_key:
46
+ raise DavidDataError(
47
+ "No API key provided. Pass api_key=... or set the "
48
+ f"{ENV_API_KEY} environment variable. Get a key at https://davidhf.com."
49
+ )
50
+ base_url = (base_url or os.environ.get(ENV_BASE_URL) or DEFAULT_BASE_URL).rstrip("/") + "/"
51
+ self.api_key = api_key
52
+ self.base_url = base_url
53
+ self.max_retries = max(0, int(max_retries))
54
+ self._owns_client = http_client is None
55
+ self._client = http_client or httpx.Client(timeout=timeout)
56
+ self._headers = {
57
+ "X-API-KEY": api_key,
58
+ "Accept": "application/json",
59
+ "User-Agent": f"david-data-python/{__version__}",
60
+ }
61
+
62
+ # -- lifecycle ---------------------------------------------------------
63
+ def close(self) -> None:
64
+ if self._owns_client:
65
+ self._client.close()
66
+
67
+ def __enter__(self) -> "Transport":
68
+ return self
69
+
70
+ def __exit__(self, *exc: Any) -> None:
71
+ self.close()
72
+
73
+ # -- requests ----------------------------------------------------------
74
+ def request(
75
+ self,
76
+ method: str,
77
+ path: str,
78
+ *,
79
+ params: Optional[Mapping[str, Any]] = None,
80
+ json: Any = None,
81
+ ) -> Any:
82
+ url = urljoin(self.base_url, path.lstrip("/"))
83
+ clean_params = _clean_params(params) if params else None
84
+
85
+ last_exc: Optional[Exception] = None
86
+ for attempt in range(self.max_retries + 1):
87
+ try:
88
+ response = self._client.request(
89
+ method, url, params=clean_params, json=json, headers=self._headers
90
+ )
91
+ except httpx.TimeoutException as exc:
92
+ last_exc = APITimeoutError(f"Request to {url} timed out: {exc}")
93
+ except httpx.HTTPError as exc:
94
+ last_exc = APIConnectionError(f"Could not reach {url}: {exc}")
95
+ else:
96
+ if response.status_code in _RETRY_STATUS and attempt < self.max_retries:
97
+ time.sleep(_retry_delay(response, attempt))
98
+ continue
99
+ return _handle_response(response)
100
+
101
+ if attempt < self.max_retries:
102
+ time.sleep(_backoff(attempt))
103
+ assert last_exc is not None
104
+ raise last_exc
105
+
106
+
107
+ def _handle_response(response: httpx.Response) -> Any:
108
+ request_id = response.headers.get("x-request-id")
109
+ if response.is_success:
110
+ if not response.content:
111
+ return None
112
+ try:
113
+ return response.json()
114
+ except ValueError:
115
+ return response.text
116
+ body = _safe_body(response)
117
+ raise error_for_status(
118
+ response.status_code,
119
+ body=body,
120
+ request_id=request_id,
121
+ retry_after=_parse_retry_after(response),
122
+ )
123
+
124
+
125
+ def _safe_body(response: httpx.Response) -> Any:
126
+ try:
127
+ return response.json()
128
+ except ValueError:
129
+ return response.text
130
+
131
+
132
+ def _retry_delay(response: httpx.Response, attempt: int) -> float:
133
+ retry_after = _parse_retry_after(response)
134
+ if retry_after is not None:
135
+ return retry_after
136
+ return _backoff(attempt)
137
+
138
+
139
+ def _parse_retry_after(response: httpx.Response) -> Optional[float]:
140
+ raw = response.headers.get("retry-after")
141
+ if not raw:
142
+ return None
143
+ try:
144
+ return max(0.0, float(raw))
145
+ except ValueError:
146
+ return None
147
+
148
+
149
+ def _backoff(attempt: int) -> float:
150
+ # Exponential backoff, capped. attempt is 0-based.
151
+ return min(8.0, 0.5 * (2 ** attempt))
152
+
153
+
154
+ def _clean_params(params: Mapping[str, Any]) -> dict:
155
+ """Drop None values and normalise dates/bools so callers can pass natural types."""
156
+ out: dict[str, Any] = {}
157
+ for key, value in params.items():
158
+ if value is None:
159
+ continue
160
+ out[key] = _coerce(value)
161
+ return out
162
+
163
+
164
+ def _coerce(value: Any) -> Union[str, int, float, list]:
165
+ if isinstance(value, bool):
166
+ return "true" if value else "false"
167
+ # date / datetime -> ISO string without importing datetime eagerly.
168
+ if hasattr(value, "isoformat") and not isinstance(value, (str, bytes)):
169
+ return value.isoformat()
170
+ if isinstance(value, (list, tuple)):
171
+ return [_coerce(v) for v in value]
172
+ return value
david_data/_pandas.py ADDED
@@ -0,0 +1,27 @@
1
+ """Optional pandas helpers. pandas is only imported when these are called."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ def to_df(data: Any): # noqa: ANN401 - returns a pandas.DataFrame
9
+ """Convert an API result into a :class:`pandas.DataFrame`.
10
+
11
+ Accepts a list of records (the common case — prices, statements, news, …)
12
+ or a single dict. Requires the ``pandas`` extra: ``pip install david-data[pandas]``.
13
+ """
14
+ try:
15
+ import pandas as pd
16
+ except ImportError as exc: # pragma: no cover - exercised via extra
17
+ raise ImportError(
18
+ "pandas is required for to_df(). Install it with: pip install david-data[pandas]"
19
+ ) from exc
20
+
21
+ if isinstance(data, dict):
22
+ # A list-bearing envelope that slipped through, else a single record.
23
+ list_values = [v for v in data.values() if isinstance(v, list)]
24
+ if len(list_values) == 1:
25
+ return pd.DataFrame(list_values[0])
26
+ return pd.DataFrame([data])
27
+ return pd.DataFrame(data)
david_data/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
david_data/client.py ADDED
@@ -0,0 +1,125 @@
1
+ """The David Data client.
2
+
3
+ from david_data import DavidData
4
+
5
+ dd = DavidData(api_key="...") # or set DAVID_DATA_API_KEY
6
+ scenario = dd.scenarios.list(limit=1)[0]["id"]
7
+ bars = dd.prices.get("AAPL", scenario_id=scenario)
8
+
9
+ Every data call is keyed by a ``scenario_id`` (a synthetic world). Discover them
10
+ with ``dd.scenarios.list()``. Either pass ``scenario_id=`` per call, or set a
11
+ default once with ``DavidData(..., scenario_id="my-scenario")`` and omit it
12
+ thereafter. Calling a data endpoint with no scenario_id (and no client default)
13
+ raises a clear error rather than guessing.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any, Optional
19
+
20
+ import httpx
21
+
22
+ from . import resources as _r
23
+ from ._http import Transport
24
+ from ._version import __version__
25
+
26
+
27
+ class DavidData:
28
+ def __init__(
29
+ self,
30
+ api_key: Optional[str] = None,
31
+ *,
32
+ scenario_id: Optional[str] = None,
33
+ base_url: Optional[str] = None,
34
+ timeout: float = 30.0,
35
+ max_retries: int = 3,
36
+ http_client: Optional[httpx.Client] = None,
37
+ ) -> None:
38
+ """Create a client.
39
+
40
+ Args:
41
+ api_key: Your David Data API key. Falls back to the
42
+ ``DAVID_DATA_API_KEY`` environment variable.
43
+ scenario_id: Default scenario applied to every data call when you
44
+ don't pass one explicitly. Leave it ``None`` to require an
45
+ explicit ``scenario_id=`` on each call. Discover ids with
46
+ ``client.scenarios.list()``.
47
+ base_url: API root. Defaults to ``DAVID_DATA_BASE_URL`` or
48
+ ``https://api.davidhf.com``.
49
+ timeout: Per-request timeout in seconds.
50
+ max_retries: Retries for rate limits (429) and transient 5xx errors,
51
+ honouring the server's ``Retry-After`` header.
52
+ http_client: Bring your own configured ``httpx.Client`` (proxies,
53
+ custom transport, etc.). The SDK will not close a client you pass.
54
+ """
55
+ self._t = Transport(
56
+ api_key,
57
+ base_url=base_url,
58
+ timeout=timeout,
59
+ max_retries=max_retries,
60
+ http_client=http_client,
61
+ )
62
+ self.scenario_id = scenario_id
63
+
64
+ s = scenario_id
65
+ t = self._t
66
+ #: OHLCV prices and snapshots.
67
+ self.prices = _r.Prices(t, s)
68
+ #: Financial statements, metrics, segments, KPIs, and the screener.
69
+ self.financials = _r.Financials(t, s)
70
+ #: Company directory, facts, and identifier lookups.
71
+ self.company = _r.Company(t, s)
72
+ #: News articles.
73
+ self.news = _r.News(t, s)
74
+ #: SEC-style filings and item search.
75
+ self.filings = _r.Filings(t, s)
76
+ #: Reported earnings and the earnings calendar.
77
+ self.earnings = _r.Earnings(t, s)
78
+ #: Analyst estimates and notes.
79
+ self.analyst = _r.Analyst(t, s)
80
+ #: The per-scenario event timeline.
81
+ self.events = _r.Events(t, s)
82
+ #: Insider trades and transactions.
83
+ self.insiders = _r.Insiders(t, s)
84
+ #: 13F institutional holdings.
85
+ self.institutional = _r.Institutional(t, s)
86
+ #: Index-fund constituents.
87
+ self.index_funds = _r.IndexFunds(t, s)
88
+ #: Corporate actions (splits, dividends).
89
+ self.corporate_actions = _r.CorporateActions(t, s)
90
+ #: Macro series and central-bank rates.
91
+ self.macro = _r.Macro(t, s)
92
+ #: Discover and generate scenarios.
93
+ self.scenarios = _r.Scenarios(t, s)
94
+ #: API coverage, presets, themes, and audits.
95
+ self.metadata = _r.Metadata(t, s)
96
+
97
+ # -- escape hatch ------------------------------------------------------
98
+ def get(self, path: str, **params: Any) -> Any:
99
+ """Call any GET endpoint directly. ``scenario_id`` is *not* injected."""
100
+ return self._t.request("GET", path, params=params)
101
+
102
+ def post(self, path: str, *, json: Any = None, **params: Any) -> Any:
103
+ """Call any POST endpoint directly."""
104
+ return self._t.request("POST", path, params=params or None, json=json)
105
+
106
+ def health(self) -> dict:
107
+ """Liveness probe (unauthenticated on the server, but routed through here)."""
108
+ return self._t.request("GET", "/health")
109
+
110
+ # -- lifecycle ---------------------------------------------------------
111
+ def close(self) -> None:
112
+ """Close the underlying HTTP connection pool."""
113
+ self._t.close()
114
+
115
+ def __enter__(self) -> "DavidData":
116
+ return self
117
+
118
+ def __exit__(self, *exc: Any) -> None:
119
+ self.close()
120
+
121
+ def __repr__(self) -> str:
122
+ return (
123
+ f"DavidData(base_url={self._t.base_url!r}, scenario_id={self.scenario_id!r}, "
124
+ f"version={__version__!r})"
125
+ )
david_data/errors.py ADDED
@@ -0,0 +1,121 @@
1
+ """Exception hierarchy for the David Data client.
2
+
3
+ Every error raised by the SDK is a subclass of :class:`DavidDataError`, so you
4
+ can catch all of them with a single ``except DavidDataError``. HTTP failures
5
+ carry the parsed response body and status code when available.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Optional
11
+
12
+
13
+ class DavidDataError(Exception):
14
+ """Base class for every error raised by the SDK."""
15
+
16
+
17
+ class APIConnectionError(DavidDataError):
18
+ """The request never produced a response (DNS, TLS, timeout, connection reset)."""
19
+
20
+
21
+ class APITimeoutError(APIConnectionError):
22
+ """The request exceeded the configured timeout."""
23
+
24
+
25
+ class APIStatusError(DavidDataError):
26
+ """The server returned a non-2xx status code.
27
+
28
+ Attributes:
29
+ status_code: The HTTP status code.
30
+ body: The parsed JSON body, or the raw text if it was not JSON.
31
+ detail: The server's ``detail`` message when present (FastAPI style).
32
+ request_id: The value of any ``x-request-id`` response header.
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ message: str,
38
+ *,
39
+ status_code: int,
40
+ body: Any = None,
41
+ request_id: Optional[str] = None,
42
+ ) -> None:
43
+ super().__init__(message)
44
+ self.status_code = status_code
45
+ self.body = body
46
+ self.request_id = request_id
47
+
48
+ @property
49
+ def detail(self) -> Optional[str]:
50
+ if isinstance(self.body, dict):
51
+ d = self.body.get("detail")
52
+ if isinstance(d, str):
53
+ return d
54
+ return None
55
+
56
+
57
+ class BadRequestError(APIStatusError):
58
+ """400 — the request parameters were rejected by the server."""
59
+
60
+
61
+ class AuthenticationError(APIStatusError):
62
+ """401 — missing or invalid API key."""
63
+
64
+
65
+ class PermissionDeniedError(APIStatusError):
66
+ """403 — the key is valid but not allowed (e.g. plan quota, admin-only route)."""
67
+
68
+
69
+ class NotFoundError(APIStatusError):
70
+ """404 — the scenario, ticker, or resource does not exist."""
71
+
72
+
73
+ class RateLimitError(APIStatusError):
74
+ """429 — the per-minute rate limit was exceeded.
75
+
76
+ ``retry_after`` is the server-advised wait in seconds, when provided.
77
+ """
78
+
79
+ def __init__(self, *args: Any, retry_after: Optional[float] = None, **kwargs: Any) -> None:
80
+ super().__init__(*args, **kwargs)
81
+ self.retry_after = retry_after
82
+
83
+
84
+ class ServerError(APIStatusError):
85
+ """5xx — the server failed to handle a valid request."""
86
+
87
+
88
+ _STATUS_TO_ERROR = {
89
+ 400: BadRequestError,
90
+ 401: AuthenticationError,
91
+ 403: PermissionDeniedError,
92
+ 404: NotFoundError,
93
+ 429: RateLimitError,
94
+ }
95
+
96
+
97
+ def error_for_status(
98
+ status_code: int, *, body: Any, request_id: Optional[str], retry_after: Optional[float] = None
99
+ ) -> APIStatusError:
100
+ """Build the most specific :class:`APIStatusError` subclass for a status code."""
101
+ message = _message_from_body(body) or f"HTTP {status_code}"
102
+ if status_code == 429:
103
+ return RateLimitError(
104
+ message, status_code=status_code, body=body, request_id=request_id, retry_after=retry_after
105
+ )
106
+ cls = _STATUS_TO_ERROR.get(status_code)
107
+ if cls is None:
108
+ cls = ServerError if status_code >= 500 else APIStatusError
109
+ return cls(message, status_code=status_code, body=body, request_id=request_id)
110
+
111
+
112
+ def _message_from_body(body: Any) -> Optional[str]:
113
+ if isinstance(body, dict):
114
+ detail = body.get("detail")
115
+ if isinstance(detail, str):
116
+ return detail
117
+ if detail is not None:
118
+ return str(detail)
119
+ if isinstance(body, str) and body.strip():
120
+ return body.strip()[:500]
121
+ return None
david_data/py.typed ADDED
File without changes
@@ -0,0 +1,27 @@
1
+ from .company import Company
2
+ from .documents import Filings, News
3
+ from .estimates import Analyst, Earnings, Events
4
+ from .financials import Financials
5
+ from .macro import Macro
6
+ from .metadata import Metadata
7
+ from .ownership import CorporateActions, IndexFunds, Insiders, Institutional
8
+ from .prices import Prices
9
+ from .scenarios import Scenarios
10
+
11
+ __all__ = [
12
+ "Analyst",
13
+ "Company",
14
+ "CorporateActions",
15
+ "Earnings",
16
+ "Events",
17
+ "Filings",
18
+ "Financials",
19
+ "IndexFunds",
20
+ "Insiders",
21
+ "Institutional",
22
+ "Macro",
23
+ "Metadata",
24
+ "News",
25
+ "Prices",
26
+ "Scenarios",
27
+ ]
@@ -0,0 +1,62 @@
1
+ """Shared base for resource groups.
2
+
3
+ A resource group is a thin namespace (``client.prices``, ``client.financials``,
4
+ …) whose methods translate friendly arguments into a single HTTP call and
5
+ return the useful part of the JSON envelope.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Mapping, Optional
11
+
12
+ from .._http import Transport
13
+ from ..errors import DavidDataError
14
+
15
+
16
+ class Resource:
17
+ def __init__(self, transport: Transport, default_scenario_id: Optional[str]) -> None:
18
+ self._t = transport
19
+ self._default_scenario_id = default_scenario_id
20
+
21
+ def _scenario(self, scenario_id: Optional[str]) -> str:
22
+ sid = scenario_id or self._default_scenario_id
23
+ if not sid:
24
+ raise DavidDataError(
25
+ "This endpoint requires a scenario_id. Pass scenario_id=... to the call, "
26
+ "or set a default on the client: DavidData(scenario_id='...'). "
27
+ "Browse available scenarios with client.scenarios.list()."
28
+ )
29
+ return sid
30
+
31
+ def _get(
32
+ self,
33
+ path: str,
34
+ *,
35
+ params: Optional[Mapping[str, Any]] = None,
36
+ unwrap: Optional[str] = None,
37
+ ) -> Any:
38
+ data = self._t.request("GET", path, params=params)
39
+ return _unwrap(data, unwrap)
40
+
41
+ def _post(
42
+ self,
43
+ path: str,
44
+ *,
45
+ json: Any = None,
46
+ params: Optional[Mapping[str, Any]] = None,
47
+ unwrap: Optional[str] = None,
48
+ ) -> Any:
49
+ data = self._t.request("POST", path, params=params, json=json)
50
+ return _unwrap(data, unwrap)
51
+
52
+
53
+ def _unwrap(data: Any, key: Optional[str]) -> Any:
54
+ """Return ``data[key]`` when present, else the whole payload.
55
+
56
+ The API wraps list/object results in a single-key envelope (e.g.
57
+ ``{"prices": [...]}``). We return the inner value so callers get the data
58
+ directly, FMP-style, while still tolerating a missing/renamed key.
59
+ """
60
+ if key and isinstance(data, dict) and key in data:
61
+ return data[key]
62
+ return data
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, List, Optional
4
+
5
+ from .base import Resource
6
+
7
+
8
+ class Company(Resource):
9
+ """Company reference data: directory, facts, and identifier lookups."""
10
+
11
+ def list(
12
+ self,
13
+ *,
14
+ scenario_id: Optional[str] = None,
15
+ ticker: Optional[str] = None,
16
+ search: Optional[str] = None,
17
+ limit: int = 100,
18
+ offset: int = 0,
19
+ ) -> Any:
20
+ """Browse or search the company directory."""
21
+ return self._get(
22
+ "/companies",
23
+ params={
24
+ "scenario_id": self._scenario(scenario_id),
25
+ "ticker": ticker,
26
+ "search": search,
27
+ "limit": limit,
28
+ "offset": offset,
29
+ },
30
+ unwrap="companies",
31
+ )
32
+
33
+ def facts(
34
+ self,
35
+ ticker: Optional[str] = None,
36
+ *,
37
+ cik: Optional[str] = None,
38
+ scenario_id: Optional[str] = None,
39
+ ) -> Any:
40
+ """Company facts for a single ``ticker`` or ``cik``."""
41
+ return self._get(
42
+ "/company/facts",
43
+ params={"scenario_id": self._scenario(scenario_id), "ticker": ticker, "cik": cik},
44
+ unwrap="company_facts",
45
+ )
46
+
47
+ def tickers(self, *, scenario_id: Optional[str] = None) -> List[str]:
48
+ return self._get(
49
+ "/company/facts/tickers",
50
+ params={"scenario_id": self._scenario(scenario_id)},
51
+ unwrap="tickers",
52
+ )
53
+
54
+ def ciks(self, *, scenario_id: Optional[str] = None) -> Any:
55
+ return self._get(
56
+ "/company/facts/ciks",
57
+ params={"scenario_id": self._scenario(scenario_id)},
58
+ unwrap="ciks",
59
+ )