Skip to content

Commit b4cb830

Browse files
committed
v0.6.0: CLI expansion — full endpoint coverage
Add CLI commands for company, financials, dividends, events, and real-time stream. The stream command outputs JSON lines to stdout with reconnect status on stderr; stop with Ctrl+C. All SDK endpoints are now accessible via the CLI.
1 parent d6ec4d7 commit b4cb830

7 files changed

Lines changed: 292 additions & 9 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.6.0] — 2026-04-02
8+
9+
### Added
10+
11+
- **CLI: `company` command**`sahmk company 2222` to get company info
12+
- **CLI: `financials` command**`sahmk financials 2222` for financial statements
13+
- **CLI: `dividends` command**`sahmk dividends 2222` for dividend history
14+
- **CLI: `events` command**`sahmk events --symbol 2222 --limit 10` for stock events
15+
- **CLI: `stream` command**`sahmk stream 2222,1120` for real-time WebSocket streaming with auto-reconnect; outputs JSON lines to stdout, status/errors to stderr; stop with Ctrl+C
16+
17+
### Changed
18+
19+
- CLI now covers all SDK endpoints (previously only quote, quotes, market, historical)
20+
- Stream command outputs each quote as a single JSON line for easy piping/parsing
21+
722
## [0.5.0] — 2026-04-02
823

