Skip to content

Commit cbce010

Browse files
committed
v0.8.0: Add company directory symbol discovery endpoint
Introduce companies() as the canonical symbol discovery path with validated pagination, market normalization, and full docs/tests coverage so quote/company workflows can start from verified symbols.
1 parent abeaa3d commit cbce010

7 files changed

Lines changed: 214 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,23 @@ 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.8.0] — 2026-04-24
8+
9+
### Added
10+
11+
- New `companies()` SDK method for canonical symbol discovery via `GET /companies/`, with support for `search`, `market`, `limit`, and `offset`
12+
- Discovery-first README guidance and examples for symbol/name search, market filtering, and offset-based pagination before calling `quote()`/`company()`
13+
- Integration coverage for live company directory response shape
14+
15+
### Changed
16+
17+
- Company directory market filter now uses the same normalization rules as market endpoints (`TASI`/`NOMU`, `NOMUC` alias to `NOMU`)
18+
- API reference docs now include `GET /companies/` as the symbol discovery endpoint
19+
20+
### Fixed
21+
22+
- Client-side pagination validation for company discovery now enforces `limit > 0` and `offset >= 0` before request dispatch
23+
724
## [0.7.0] — 2026-04-18
825

926
### Added

README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Use one client for live Tadawul quotes, market-level insights, company/fundament
1010
- **Batch quotes** for up to 50 symbols per request
1111
- **Historical OHLCV** data with date-range support
1212
- **Market overview** with index scoping (`TASI`/`NOMU`)
13+
- **Company directory** endpoint for symbol discovery
1314
- **Company/fundamental** data (plan-dependent fields)
1415
- **Financials, dividends, and events** endpoints (by plan)
1516
- **WebSocket streaming** for real-time updates (Pro+)
@@ -89,6 +90,44 @@ print(quote.resolved_symbol) # 2222
8990
print(quote.resolution.matched_by) # alias (if provided by API)
9091
```
9192

93+
## Company Directory / Symbol Discovery
94+
95+
Use `companies()` as the canonical symbol-discovery path before calling
96+
`quote()` or `company()`.
97+
98+
```python
99+
# Search by symbol or name
100+
directory = client.companies(search="aram")
101+
for row in directory["results"]:
102+
print(row["symbol"], row.get("name_en") or row.get("name"))
103+
```
104+
105+
```python
106+
# Filter by market (TASI / NOMU, NOMUC alias is accepted)
107+
nomu_companies = client.companies(market="NOMUC", limit=20)
108+
print(nomu_companies["count"])
109+
```
110+
111+
```python
112+
# Pagination loop with offset
113+
offset = 0
114+
page_size = 100
115+
116+
while True:
117+
page = client.companies(limit=page_size, offset=offset)
118+
for company in page["results"]:
119+
print(company["symbol"])
120+
121+
offset += page_size
122+
if offset >= page["total"]:
123+
break
124+
```
125+
126+
Recommended flow:
127+
128+
1. Discover valid symbols with `companies()`.
129+
2. Call `quote(symbol)` / `company(symbol)` with a validated symbol.
130+
92131
## Production Reliability
93132

94133
- The client retries transient failures: **HTTP 429** and **5xx** errors.
@@ -171,6 +210,7 @@ Base URL: `https://app.sahmk.sa/api/v1`
171210
| `GET /market/volume/` | Free | Volume leaders |
172211
| `GET /market/value/` | Free | Value leaders |
173212
| `GET /market/sectors/` | Free | Sector performance |
213+
| `GET /companies/` | Free | Company directory and symbol discovery |
174214
| `GET /company/{symbol}/` | Free+ | Company info (tiered by plan) |
175215
| `GET /financials/{symbol}/` | Starter+ | Financial statements |
176216
| `GET /dividends/{symbol}/` | Starter+ | Dividend history and yield |

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.7.0"
7+
version = "0.8.0"
88
description = "Official Python SDK for Sahmk — Saudi market data and richer market workflows for developers."
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
@@ -35,7 +35,7 @@
3535
Liquidity,
3636
)
3737

