Skip to content

Commit d6ec4d7

Browse files
committed
v0.5.0: Typed response models
All client methods now return typed dataclass objects with IDE autocompletion. Full backwards compatibility preserved — dict-style [] access, .get(), and iteration all work unchanged. Models expose .raw for the original API dict. Nested sub-objects for complex responses (Quote.liquidity, Company.fundamentals/technicals/valuation/analysts). No external dependencies — uses stdlib dataclasses only.
1 parent e89ebf6 commit d6ec4d7

9 files changed

Lines changed: 1378 additions & 36 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,21 @@ All notable changes to the `sahmk` Python SDK will be documented in this file.
44

55
This project follows [Semantic Versioning](https://semver.org/).
66

7+
## [0.5.0] — 2026-04-02
8+
9+
### Added
10+
11+
- **Typed response models** — all client methods now return typed dataclass objects (e.g., `Quote`, `Company`, `HistoricalResponse`, `FinancialsResponse`, `DividendsResponse`, `EventsResponse`, `MarketSummary`, etc.) with IDE autocompletion and attribute access
12+
- **Full backwards compatibility** — all typed models support dict-style `[]` access, `.get()`, `.keys()`, `.values()`, `.items()`, and `in` checks; existing code using `result["price"]` continues to work unchanged
13+
- **`.raw` attribute** — every model exposes the original API response dict via `.raw` for cases where you need the full untyped data
14+
- **Nested typed models** — complex responses have properly typed sub-objects (e.g., `Quote.liquidity` returns a `Liquidity` object, `Company.fundamentals` returns a `Fundamentals` object)
15+
- **Plan-aware models**`Company` model gracefully handles tiered responses (Free: basic fields, Starter: +fundamentals, Pro: +technicals/valuation/analysts) with `None` for unavailable sections
16+
17+
### Changed
18+
19+
- All client methods (`quote()`, `quotes()`, `historical()`, `market_summary()`, `gainers()`, `losers()`, `volume_leaders()`, `value_leaders()`, `sectors()`, `company()`, `financials()`, `dividends()`, `events()`) now return typed model instances instead of plain dicts
20+
- CLI `_print_json()` now handles model objects by serializing their `.raw` dict
21+
722
## [0.4.0] — 2026-04-02
823

924
### Added

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,28 @@ You can also pass the key directly:
6666
sahmk quote 2222 --api-key your_api_key
6767
```
6868

69+
## Typed Responses
70+
71+
All client methods return typed objects with IDE autocompletion — while preserving full backwards compatibility with dict-style access:
72+
73+
```python
74+
quote = client.quote("2222")
75+
76+
# New: typed attribute access
77+
print(quote.price)
78+
print(quote.liquidity.net_value)
79+
80+
# Still works: dict-style access (backwards compatible)
81+
print(quote["price"])
82+
print(quote.get("volume"))
83+
```
84+
85+
Access the original API response dict via `.raw`:
86+
87+
```python
88+
raw_dict = quote.raw
89+
```
90+
6991
## Retries and Rate Limits
7092

7193
The client automatically retries transient failures (429 rate-limit and 5xx server errors) with exponential backoff:

ROADMAP.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,14 @@ Timelines are approximate and can change based on user feedback and API evolutio
4040
- Configurable retry behavior (`retries`, `backoff_factor`, `retry_on_timeout`)
4141
- Timeout retries (opt-in, enabled by default)
4242

43-
## Next Milestones
43+
### v0.5.0 (released)
4444

45-
### v0.5.0 (planned) — Typed response models
45+
- Typed dataclass models for all endpoints (Quote, Company, Historical, etc.)
46+
- Full backwards compatibility — dict-style `[]` access preserved
47+
- Nested typed sub-objects (Liquidity, Fundamentals, Technicals, etc.)
48+
- `.raw` attribute on all models for original API response access
4649

47-
- Typed response models for key endpoints (quote, company, historical, etc.)
48-
- Improved developer experience with IDE autocompletion
49-
- Backwards-compatible — raw dict access preserved
50+
## Next Milestones
5051

5152
### v0.6.0 (planned) — CLI expansion
5253

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "sahmk"
7-
version = "0.4.0"
7+
version = "0.5.0"
88
description = "Lightweight Python client for the SAHMK Developer API."
99
readme = "README.md"
1010
requires-python = ">=3.9"

sahmk/__init__.py

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,58 @@
11
from .client import SahmkClient, SahmkError, SahmkRateLimitError
2+
from .models import (
3+
Quote,
4+
BatchQuote,
5+
BatchQuotesResponse,
6+
HistoricalResponse,
7+
OHLCV,
8+
MarketSummary,
9+
MarketMover,
10+
MarketMoversResponse,
11+
Sector,
12+
SectorsResponse,
13+
Company,
14+
Fundamentals,
15+
Technicals,
16+
Valuation,
17+
Analysts,
18+
FinancialsResponse,
19+
IncomeStatement,
20+
BalanceSheet,
21+
CashFlow,
22+
DividendsResponse,
23+
DividendPayment,
24+
Event,
25+
EventsResponse,
26+
Liquidity,
27+
)
228

3-
__version__ = "0.4.0"
4-
__all__ = ["SahmkClient", "SahmkError", "SahmkRateLimitError"]
29+
__version__ = "0.5.0"
30+
__all__ = [
31+
"SahmkClient",
32+
"SahmkError",
33+
"SahmkRateLimitError",
34+
"Quote",
35+
"BatchQuote",
36+
"BatchQuotesResponse",
37+
"HistoricalResponse",
38+
"OHLCV",
39+
"MarketSummary",
40+
"MarketMover",
41+
"MarketMoversResponse",
42+
"Sector",
43+
"SectorsResponse",
44+
"Company",
45+
"Fundamentals",
46+
"Technicals",
47+
"Valuation",
48+
"Analysts",
49+
"FinancialsResponse",
50+
"IncomeStatement",
51+
"BalanceSheet",
52+
"CashFlow",
53+
"DividendsResponse",
54+
"DividendPayment",
55+
"Event",
56+
"EventsResponse",
57+
"Liquidity",
58+
]

sahmk/cli.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,11 @@ def _resolve_api_key(cli_api_key):
8181

8282

8383
def _print_json(payload, compact=False):
84+
data = getattr(payload, "raw", payload)
8485
if compact:
85-
print(json.dumps(payload, ensure_ascii=False, separators=(",", ":")))
86+
print(json.dumps(data, ensure_ascii=False, separators=(",", ":")))
8687
return
87-
print(json.dumps(payload, ensure_ascii=False, indent=2))
88+
print(json.dumps(data, ensure_ascii=False, indent=2))
8889

8990

9091
def main(argv=None):

sahmk/client.py

Lines changed: 69 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -239,10 +239,11 @@ def quote(self, symbol):
239239
symbol: Stock symbol (e.g., "2222" for Aramco)
240240
241241
Returns:
242-
dict with keys: symbol, name, name_en, price, change,
243-
change_percent, volume, bid, ask, liquidity, etc.
242+
Quote object (supports dict-style access via [] for backwards compat)
244243
"""
245-
return self._request("GET", f"/quote/{symbol}/")
244+
from .models import Quote
245+
data = self._request("GET", f"/quote/{symbol}/")
246+
return Quote.from_dict(data)
246247

247248
def quotes(self, symbols):
248249
"""
@@ -252,13 +253,15 @@ def quotes(self, symbols):
252253
symbols: List of stock symbols (up to 50)
253254
254255
Returns:
255-
dict with "quotes" list and "count"
256+
BatchQuotesResponse with .quotes list and .count
256257
"""
258+
from .models import BatchQuotesResponse
257259
if len(symbols) > 50:
258260
raise SahmkError("Maximum 50 symbols per batch request")
259-
return self._request(
261+
data = self._request(
260262
"GET", "/quotes/", params={"symbols": ",".join(symbols)}
261263
)
264+
return BatchQuotesResponse.from_dict(data)
262265

263266
# -------------------------------------------------------------------------
264267
# Historical
@@ -275,24 +278,33 @@ def historical(self, symbol, from_date=None, to_date=None, interval=None):
275278
interval: "1d", "1w", or "1m" (default: "1d")
276279
277280
Returns:
278-
dict with "symbol", "interval", "from", "to", "count", and "data" list
281+
HistoricalResponse with .data list of OHLCV objects
279282
"""
283+
from .models import HistoricalResponse
280284
params = {}
281285
if from_date:
282286
params["from"] = from_date
283287
if to_date:
284288
params["to"] = to_date
285289
if interval:
286290
params["interval"] = interval
287-
return self._request("GET", f"/historical/{symbol}/", params=params)
291+
data = self._request("GET", f"/historical/{symbol}/", params=params)
292+
return HistoricalResponse.from_dict(data)
288293

289294
# -------------------------------------------------------------------------
290295
# Market
291296
# -------------------------------------------------------------------------
292297

293298
def market_summary(self):
294-
"""Get market overview (TASI index, change, volume, market_mood)."""
295-
return self._request("GET", "/market/summary/")
299+
"""
300+
Get market overview (TASI index, change, volume, market_mood).
301+
302+
Returns:
303+
MarketSummary object
304+
"""
305+
from .models import MarketSummary
306+
data = self._request("GET", "/market/summary/")
307+
return MarketSummary.from_dict(data)
296308

297309
def gainers(self, limit=None):
298310
"""
@@ -302,12 +314,14 @@ def gainers(self, limit=None):
302314
limit: Number of results (default: 10, max: 50)
303315
304316
Returns:
305-
dict with "gainers" list and "count"
317+
MarketMoversResponse with .stocks list
306318
"""
319+
from .models import MarketMoversResponse
307320
params = {}
308321
if limit is not None:
309322
params["limit"] = limit
310-
return self._request("GET", "/market/gainers/", params=params or None)
323+
data = self._request("GET", "/market/gainers/", params=params or None)
324+
return MarketMoversResponse.from_dict(data, list_key="gainers")
311325

312326
def losers(self, limit=None):
313327
"""
@@ -317,12 +331,14 @@ def losers(self, limit=None):
317331
limit: Number of results (default: 10, max: 50)
318332
319333
Returns:
320-
dict with "losers" list and "count"
334+
MarketMoversResponse with .stocks list
321335
"""
336+
from .models import MarketMoversResponse
322337
params = {}
323338
if limit is not None:
324339
params["limit"] = limit
325-
return self._request("GET", "/market/losers/", params=params or None)
340+
data = self._request("GET", "/market/losers/", params=params or None)
341+
return MarketMoversResponse.from_dict(data, list_key="losers")
326342

327343
def volume_leaders(self, limit=None):
328344
"""
@@ -332,12 +348,14 @@ def volume_leaders(self, limit=None):
332348
limit: Number of results (default: 10, max: 50)
333349
334350
Returns:
335-
dict with "stocks" list and "count"
351+
MarketMoversResponse with .stocks list
336352
"""
353+
from .models import MarketMoversResponse
337354
params = {}
338355
if limit is not None:
339356
params["limit"] = limit
340-
return self._request("GET", "/market/volume/", params=params or None)
357+
data = self._request("GET", "/market/volume/", params=params or None)
358+
return MarketMoversResponse.from_dict(data, list_key="stocks")
341359

342360
def value_leaders(self, limit=None):
343361
"""
@@ -347,21 +365,25 @@ def value_leaders(self, limit=None):
347365
limit: Number of results (default: 10, max: 50)
348366
349367
Returns:
350-
dict with "stocks" list and "count"
368+
MarketMoversResponse with .stocks list
351369
"""
370+
from .models import MarketMoversResponse
352371
params = {}
353372
if limit is not None:
354373
params["limit"] = limit
355-
return self._request("GET", "/market/value/", params=params or None)
374+
data = self._request("GET", "/market/value/", params=params or None)
375+
return MarketMoversResponse.from_dict(data, list_key="stocks")
356376

357377
def sectors(self):
358378
"""
359379
Get sector performance.
360380
361381
Returns:
362-
dict with "sectors" list and "count"
382+
SectorsResponse with .sectors list
363383
"""
364-
return self._request("GET", "/market/sectors/")
384+
from .models import SectorsResponse
385+
data = self._request("GET", "/market/sectors/")
386+
return SectorsResponse.from_dict(data)
365387

366388
# -------------------------------------------------------------------------
367389
# Company Data
@@ -371,16 +393,35 @@ def company(self, symbol):
371393
"""
372394
Get company info. Response varies by plan:
373395
Free (basic), Starter (fundamentals), Pro (technicals, valuation, analysts).
396+
397+
Returns:
398+
Company object
374399
"""
375-
return self._request("GET", f"/company/{symbol}/")
400+
from .models import Company as CompanyModel
401+
data = self._request("GET", f"/company/{symbol}/")
402+
return CompanyModel.from_dict(data)
376403

377404
def financials(self, symbol):
378-
"""Get financial statements (income, balance sheet, cash flow). Starter+ plan."""
379-
return self._request("GET", f"/financials/{symbol}/")
405+
"""
406+
Get financial statements (income, balance sheet, cash flow). Starter+ plan.
407+
408+
Returns:
409+
FinancialsResponse object
410+
"""
411+
from .models import FinancialsResponse
412+
data = self._request("GET", f"/financials/{symbol}/")
413+
return FinancialsResponse.from_dict(data)
380414

381415
def dividends(self, symbol):
382-
"""Get dividend history and yield. Starter+ plan."""
383-
return self._request("GET", f"/dividends/{symbol}/")
416+
"""
417+
Get dividend history and yield. Starter+ plan.
418+
419+
Returns:
420+
DividendsResponse object
421+
"""
422+
from .models import DividendsResponse
423+
data = self._request("GET", f"/dividends/{symbol}/")
424+
return DividendsResponse.from_dict(data)
384425

385426
# -------------------------------------------------------------------------
386427
# Events
@@ -395,14 +436,16 @@ def events(self, symbol=None, limit=None):
395436
limit: Number of results (default: 20)
396437
397438
Returns:
398-
dict with "events" list, "count", and "available_types"
439+
EventsResponse with .events list
399440
"""
441+
from .models import EventsResponse
400442
params = {}
401443
if symbol:
402444
params["symbol"] = symbol
403445
if limit is not None:
404446
params["limit"] = limit
405-
return self._request("GET", "/events/", params=params or None)
447+
data = self._request("GET", "/events/", params=params or None)
448+
return EventsResponse.from_dict(data)
406449

407450
# -------------------------------------------------------------------------
408451
# WebSocket Streaming (Pro+ plan)

0 commit comments

Comments
 (0)