924
### Added

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ export SAHMK_API_KEY="your_api_key"
5858
sahmk quote 2222
5959
sahmk market gainers --limit 5
6060
sahmk historical 2222 --from 2026-01-01 --to 2026-01-28
61+
sahmk company 2222
62+
sahmk financials 2222
63+
sahmk dividends 2222
64+
sahmk events --symbol 2222 --limit 5
65+
sahmk stream 2222,1120
6166
```
6267

6368
You can also pass the key directly:

ROADMAP.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,13 @@ Timelines are approximate and can change based on user feedback and API evolutio
4747
- Nested typed sub-objects (Liquidity, Fundamentals, Technicals, etc.)
4848
- `.raw` attribute on all models for original API response access
4949

50-
## Next Milestones
50+
### v0.6.0 (released)
5151

52-
### v0.6.0 (planned) — CLI expansion
52+
- CLI commands for `company`, `financials`, `dividends`, `events`, and `stream`
53+
- Full endpoint coverage in CLI
54+
- Stream command outputs JSON lines with auto-reconnect
5355

54-
- CLI commands for `company`, `financials`, `dividends`, `events`
55-
- Optional `stream` CLI command
56-
- Improved CLI output modes
56+
## Next Milestones
5757

5858
## Documentation and Examples Plan
5959

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.5.0"
7+
version = "0.6.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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
Liquidity,
2727
)
2828

29-
__version__ = "0.5.0"
29+
__version__ = "0.6.0"
3030
__all__ = [
3131
"SahmkClient",
3232
"SahmkError",

sahmk/cli.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""
44

55
import argparse
6+
import asyncio
67
import json
78
import os
89
import sys
@@ -73,6 +74,44 @@ def _build_parser():
7374
help='Interval: "1d", "1w", or "1m".',
7475
)
7576

77+
company_parser = subparsers.add_parser(
78+
"company", help="Get company info (tiered by plan)."
79+
)
80+
company_parser.add_argument("symbol", help='Stock symbol (e.g., "2222").')
81+
82+
financials_parser = subparsers.add_parser(
83+
"financials", help="Get financial statements (Starter+ plan)."
84+
)
85+
financials_parser.add_argument("symbol", help='Stock symbol (e.g., "2222").')
86+
87+
dividends_parser = subparsers.add_parser(
88+
"dividends", help="Get dividend history and yield (Starter+ plan)."
89+
)
90+
dividends_parser.add_argument("symbol", help='Stock symbol (e.g., "2222").')
91+
92+
events_parser = subparsers.add_parser(
93+
"events", help="Get AI-generated stock events (Pro+ plan)."
94+
)
95+
events_parser.add_argument(
96+
"--symbol",
97+
default=None,
98+
help="Filter events for a specific stock symbol.",
99+
)
100+
events_parser.add_argument(
101+
"--limit",
102+
type=int,
103+
default=None,
104+
help="Number of events to return.",
105+
)
106+
107+
stream_parser = subparsers.add_parser(
108+
"stream", help="Stream real-time quotes via WebSocket (Pro+ plan)."
109+
)
110+
stream_parser.add_argument(
111+
"symbols",
112+
help='Comma-separated symbols to stream, e.g. "2222,1120".',
113+
)
114+
76115
return parser
77116

78117

@@ -88,6 +127,55 @@ def _print_json(payload, compact=False):
88127
print(json.dumps(data, ensure_ascii=False, indent=2))
89128

90129

130+
def _run_stream(client, symbols):
131+
"""Run the WebSocket stream, printing quotes as JSON lines."""
132+
133+
async def on_quote(msg):
134+
symbol = msg.get("symbol", "?")
135+
data = msg.get("data", {})
136+
line = json.dumps(
137+
{"symbol": symbol, **data},
138+
ensure_ascii=False,
139+
)
140+
print(line, flush=True)
141+
142+
async def on_error(error):
143+
err_msg = error.get("message", str(error))
144+
print(
145+
json.dumps({"error": err_msg}, ensure_ascii=False),
146+
file=sys.stderr,
147+
flush=True,
148+
)
149+
150+
async def on_disconnect(reason):
151+
print(
152+
json.dumps({"status": "disconnected", "reason": reason}, ensure_ascii=False),
153+
file=sys.stderr,
154+
flush=True,
155+
)
156+
157+
async def on_reconnect(attempt):
158+
print(
159+
json.dumps({"status": "reconnecting", "attempt": attempt}, ensure_ascii=False),
160+
file=sys.stderr,
161+
flush=True,
162+
)
163+
164+
async def _stream():
165+
await client.stream(
166+
symbols,
167+
on_quote=on_quote,
168+
on_error=on_error,
169+
on_disconnect=on_disconnect,
170+
on_reconnect=on_reconnect,
171+
)
172+
173+
try:
174+
asyncio.run(_stream())
175+
except KeyboardInterrupt:
176+
pass
177+
178+
91179
def main(argv=None):
92180
parser = _build_parser()
93181
args = parser.parse_args(argv)
@@ -130,6 +218,20 @@ def main(argv=None):
130218
to_date=args.to_date,
131219
interval=args.interval,
132220
)
221+
elif args.command == "company":
222+
result = client.company(args.symbol)
223+
elif args.command == "financials":
224+
result = client.financials(args.symbol)
225+
elif args.command == "dividends":
226+
result = client.dividends(args.symbol)
227+
elif args.command == "events":
228+
result = client.events(symbol=args.symbol, limit=args.limit)
229+
elif args.command == "stream":
230+
symbols = [s.strip() for s in args.symbols.split(",") if s.strip()]
231+
if not symbols:
232+
parser.error("At least one symbol is required for stream.")
233+
_run_stream(client, symbols)
234+
return 0
133235
else:
134236
parser.error("Unknown command.")
135237
return 2

tests/test_cli.py

Lines changed: 163 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import pytest
1010
import responses
11-
from sahmk.cli import main, _build_parser, _resolve_api_key, _print_json
11+
from sahmk.cli import main, _build_parser, _resolve_api_key, _print_json, _run_stream
1212
from sahmk.client import SahmkError
1313

1414

@@ -453,8 +453,169 @@ def test_compact_output(self, capsys, sample_quote_response):
453453
)
454454

455455
exit_code = main(["--api-key", "test_key", "--compact", "quote", "2222"])
456-
456+
457457
assert exit_code == 0
458458
captured = capsys.readouterr()
459459
# Compact output should not have indentation
460460
assert "\n " not in captured.out
461+
462+
463+
class TestMainCompanyCommand:
464+
"""Tests for main function with company command."""
465+
466+
@responses.activate
467+
def test_company_success(self, capsys, sample_company_response, monkeypatch):
468+
"""Test successful company command."""
469+
monkeypatch.setenv("SAHMK_API_KEY", "test_key")
470+
responses.add(
471+
responses.GET,
472+
"https://app.sahmk.sa/api/v1/company/2222/",
473+
json=sample_company_response,
474+
status=200,
475+
)
476+
477+
exit_code = main(["company", "2222"])
478+
479+
assert exit_code == 0
480+
captured = capsys.readouterr()
481+
assert "2222" in captured.out
482+
assert "Saudi Arabian Oil Company" in captured.out
483+
484+
485+
class TestMainFinancialsCommand:
486+
"""Tests for main function with financials command."""
487+
488+
@responses.activate
489+
def test_financials_success(self, capsys, sample_financials_response, monkeypatch):
490+
"""Test successful financials command."""
491+
monkeypatch.setenv("SAHMK_API_KEY", "test_key")
492+
responses.add(
493+
responses.GET,
494+
"https://app.sahmk.sa/api/v1/financials/2222/",
495+
json=sample_financials_response,
496+
status=200,
497+
)
498+
499+
exit_code = main(["financials", "2222"])
500+
501+
assert exit_code == 0
502+
captured = capsys.readouterr()
503+
assert "income_statement" in captured.out
504+
505+
506+
class TestMainDividendsCommand:
507+
"""Tests for main function with dividends command."""
508+
509+
@responses.activate
510+
def test_dividends_success(self, capsys, sample_dividends_response, monkeypatch):
511+
"""Test successful dividends command."""
512+
monkeypatch.setenv("SAHMK_API_KEY", "test_key")
513+
responses.add(
514+
responses.GET,
515+
"https://app.sahmk.sa/api/v1/dividends/2222/",
516+
json=sample_dividends_response,
517+
status=200,
518+
)
519+
520+
exit_code = main(["dividends", "2222"])
521+
522+
assert exit_code == 0
523+
captured = capsys.readouterr()
524+
assert "dividend_yield" in captured.out
525+
526+
527+
class TestMainEventsCommand:
528+
"""Tests for main function with events command."""
529+
530+
@responses.activate
531+
def test_events_success(self, capsys, sample_events_response, monkeypatch):
532+
"""Test successful events command."""
533+
monkeypatch.setenv("SAHMK_API_KEY", "test_key")
534+
responses.add(
535+
responses.GET,
536+
"https://app.sahmk.sa/api/v1/events/",
537+
json=sample_events_response,
538+
status=200,
539+
)
540+
541+
exit_code = main(["events"])
542+
543+
assert exit_code == 0
544+
captured = capsys.readouterr()
545+
assert "events" in captured.out
546+
547+
@responses.activate
548+
def test_events_with_symbol(self, capsys, sample_events_response, monkeypatch):
549+
"""Test events command with --symbol filter."""
550+
monkeypatch.setenv("SAHMK_API_KEY", "test_key")
551+
responses.add(
552+
responses.GET,
553+
"https://app.sahmk.sa/api/v1/events/",
554+
json=sample_events_response,
555+
status=200,
556+
)
557+
558+
exit_code = main(["events", "--symbol", "2222"])
559+
560+
assert exit_code == 0
561+
request = responses.calls[0].request
562+
assert "symbol=2222" in request.url
563+
564+
@responses.activate
565+
def test_events_with_limit(self, capsys, sample_events_response, monkeypatch):
566+
"""Test events command with --limit."""
567+
monkeypatch.setenv("SAHMK_API_KEY", "test_key")
568+
responses.add(
569+
responses.GET,
570+
"https://app.sahmk.sa/api/v1/events/",
571+
json=sample_events_response,
572+
status=200,
573+
)
574+
575+
exit_code = main(["events", "--limit", "5"])
576+
577+
assert exit_code == 0
578+
request = responses.calls[0].request
579+
assert "limit=5" in request.url
580+
581+
582+
class TestParserNewCommands:
583+
"""Tests for parsing new CLI commands."""
584+
585+
def test_parser_company_command(self):
586+
parser = _build_parser()
587+
args = parser.parse_args(["company", "2222"])
588+
assert args.command == "company"
589+
assert args.symbol == "2222"
590+
591+
def test_parser_financials_command(self):
592+
parser = _build_parser()
593+
args = parser.parse_args(["financials", "2222"])
594+
assert args.command == "financials"
595+
assert args.symbol == "2222"
596+
597+
def test_parser_dividends_command(self):
598+
parser = _build_parser()
599+
args = parser.parse_args(["dividends", "2222"])
600+
assert args.command == "dividends"
601+
assert args.symbol == "2222"
602+
603+
def test_parser_events_command(self):
604+
parser = _build_parser()
605+
args = parser.parse_args(["events", "--symbol", "2222", "--limit", "10"])
606+
assert args.command == "events"
607+
assert args.symbol == "2222"
608+
assert args.limit == 10
609+
610+
def test_parser_events_no_args(self):
611+
parser = _build_parser()
612+
args = parser.parse_args(["events"])
613+
assert args.command == "events"
614+
assert args.symbol is None
615+
assert args.limit is None
616+
617+
def test_parser_stream_command(self):
618+
parser = _build_parser()
619+
args = parser.parse_args(["stream", "2222,1120"])
620+
assert args.command == "stream"
621+
assert args.symbols == "2222,1120"

0 commit comments

Comments
 (0)