38-
__version__ = "0.7.0"
38+
__version__ = "0.8.0"
3939
__all__ = [
4040
"SahmkClient",
4141
"SahmkError",

sahmk/client.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,30 @@ def _market_params(self, limit=None, index=None):
375375
params["index"] = normalized_index
376376
return params or None
377377

378+
@staticmethod
379+
def _validate_limit_offset(limit, offset):
380+
"""Validate shared pagination arguments."""
381+
if isinstance(limit, bool) or not isinstance(limit, int):
382+
raise ValueError("limit must be an integer greater than 0")
383+
if limit <= 0:
384+
raise ValueError("limit must be greater than 0")
385+
386+
if isinstance(offset, bool) or not isinstance(offset, int):
387+
raise ValueError("offset must be an integer greater than or equal to 0")
388+
if offset < 0:
389+
raise ValueError("offset must be greater than or equal to 0")
390+
391+
def _companies_params(self, search=None, market=None, limit=100, offset=0):
392+
"""Build validated query params for the company directory endpoint."""
393+
self._validate_limit_offset(limit=limit, offset=offset)
394+
params = {"limit": limit, "offset": offset}
395+
if search is not None:
396+
params["search"] = search
397+
normalized_market = self._normalize_market_index(market)
398+
if normalized_market is not None:
399+
params["market"] = normalized_market
400+
return params
401+
378402
# -------------------------------------------------------------------------
379403
# Quotes
380404
# -------------------------------------------------------------------------
@@ -596,6 +620,31 @@ def sectors(self, index=None):
596620
# Company Data
597621
# -------------------------------------------------------------------------
598622

623+
def companies(self, search=None, market=None, limit=100, offset=0):
624+
"""
625+
Discover listed companies/symbols for quote/company workflows.
626+
627+
Args:
628+
search: Optional symbol/name search string.
629+
market: Optional market filter ("TASI" or "NOMU"). "NOMUC" alias
630+
is accepted and normalized to "NOMU".
631+
limit: Page size (must be > 0, default: 100).
632+
offset: Pagination offset (must be >= 0, default: 0).
633+
634+
Returns:
635+
Raw API response with keys such as results/count/total/limit/offset.
636+
"""
637+
return self._request(
638+
"GET",
639+
"/companies/",
640+
params=self._companies_params(
641+
search=search,
642+
market=market,
643+
limit=limit,
644+
offset=offset,
645+
),
646+
)
647+
599648
def company(self, symbol):
600649
"""
601650
Get company info. Response varies by plan:

tests/test_client.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,94 @@ def test_market_methods_invalid_index_raises(self, mock_client):
666666
class TestCompanyEndpoints:
667667
"""Tests for company data endpoints."""
668668

669+
@responses.activate
670+
def test_companies_with_search_market_and_pagination(self, mock_client):
671+
"""Test company directory query serialization with all parameters."""
672+
responses.add(
673+
responses.GET,
674+
f"{mock_client.base_url}/companies/",
675+
json={
676+
"results": [{"symbol": "2222", "name_en": "Saudi Aramco"}],
677+
"count": 1,
678+
"total": 1,
679+
"limit": 25,
680+
"offset": 50,
681+
},
682+
status=200,
683+
)
684+
685+
result = mock_client.companies(
686+
search="aram",
687+
market="tasi",
688+
limit=25,
689+
offset=50,
690+
)
691+
692+
assert result["count"] == 1
693+
request = responses.calls[0].request.url
694+
assert "search=aram" in request
695+
assert "market=TASI" in request
696+
assert "limit=25" in request
697+
assert "offset=50" in request
698+
699+
@responses.activate
700+
def test_companies_market_alias_nomuc_normalizes_to_nomu(self, mock_client):
701+
"""Test NOMUC market alias is normalized to NOMU for company discovery."""
702+
responses.add(
703+
responses.GET,
704+
f"{mock_client.base_url}/companies/",
705+
json={
706+
"results": [{"symbol": "9510", "name_en": "Nomu Co"}],
707+
"count": 1,
708+
"total": 1,
709+
"limit": 10,
710+
"offset": 0,
711+
},
712+
status=200,
713+
)
714+
715+
result = mock_client.companies(market="NOMUC", limit=10, offset=0)
716+
717+
assert result["count"] == 1
718+
request = responses.calls[0].request.url
719+
assert "market=NOMU" in request
720+
721+
def test_companies_invalid_limit_raises(self, mock_client):
722+
"""Test company directory rejects non-positive limits."""
723+
with pytest.raises(ValueError, match="limit must be greater than 0"):
724+
mock_client.companies(limit=0)
725+
with pytest.raises(ValueError, match="limit must be an integer"):
726+
mock_client.companies(limit="100")
727+
728+
def test_companies_invalid_offset_raises(self, mock_client):
729+
"""Test company directory rejects negative/non-integer offsets."""
730+
with pytest.raises(ValueError, match="offset must be greater than or equal to 0"):
731+
mock_client.companies(offset=-1)
732+
with pytest.raises(ValueError, match="offset must be an integer"):
733+
mock_client.companies(offset="0")
734+
735+
@responses.activate
736+
def test_companies_surfaces_api_errors_with_status_and_code(self, mock_client):
737+
"""Test company directory surfaces API error details clearly."""
738+
responses.add(
739+
responses.GET,
740+
f"{mock_client.base_url}/companies/",
741+
json={
742+
"error": {
743+
"code": "INVALID_PARAM",
744+
"message": "unsupported filter combination",
745+
}
746+
},
747+
status=400,
748+
)
749+
750+
with pytest.raises(SahmkError) as exc_info:
751+
mock_client.companies(search="*", market="TASI")
752+
753+
assert exc_info.value.status_code == 400
754+
assert exc_info.value.error_code == "INVALID_PARAM"
755+
assert "unsupported filter combination" in str(exc_info.value)
756+
669757
@responses.activate
670758
def test_company(self, mock_client, sample_company_response):
671759
"""Test getting company info."""

tests/test_integration.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,24 @@ def test_sectors(self, live_client):
238238
class TestLiveCompanyData:
239239
"""Live tests for company data endpoints."""
240240

241+
def test_companies_directory(self, live_client):
242+
"""Test company directory endpoint for symbol discovery."""
243+
result = live_client.companies(search="aram", limit=5, offset=0)
244+
245+
assert "results" in result
246+
assert "count" in result
247+
assert "total" in result
248+
assert "limit" in result
249+
assert "offset" in result
250+
assert isinstance(result["results"], list)
251+
assert result["limit"] == 5
252+
assert result["offset"] == 0
253+
254+
print(
255+
f"\nCompanies directory: {result.get('count', 0)} returned, "
256+
f"{result.get('total', 0)} total"
257+
)
258+
241259
def test_company_info(self, live_client):
242260
"""Test getting company information."""
243261
result = live_client.company("2222")

0 commit comments

Comments
 (0)