diff --git a/CHANGELOG.md b/CHANGELOG.md index 408c637..f4aa019 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to the `sahmk` Python SDK will be documented in this file. This project follows [Semantic Versioning](https://semver.org/). +## [0.6.1] — 2026-04-02 + +### Fixed + +- **CLI `--compact` flag** now works in both positions: `sahmk --compact quote 2222` and `sahmk quote 2222 --compact` (previously only the first form worked) +- **Non-JSON 200 responses** (e.g. proxy HTML errors) are now wrapped in `SahmkError` instead of raising a raw `ValueError` +- **`on_reconnect` docstring** corrected — it fires before a reconnect attempt (after backoff delay), not after a successful reconnection +- **Test fixtures** aligned with real API response shapes (market summary, company, financials, dividends, events) +- **`quotes([])` guard** — calling `quotes()` with an empty list now raises `ValueError` immediately instead of sending an invalid request +- **Redundant 429** removed from internal `_RETRIABLE_STATUS_CODES` (429 is handled by its own dedicated branch) + +### Changed + +- **PyPI classifier** updated from "3 - Alpha" to "4 - Beta" + ## [0.6.0] — 2026-04-02 ### Added diff --git a/pyproject.toml b/pyproject.toml index 7de9e51..243ad6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sahmk" -version = "0.6.0" +version = "0.6.1" description = "Lightweight Python client for the SAHMK Developer API." readme = "README.md" requires-python = ">=3.9" @@ -14,7 +14,7 @@ authors = [ ] keywords = ["stocks", "tadawul", "market-data", "websocket", "api"] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Operating System :: OS Independent", "Programming Language :: Python :: 3", diff --git a/sahmk/__init__.py b/sahmk/__init__.py index 9afc6c4..4efc294 100644 --- a/sahmk/__init__.py +++ b/sahmk/__init__.py @@ -26,7 +26,7 @@ Liquidity, ) -__version__ = "0.6.0" +__version__ = "0.6.1" __all__ = [ "SahmkClient", "SahmkError", diff --git a/sahmk/cli.py b/sahmk/cli.py index 2096605..1967158 100644 --- a/sahmk/cli.py +++ b/sahmk/cli.py @@ -11,6 +11,15 @@ from .client import SahmkClient, SahmkError +def _compact_arg(parser): + """Add --compact flag to a parser.""" + parser.add_argument( + "--compact", + action="store_true", + help="Print compact JSON output.", + ) + + def _build_parser(): parser = argparse.ArgumentParser( prog="sahmk", @@ -31,16 +40,12 @@ def _build_parser(): default=30, help="Request timeout in seconds (default: 30).", ) - parser.add_argument( - "--compact", - action="store_true", - help="Print compact JSON output.", - ) subparsers = parser.add_subparsers(dest="command", required=True) quote_parser = subparsers.add_parser("quote", help="Get a single stock quote.") quote_parser.add_argument("symbol", help='Stock symbol (e.g., "2222").') + _compact_arg(quote_parser) quotes_parser = subparsers.add_parser( "quotes", help="Get quotes for multiple symbols." @@ -49,6 +54,7 @@ def _build_parser(): "symbols", help='Comma-separated symbols, e.g. "2222,1120,2010".', ) + _compact_arg(quotes_parser) market_parser = subparsers.add_parser("market", help="Market overview endpoints.") market_parser.add_argument( @@ -61,6 +67,7 @@ def _build_parser(): type=int, help="Optional limit for gainers/losers/volume/value.", ) + _compact_arg(market_parser) historical_parser = subparsers.add_parser( "historical", help="Get historical OHLCV data." @@ -73,21 +80,25 @@ def _build_parser(): choices=["1d", "1w", "1m"], help='Interval: "1d", "1w", or "1m".', ) + _compact_arg(historical_parser) company_parser = subparsers.add_parser( "company", help="Get company info (tiered by plan)." ) company_parser.add_argument("symbol", help='Stock symbol (e.g., "2222").') + _compact_arg(company_parser) financials_parser = subparsers.add_parser( "financials", help="Get financial statements (Starter+ plan)." ) financials_parser.add_argument("symbol", help='Stock symbol (e.g., "2222").') + _compact_arg(financials_parser) dividends_parser = subparsers.add_parser( "dividends", help="Get dividend history and yield (Starter+ plan)." ) dividends_parser.add_argument("symbol", help='Stock symbol (e.g., "2222").') + _compact_arg(dividends_parser) events_parser = subparsers.add_parser( "events", help="Get AI-generated stock events (Pro+ plan)." @@ -103,6 +114,7 @@ def _build_parser(): default=None, help="Number of events to return.", ) + _compact_arg(events_parser) stream_parser = subparsers.add_parser( "stream", help="Stream real-time quotes via WebSocket (Pro+ plan)." diff --git a/sahmk/client.py b/sahmk/client.py index da1d7d8..8150299 100644 --- a/sahmk/client.py +++ b/sahmk/client.py @@ -16,7 +16,7 @@ logger = logging.getLogger("sahmk") -_RETRIABLE_STATUS_CODES = frozenset({429, 500, 502, 503, 504}) +_RETRIABLE_STATUS_CODES = frozenset({500, 502, 503, 504}) class SahmkError(Exception): @@ -154,7 +154,14 @@ def _request(self, method, endpoint, params=None): if response.status_code != 200: raise self._build_api_error(response) - return response.json() + try: + return response.json() + except (ValueError, TypeError) as e: + raise SahmkError( + f"Unexpected non-JSON response: {e}", + status_code=response.status_code, + response=response, + ) raise last_exc # pragma: no cover @@ -256,6 +263,8 @@ def quotes(self, symbols): BatchQuotesResponse with .quotes list and .count """ from .models import BatchQuotesResponse + if not symbols: + raise ValueError("At least one symbol is required") if len(symbols) > 50: raise SahmkError("Maximum 50 symbols per batch request") data = self._request( @@ -478,8 +487,9 @@ async def stream( on_error: Async callback — on_error(error_data) on_disconnect: Async callback — on_disconnect(reason) called when the connection drops. Receives a string reason. - on_reconnect: Async callback — on_reconnect(attempt) called after - a successful reconnection. Receives the attempt number. + on_reconnect: Async callback — on_reconnect(attempt) called before + a reconnect attempt (after the backoff delay). + Receives the attempt number. ping_interval: Seconds between keep-alive pings (default: 30) max_reconnect_attempts: Maximum reconnection attempts. 0 means unlimited reconnection (default). Set to -1 diff --git a/tests/conftest.py b/tests/conftest.py index 00a2e66..e573840 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -30,22 +30,33 @@ def mock_client(api_key, mock_base_url): @pytest.fixture def sample_quote_response(): - """Sample quote API response.""" + """Sample quote API response (matches live API shape).""" return { "symbol": "2222", "name": "أرامكو السعودية", - "name_en": "Saudi Aramco", + "name_en": "Saudi Arabian Oil Co", "price": 32.45, "change": 0.35, "change_percent": 1.09, - "volume": 15000000, - "bid": 32.40, - "ask": 32.50, - "liquidity": 487500000, "open": 32.10, "high": 32.60, "low": 32.05, "previous_close": 32.10, + "volume": 15000000, + "value": 487500000.0, + "bid": 32.40, + "ask": 32.50, + "liquidity": { + "inflow_value": 300000000.0, + "inflow_volume": 9200000, + "inflow_trades": 8500, + "outflow_value": 187500000.0, + "outflow_volume": 5800000, + "outflow_trades": 4200, + "net_value": 112500000.0, + }, + "updated_at": "2026-02-10T12:19:22+00:00", + "is_delayed": False, } @@ -91,18 +102,17 @@ def sample_historical_response(): @pytest.fixture def sample_market_summary_response(): - """Sample market summary API response.""" + """Sample market summary API response (matches live API shape).""" return { - "index": "TASI", + "timestamp": "2026-01-28T12:20:00+00:00", "index_value": 11950.35, - "change": 125.40, - "change_percent": 1.06, - "volume": 350000000, - "value": 8750000000, + "index_change": 125.40, + "index_change_percent": 1.06, + "total_volume": 350000000, + "advancing": 142, + "declining": 68, + "unchanged": 15, "market_mood": "bullish", - "up_count": 142, - "down_count": 68, - "unchanged_count": 15, } @@ -168,65 +178,147 @@ def sample_sectors_response(): @pytest.fixture def sample_company_response(): - """Sample company info API response.""" + """Sample company info API response (matches live API shape).""" return { "symbol": "2222", "name": "أرامكو السعودية", - "name_en": "Saudi Arabian Oil Company", - "sector": "البترول", - "sector_en": "Energy", - "market_cap": 7500000000000, - "shares_outstanding": 231000000000, + "name_en": "Saudi Arabian Oil Co", + "current_price": 25.64, + "sector": "Energy", + "industry": "Oil & Gas", + "description": "Saudi Aramco is the world's largest oil producer...", "website": "https://www.aramco.com", - "description": "شركة النفط والغاز العملاقة", + "country": "Saudi Arabia", + "currency": "SAR", + "fundamentals": { + "market_cap": 6258120000000, + "pe_ratio": 16.77, + "forward_pe": 15.48, + "eps": 1.54, + "book_value": 6.16, + "price_to_book": 4.19, + "beta": 0.104, + "shares_outstanding": 242000000000, + "float_shares": 5969578000, + "week_high": 26.10, + "week_low": 25.40, + "month_high": 27.20, + "month_low": 24.80, + "fifty_two_week_high": 27.85, + "fifty_two_week_low": 23.04, + }, + "technicals": { + "rsi_14": 55.3, + "macd_line": 0.12, + "macd_signal": 0.08, + "macd_histogram": 0.04, + "fifty_day_average": 26.1, + "technical_strength": 0.65, + "price_direction": "bullish", + "updated_at": "2026-01-28T10:00:00+03:00", + }, + "valuation": { + "fair_price": 28.50, + "fair_price_confidence": 0.85, + "calculated_at": "2026-01-28T10:00:00+03:00", + }, + "analysts": { + "target_mean": 29.5, + "target_median": 29.0, + "target_high": 35.0, + "target_low": 24.0, + "consensus": "buy", + "consensus_score": 2.1, + "num_analysts": 15, + }, } @pytest.fixture def sample_financials_response(): - """Sample financials API response.""" + """Sample financials API response (matches live API shape).""" return { "symbol": "2222", - "income_statement": { - "revenue": 1500000000000, - "net_income": 490000000000, - "eps": 2.11, - }, - "balance_sheet": { - "total_assets": 2200000000000, - "total_equity": 1540000000000, - }, + "income_statements": [ + { + "report_date": "2025-09-30", + "total_revenue": 418116750000.0, + "gross_profit": 215000000000.0, + "operating_income": 180000000000.0, + "net_income": 105000000000.0, + } + ], + "balance_sheets": [ + { + "report_date": "2025-09-30", + "total_assets": 2516431000000.0, + "total_liabilities": 1026431000000.0, + "stockholders_equity": 1490000000000.0, + "total_debt": 356540000000.0, + } + ], + "cash_flows": [ + { + "report_date": "2025-09-30", + "operating_cash_flow": 135375000000.0, + "investing_cash_flow": -45000000000.0, + "financing_cash_flow": -82337000000.0, + "free_cash_flow": 88500000000.0, + } + ], } @pytest.fixture def sample_dividends_response(): - """Sample dividends API response.""" + """Sample dividends API response (matches live API shape).""" return { "symbol": "2222", - "dividend_yield": 3.85, - "payout_ratio": 0.67, + "current_price": 25.64, + "trailing_12m_yield": 4.2, + "trailing_12m_dividends": 1.60, + "payments_last_year": 4, + "upcoming": [ + { + "value": 0.40, + "period": "Q4", + "eligibility_date": "2026-03-15", + "distribution_date": "2026-04-01", + } + ], "history": [ - {"date": "2024-06-15", "amount": 0.3198, "type": "quarterly"}, - {"date": "2024-03-15", "amount": 0.3198, "type": "quarterly"}, + { + "value": 0.40, + "value_percent": 1.5, + "period": "Q3", + "fiscal_year": 2025, + "announcement_date": "2025-09-01", + "eligibility_date": "2025-09-15", + "distribution_date": "2025-10-01", + } ], } @pytest.fixture def sample_events_response(): - """Sample events API response.""" + """Sample events API response (matches live API shape).""" return { "events": [ { - "id": "evt_123", "symbol": "2222", - "type": "earnings", - "title": "إعلان النتائج المالية", - "date": "2024-08-10", - "summary": "أرامكو تعلن عن ارتفاع الأرباح", + "stock_name": "أرامكو السعودية", + "event_type": "FINANCIAL_REPORT", + "importance": "important", + "sentiment": "positive", + "description": "أرامكو تعلن عن ارتفاع الأرباح بنسبة 13%", + "article_date": "2026-01-29T17:10:06+00:00", + "created_at": "2026-01-29T17:10:12+00:00", } ], "count": 1, - "available_types": ["earnings", "dividend", "news", "technical"], + "available_types": [ + "FINANCIAL_REPORT", "DIVIDEND_ANNOUNCEMENT", "STOCK_SPLIT", + "MERGER_ACQUISITION", "MANAGEMENT_CHANGE", + ], } diff --git a/tests/test_cli.py b/tests/test_cli.py index f7ea60e..720527b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -24,21 +24,20 @@ def test_parser_creation(self): def test_parser_global_flags(self): """Test global flags are recognized.""" parser = _build_parser() - - # Test with API key + args = parser.parse_args(["--api-key", "test_key", "quote", "2222"]) assert args.api_key == "test_key" - - # Test with base URL + args = parser.parse_args(["--base-url", "https://api.test", "quote", "2222"]) assert args.base_url == "https://api.test" - - # Test with timeout + args = parser.parse_args(["--timeout", "60", "quote", "2222"]) assert args.timeout == 60 - - # Test with compact - args = parser.parse_args(["--compact", "quote", "2222"]) + + def test_parser_compact_flag_after_subcommand(self): + """Test --compact works after the subcommand.""" + parser = _build_parser() + args = parser.parse_args(["quote", "2222", "--compact"]) assert args.compact is True def test_parser_quote_command(self): @@ -167,7 +166,7 @@ def test_quote_success(self, capsys, sample_quote_response): assert exit_code == 0 captured = capsys.readouterr() assert "2222" in captured.out - assert "Saudi Aramco" in captured.out + assert "Saudi Arabian Oil Co" in captured.out @responses.activate def test_quote_error(self, capsys): @@ -259,7 +258,7 @@ def test_market_summary(self, capsys, sample_market_summary_response, monkeypatc assert exit_code == 0 captured = capsys.readouterr() - assert "TASI" in captured.out + assert "11950.35" in captured.out @responses.activate def test_market_gainers(self, capsys, sample_gainers_response, monkeypatch): @@ -444,7 +443,7 @@ class TestMainCompactOutput: @responses.activate def test_compact_output(self, capsys, sample_quote_response): - """Test --compact flag produces compact JSON.""" + """Test --compact produces compact JSON.""" responses.add( responses.GET, "https://app.sahmk.sa/api/v1/quote/2222/", @@ -452,13 +451,28 @@ def test_compact_output(self, capsys, sample_quote_response): status=200, ) - exit_code = main(["--api-key", "test_key", "--compact", "quote", "2222"]) + exit_code = main(["--api-key", "test_key", "quote", "2222", "--compact"]) assert exit_code == 0 captured = capsys.readouterr() - # Compact output should not have indentation assert "\n " not in captured.out + @responses.activate + def test_non_compact_output(self, capsys, sample_quote_response): + """Test default (non-compact) output is indented.""" + responses.add( + responses.GET, + "https://app.sahmk.sa/api/v1/quote/2222/", + json=sample_quote_response, + status=200, + ) + + exit_code = main(["--api-key", "test_key", "quote", "2222"]) + + assert exit_code == 0 + captured = capsys.readouterr() + assert "\n " in captured.out + class TestMainCompanyCommand: """Tests for main function with company command.""" @@ -479,7 +493,7 @@ def test_company_success(self, capsys, sample_company_response, monkeypatch): assert exit_code == 0 captured = capsys.readouterr() assert "2222" in captured.out - assert "Saudi Arabian Oil Company" in captured.out + assert "Saudi Arabian Oil Co" in captured.out class TestMainFinancialsCommand: @@ -500,7 +514,7 @@ def test_financials_success(self, capsys, sample_financials_response, monkeypatc assert exit_code == 0 captured = capsys.readouterr() - assert "income_statement" in captured.out + assert "income_statements" in captured.out class TestMainDividendsCommand: @@ -521,7 +535,7 @@ def test_dividends_success(self, capsys, sample_dividends_response, monkeypatch) assert exit_code == 0 captured = capsys.readouterr() - assert "dividend_yield" in captured.out + assert "trailing_12m_yield" in captured.out class TestMainEventsCommand: diff --git a/tests/test_client.py b/tests/test_client.py index 933c29d..e9255ce 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -126,6 +126,23 @@ def test_request_api_error_without_json(self, mock_client): assert exc_info.value.error_code == "UNKNOWN" assert "API error 500" in str(exc_info.value) + @responses.activate + def test_request_non_json_200_response(self, mock_client): + """Test handling of non-JSON 200 response (e.g. proxy HTML).""" + responses.add( + responses.GET, + f"{mock_client.base_url}/quote/2222/", + body="Bad Gateway", + status=200, + content_type="text/html", + ) + + with pytest.raises(SahmkError) as exc_info: + mock_client._request("GET", "/quote/2222/") + + assert "non-json response" in str(exc_info.value).lower() + assert exc_info.value.status_code == 200 + def test_request_network_error(self, mock_client): """Test handling of network/request errors.""" # Create a client with a URL that won't resolve @@ -161,7 +178,7 @@ def test_quote_success(self, mock_client, sample_quote_response): assert result["symbol"] == "2222" assert result["price"] == 32.45 - assert result["name_en"] == "Saudi Aramco" + assert result["name_en"] == "Saudi Arabian Oil Co" @responses.activate def test_quote_different_symbol(self, mock_client, sample_quote_response): @@ -213,6 +230,11 @@ def test_quotes_url_params(self, mock_client, sample_quotes_response): request = responses.calls[0].request assert "symbols=2222%2C1120%2C2010" in request.url or "symbols=2222,1120,2010" in request.url + def test_quotes_empty_list(self, mock_client): + """Test that empty symbol list raises ValueError.""" + with pytest.raises(ValueError, match="At least one symbol"): + mock_client.quotes([]) + def test_quotes_too_many_symbols(self, mock_client): """Test that more than 50 symbols raises error.""" too_many_symbols = [str(i) for i in range(51)] @@ -296,8 +318,8 @@ def test_market_summary(self, mock_client, sample_market_summary_response): result = mock_client.market_summary() - assert result["index"] == "TASI" assert result["index_value"] == 11950.35 + assert result["index_change"] == 125.40 assert result["market_mood"] == "bullish" @responses.activate @@ -422,8 +444,8 @@ def test_company(self, mock_client, sample_company_response): result = mock_client.company("2222") assert result["symbol"] == "2222" - assert result["name_en"] == "Saudi Arabian Oil Company" - assert "market_cap" in result + assert result["name_en"] == "Saudi Arabian Oil Co" + assert "fundamentals" in result @responses.activate def test_financials(self, mock_client, sample_financials_response): @@ -437,9 +459,9 @@ def test_financials(self, mock_client, sample_financials_response): result = mock_client.financials("2222") - assert "income_statement" in result - assert "balance_sheet" in result - assert result["income_statement"]["eps"] == 2.11 + assert "income_statements" in result + assert "balance_sheets" in result + assert result["income_statements"][0]["total_revenue"] == 418116750000.0 @responses.activate def test_dividends(self, mock_client, sample_dividends_response): @@ -453,9 +475,9 @@ def test_dividends(self, mock_client, sample_dividends_response): result = mock_client.dividends("2222") - assert "dividend_yield" in result + assert "trailing_12m_yield" in result assert "history" in result - assert len(result["history"]) == 2 + assert len(result["history"]) == 1 class TestEventsEndpoint: diff --git a/tests/test_integration.py b/tests/test_integration.py index 6c04214..2e8ee09 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -147,9 +147,9 @@ def test_market_summary(self, live_client): """Test getting market summary.""" result = live_client.market_summary() - assert "index" in result or "index_value" in result - assert "change" in result or "change_percent" in result - assert "volume" in result or "value" in result + assert "index_value" in result + assert "index_change" in result or "index_change_percent" in result + assert "total_volume" in result print(f"\nMarket Summary:") print(f" Index: {result.get('index_value', 'N/A')}") @@ -257,7 +257,7 @@ def test_financials(self, live_client): result = live_client.financials("2222") assert "symbol" in result - assert "income_statement" in result or "balance_sheet" in result or "cash_flow" in result + assert "income_statements" in result or "balance_sheets" in result or "cash_flows" in result print(f"\nFinancials for 2222 retrieved") if "income_statement" in result: diff --git a/tests/test_retry.py b/tests/test_retry.py index 0aab04e..90fc213 100644 --- a/tests/test_retry.py +++ b/tests/test_retry.py @@ -7,7 +7,6 @@ import requests import responses from sahmk import SahmkClient, SahmkError, SahmkRateLimitError -from sahmk.client import _RETRIABLE_STATUS_CODES @pytest.fixture