publicsgdata 0.1.0__tar.gz
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.
- publicsgdata-0.1.0/.gitignore +21 -0
- publicsgdata-0.1.0/CHANGELOG.md +19 -0
- publicsgdata-0.1.0/LICENSE +21 -0
- publicsgdata-0.1.0/PKG-INFO +130 -0
- publicsgdata-0.1.0/README.md +95 -0
- publicsgdata-0.1.0/pyproject.toml +104 -0
- publicsgdata-0.1.0/src/publicsgdata/__init__.py +23 -0
- publicsgdata-0.1.0/src/publicsgdata/_base_client.py +138 -0
- publicsgdata-0.1.0/src/publicsgdata/_constants.py +17 -0
- publicsgdata-0.1.0/src/publicsgdata/_exceptions.py +38 -0
- publicsgdata-0.1.0/src/publicsgdata/_pagination.py +150 -0
- publicsgdata-0.1.0/src/publicsgdata/datagovsg/__init__.py +6 -0
- publicsgdata-0.1.0/src/publicsgdata/datagovsg/_request.py +65 -0
- publicsgdata-0.1.0/src/publicsgdata/datagovsg/async_client.py +73 -0
- publicsgdata-0.1.0/src/publicsgdata/datagovsg/client.py +73 -0
- publicsgdata-0.1.0/src/publicsgdata/datagovsg/models/__init__.py +29 -0
- publicsgdata-0.1.0/src/publicsgdata/datagovsg/models/common.py +141 -0
- publicsgdata-0.1.0/src/publicsgdata/datagovsg/resources/collections.py +43 -0
- publicsgdata-0.1.0/src/publicsgdata/datagovsg/resources/datasets.py +248 -0
- publicsgdata-0.1.0/src/publicsgdata/datagovsg/resources/realtime/__init__.py +11 -0
- publicsgdata-0.1.0/src/publicsgdata/datagovsg/resources/realtime/pm25.py +58 -0
- publicsgdata-0.1.0/tests/conftest.py +51 -0
- publicsgdata-0.1.0/tests/fixtures/collection_metadata.json +13 -0
- publicsgdata-0.1.0/tests/fixtures/collections_list.json +18 -0
- publicsgdata-0.1.0/tests/fixtures/dataset_rows.json +24 -0
- publicsgdata-0.1.0/tests/fixtures/datastore_search.json +14 -0
- publicsgdata-0.1.0/tests/fixtures/pm25.json +14 -0
- publicsgdata-0.1.0/tests/test_async_datagovsg.py +15 -0
- publicsgdata-0.1.0/tests/test_datagovsg.py +37 -0
- publicsgdata-0.1.0/tests/test_integration_datagovsg.py +74 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
*$py.class
|
|
4
|
+
*.so
|
|
5
|
+
.Python
|
|
6
|
+
.venv/
|
|
7
|
+
venv/
|
|
8
|
+
env/
|
|
9
|
+
dist/
|
|
10
|
+
build/
|
|
11
|
+
*.egg-info/
|
|
12
|
+
.mypy_cache/
|
|
13
|
+
.ruff_cache/
|
|
14
|
+
.pytest_cache/
|
|
15
|
+
.coverage
|
|
16
|
+
coverage.xml
|
|
17
|
+
htmlcov/
|
|
18
|
+
*.log
|
|
19
|
+
.DS_Store
|
|
20
|
+
.idea/
|
|
21
|
+
.vscode/
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
4
|
+
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - 2026-06-09
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `DataGovSGClient` and `AsyncDataGovSGClient`: sync and async, with optional custom `httpx` clients
|
|
12
|
+
- `collections.list()` and `collections.get_metadata()`
|
|
13
|
+
- `datasets.list()`, `get_metadata()`, `list_rows()`, `iter_rows()`, and CKAN `search()`
|
|
14
|
+
- `realtime.pm25.get()` for PM2.5 readings
|
|
15
|
+
- Pydantic v2 response models
|
|
16
|
+
- Optional `DATA_GOV_SG_API_KEY` auth via `x-api-key` header
|
|
17
|
+
- CI, release-please, and PyPI publish workflows
|
|
18
|
+
|
|
19
|
+
[0.1.0]: https://github.com/publicsgdata/publicsgdata/releases/tag/v0.1.0
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Harry
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: publicsgdata
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for Singapore government open data (data.gov.sg, LTA, OneMap)
|
|
5
|
+
Project-URL: Homepage, https://github.com/publicsgdata/publicsgdata
|
|
6
|
+
Project-URL: Repository, https://github.com/publicsgdata/publicsgdata
|
|
7
|
+
Project-URL: Documentation, https://github.com/publicsgdata/publicsgdata#readme
|
|
8
|
+
Project-URL: Issues, https://github.com/publicsgdata/publicsgdata/issues
|
|
9
|
+
Author: publicsgdata contributors
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: api,data.gov.sg,open-data,sdk,singapore
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: httpx<1,>=0.27.0
|
|
25
|
+
Requires-Dist: pydantic<3,>=2.0
|
|
26
|
+
Requires-Dist: typing-extensions<5,>=4.8
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: build>=1.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
33
|
+
Requires-Dist: ruff>=0.8; extra == 'dev'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# publicsgdata
|
|
37
|
+
|
|
38
|
+
[](LICENSE)
|
|
39
|
+
|
|
40
|
+
Python client for Singapore government open data: data.gov.sg today, LTA and OneMap later.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
Requires [uv](https://docs.astral.sh/uv/getting-started/installation/).
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
uv pip install publicsgdata
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Quickstart
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from publicsgdata import DataGovSGClient
|
|
54
|
+
|
|
55
|
+
with DataGovSGClient() as client: # optional: api_key="..." or DATA_GOV_SG_API_KEY
|
|
56
|
+
catalog = client.collections.list()
|
|
57
|
+
print(f"{len(catalog.collections)} collections")
|
|
58
|
+
print(catalog.collections[0].name)
|
|
59
|
+
|
|
60
|
+
# HDB resale prices (swap in any dataset ID)
|
|
61
|
+
rows = client.datasets.list_rows("d_8b84c4ee58e3cfc0ece0d773c8ca6abc", limit=10)
|
|
62
|
+
for row in rows.rows:
|
|
63
|
+
print(row.model_dump())
|
|
64
|
+
|
|
65
|
+
pm25 = client.realtime.pm25.get()
|
|
66
|
+
print(pm25.items[0].readings)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Async
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from publicsgdata import AsyncDataGovSGClient
|
|
73
|
+
|
|
74
|
+
async with AsyncDataGovSGClient() as client:
|
|
75
|
+
rows = await client.datasets.list_rows("d_8b84c4ee58e3cfc0ece0d773c8ca6abc", limit=5)
|
|
76
|
+
print(len(rows.rows))
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Custom HTTP client
|
|
80
|
+
|
|
81
|
+
Pass your own `httpx` client if you need custom timeouts, proxies, etc.
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
import httpx
|
|
85
|
+
from publicsgdata import DataGovSGClient
|
|
86
|
+
|
|
87
|
+
with httpx.Client(timeout=30.0) as http:
|
|
88
|
+
client = DataGovSGClient(http_client=http)
|
|
89
|
+
print(len(client.collections.list().collections))
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Authentication
|
|
93
|
+
|
|
94
|
+
You can call the API without a key while experimenting. For regular use, get a key from [data.gov.sg](https://data.gov.sg/) and set:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
export DATA_GOV_SG_API_KEY="your-key"
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Environment variables
|
|
101
|
+
|
|
102
|
+
| Variable | Required | Description |
|
|
103
|
+
|---|---|---|
|
|
104
|
+
| `DATA_GOV_SG_API_KEY` | No | data.gov.sg API key (`x-api-key` header) |
|
|
105
|
+
|
|
106
|
+
## Development
|
|
107
|
+
|
|
108
|
+
You'll need [uv](https://docs.astral.sh/uv/getting-started/installation/).
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
./scripts/dev_setup.sh # creates .venv from uv.lock
|
|
112
|
+
./scripts/format.sh
|
|
113
|
+
./scripts/validate.sh
|
|
114
|
+
./scripts/test.sh # unit tests, runs in CI
|
|
115
|
+
./scripts/test_integration.sh # hits the real API, local only
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Or run things directly: `uv run pytest`, `uv run ruff check .`, etc.
|
|
119
|
+
|
|
120
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) if you're opening a PR.
|
|
121
|
+
|
|
122
|
+
## Roadmap
|
|
123
|
+
|
|
124
|
+
- **v0.1.0**: `DataGovSGClient`
|
|
125
|
+
- **v0.2.0**: `LTAClient` (LTA DataMall)
|
|
126
|
+
- **v0.3.0**: `OneMapClient`
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# publicsgdata
|
|
2
|
+
|
|
3
|
+
[](LICENSE)
|
|
4
|
+
|
|
5
|
+
Python client for Singapore government open data: data.gov.sg today, LTA and OneMap later.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Requires [uv](https://docs.astral.sh/uv/getting-started/installation/).
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
uv pip install publicsgdata
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quickstart
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from publicsgdata import DataGovSGClient
|
|
19
|
+
|
|
20
|
+
with DataGovSGClient() as client: # optional: api_key="..." or DATA_GOV_SG_API_KEY
|
|
21
|
+
catalog = client.collections.list()
|
|
22
|
+
print(f"{len(catalog.collections)} collections")
|
|
23
|
+
print(catalog.collections[0].name)
|
|
24
|
+
|
|
25
|
+
# HDB resale prices (swap in any dataset ID)
|
|
26
|
+
rows = client.datasets.list_rows("d_8b84c4ee58e3cfc0ece0d773c8ca6abc", limit=10)
|
|
27
|
+
for row in rows.rows:
|
|
28
|
+
print(row.model_dump())
|
|
29
|
+
|
|
30
|
+
pm25 = client.realtime.pm25.get()
|
|
31
|
+
print(pm25.items[0].readings)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Async
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from publicsgdata import AsyncDataGovSGClient
|
|
38
|
+
|
|
39
|
+
async with AsyncDataGovSGClient() as client:
|
|
40
|
+
rows = await client.datasets.list_rows("d_8b84c4ee58e3cfc0ece0d773c8ca6abc", limit=5)
|
|
41
|
+
print(len(rows.rows))
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Custom HTTP client
|
|
45
|
+
|
|
46
|
+
Pass your own `httpx` client if you need custom timeouts, proxies, etc.
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import httpx
|
|
50
|
+
from publicsgdata import DataGovSGClient
|
|
51
|
+
|
|
52
|
+
with httpx.Client(timeout=30.0) as http:
|
|
53
|
+
client = DataGovSGClient(http_client=http)
|
|
54
|
+
print(len(client.collections.list().collections))
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Authentication
|
|
58
|
+
|
|
59
|
+
You can call the API without a key while experimenting. For regular use, get a key from [data.gov.sg](https://data.gov.sg/) and set:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
export DATA_GOV_SG_API_KEY="your-key"
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Environment variables
|
|
66
|
+
|
|
67
|
+
| Variable | Required | Description |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| `DATA_GOV_SG_API_KEY` | No | data.gov.sg API key (`x-api-key` header) |
|
|
70
|
+
|
|
71
|
+
## Development
|
|
72
|
+
|
|
73
|
+
You'll need [uv](https://docs.astral.sh/uv/getting-started/installation/).
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
./scripts/dev_setup.sh # creates .venv from uv.lock
|
|
77
|
+
./scripts/format.sh
|
|
78
|
+
./scripts/validate.sh
|
|
79
|
+
./scripts/test.sh # unit tests, runs in CI
|
|
80
|
+
./scripts/test_integration.sh # hits the real API, local only
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Or run things directly: `uv run pytest`, `uv run ruff check .`, etc.
|
|
84
|
+
|
|
85
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) if you're opening a PR.
|
|
86
|
+
|
|
87
|
+
## Roadmap
|
|
88
|
+
|
|
89
|
+
- **v0.1.0**: `DataGovSGClient`
|
|
90
|
+
- **v0.2.0**: `LTAClient` (LTA DataMall)
|
|
91
|
+
- **v0.3.0**: `OneMapClient`
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "publicsgdata"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Python client for Singapore government open data (data.gov.sg, LTA, OneMap)"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.10"
|
|
8
|
+
authors = [{ name = "publicsgdata contributors" }]
|
|
9
|
+
keywords = ["singapore", "open-data", "data.gov.sg", "api", "sdk"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.10",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"httpx>=0.27.0,<1",
|
|
24
|
+
"pydantic>=2.0,<3",
|
|
25
|
+
"typing-extensions>=4.8,<5",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
dev = [
|
|
30
|
+
"build>=1.0",
|
|
31
|
+
"mypy>=1.8",
|
|
32
|
+
"pytest>=8.0",
|
|
33
|
+
"pytest-asyncio>=0.24",
|
|
34
|
+
"pytest-cov>=5.0",
|
|
35
|
+
"ruff>=0.8",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[dependency-groups]
|
|
39
|
+
dev = [
|
|
40
|
+
"build>=1.0",
|
|
41
|
+
"mypy>=1.8",
|
|
42
|
+
"pytest>=8.0",
|
|
43
|
+
"pytest-asyncio>=0.24",
|
|
44
|
+
"pytest-cov>=5.0",
|
|
45
|
+
"ruff>=0.8",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[tool.uv]
|
|
49
|
+
default-groups = ["dev"]
|
|
50
|
+
|
|
51
|
+
[project.urls]
|
|
52
|
+
Homepage = "https://github.com/publicsgdata/publicsgdata"
|
|
53
|
+
Repository = "https://github.com/publicsgdata/publicsgdata"
|
|
54
|
+
Documentation = "https://github.com/publicsgdata/publicsgdata#readme"
|
|
55
|
+
Issues = "https://github.com/publicsgdata/publicsgdata/issues"
|
|
56
|
+
|
|
57
|
+
[build-system]
|
|
58
|
+
requires = ["hatchling>=1.26"]
|
|
59
|
+
build-backend = "hatchling.build"
|
|
60
|
+
|
|
61
|
+
[tool.hatch.build.targets.wheel]
|
|
62
|
+
packages = ["src/publicsgdata"]
|
|
63
|
+
|
|
64
|
+
[tool.hatch.build.targets.sdist]
|
|
65
|
+
include = [
|
|
66
|
+
"/README.md",
|
|
67
|
+
"/LICENSE",
|
|
68
|
+
"/CHANGELOG.md",
|
|
69
|
+
"/src/publicsgdata",
|
|
70
|
+
"/tests",
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
[tool.pytest.ini_options]
|
|
74
|
+
asyncio_mode = "auto"
|
|
75
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
76
|
+
testpaths = ["tests"]
|
|
77
|
+
markers = [
|
|
78
|
+
"integration: live data.gov.sg API tests (local only; use ./scripts/test_integration.sh)",
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
[tool.ruff]
|
|
82
|
+
line-length = 100
|
|
83
|
+
target-version = "py310"
|
|
84
|
+
src = ["src", "tests"]
|
|
85
|
+
|
|
86
|
+
[tool.ruff.lint]
|
|
87
|
+
select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
88
|
+
|
|
89
|
+
[tool.mypy]
|
|
90
|
+
python_version = "3.10"
|
|
91
|
+
strict = true
|
|
92
|
+
warn_return_any = true
|
|
93
|
+
warn_unused_configs = true
|
|
94
|
+
packages = ["publicsgdata"]
|
|
95
|
+
mypy_path = "src"
|
|
96
|
+
files = ["src", "tests"]
|
|
97
|
+
|
|
98
|
+
[[tool.mypy.overrides]]
|
|
99
|
+
module = ["conftest", "test_datagovsg", "test_async_datagovsg", "test_integration_datagovsg"]
|
|
100
|
+
disallow_untyped_defs = false
|
|
101
|
+
|
|
102
|
+
[tool.coverage.run]
|
|
103
|
+
source = ["publicsgdata"]
|
|
104
|
+
branch = true
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""publicsgdata: Python client for Singapore government public data."""
|
|
2
|
+
|
|
3
|
+
from publicsgdata._exceptions import (
|
|
4
|
+
APIError,
|
|
5
|
+
AuthenticationError,
|
|
6
|
+
NotFoundError,
|
|
7
|
+
PublicSGDataError,
|
|
8
|
+
RateLimitError,
|
|
9
|
+
)
|
|
10
|
+
from publicsgdata.datagovsg.async_client import AsyncDataGovSGClient
|
|
11
|
+
from publicsgdata.datagovsg.client import DataGovSGClient
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"APIError",
|
|
15
|
+
"AsyncDataGovSGClient",
|
|
16
|
+
"AuthenticationError",
|
|
17
|
+
"DataGovSGClient",
|
|
18
|
+
"NotFoundError",
|
|
19
|
+
"PublicSGDataError",
|
|
20
|
+
"RateLimitError",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from typing import Any, Literal, cast
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from publicsgdata._constants import DEFAULT_TIMEOUT, HEADER_API_KEY
|
|
9
|
+
from publicsgdata._exceptions import (
|
|
10
|
+
APIError,
|
|
11
|
+
AuthenticationError,
|
|
12
|
+
NotFoundError,
|
|
13
|
+
RateLimitError,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BaseHTTPClient:
|
|
18
|
+
"""Shared HTTP helpers for sync and async clients."""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
*,
|
|
23
|
+
api_key: str | None = None,
|
|
24
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
25
|
+
max_retries: int = 0,
|
|
26
|
+
) -> None:
|
|
27
|
+
self._api_key = api_key
|
|
28
|
+
self._timeout = timeout
|
|
29
|
+
self._max_retries = max_retries
|
|
30
|
+
self._owns_client = False
|
|
31
|
+
|
|
32
|
+
def _auth_headers(self) -> dict[str, str]:
|
|
33
|
+
if self._api_key:
|
|
34
|
+
return {HEADER_API_KEY: self._api_key}
|
|
35
|
+
return {}
|
|
36
|
+
|
|
37
|
+
def _merge_headers(self, headers: Mapping[str, str] | None = None) -> dict[str, str]:
|
|
38
|
+
merged = dict(self._auth_headers())
|
|
39
|
+
if headers:
|
|
40
|
+
merged.update(headers)
|
|
41
|
+
return merged
|
|
42
|
+
|
|
43
|
+
def _parse_json(self, response: httpx.Response) -> Any:
|
|
44
|
+
if not response.content:
|
|
45
|
+
return None
|
|
46
|
+
return response.json()
|
|
47
|
+
|
|
48
|
+
def _raise_for_response(
|
|
49
|
+
self,
|
|
50
|
+
response: httpx.Response,
|
|
51
|
+
*,
|
|
52
|
+
payload: Any | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
if response.is_success:
|
|
55
|
+
if isinstance(payload, dict):
|
|
56
|
+
code = payload.get("code")
|
|
57
|
+
if code not in (None, 0) and payload.get("success") is not True:
|
|
58
|
+
self._raise_api_payload(response, payload)
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
data = payload if payload is not None else self._parse_json(response)
|
|
62
|
+
if response.status_code == 429:
|
|
63
|
+
raise RateLimitError(
|
|
64
|
+
_error_message(data, response),
|
|
65
|
+
status_code=429,
|
|
66
|
+
code=_error_code(data),
|
|
67
|
+
name=_error_name(data),
|
|
68
|
+
body=data,
|
|
69
|
+
)
|
|
70
|
+
if response.status_code in (401, 403):
|
|
71
|
+
raise AuthenticationError(
|
|
72
|
+
_error_message(data, response),
|
|
73
|
+
status_code=response.status_code,
|
|
74
|
+
code=_error_code(data),
|
|
75
|
+
name=_error_name(data),
|
|
76
|
+
body=data,
|
|
77
|
+
)
|
|
78
|
+
if response.status_code == 404:
|
|
79
|
+
raise NotFoundError(
|
|
80
|
+
_error_message(data, response),
|
|
81
|
+
status_code=404,
|
|
82
|
+
code=_error_code(data),
|
|
83
|
+
name=_error_name(data),
|
|
84
|
+
body=data,
|
|
85
|
+
)
|
|
86
|
+
raise APIError(
|
|
87
|
+
_error_message(data, response),
|
|
88
|
+
status_code=response.status_code,
|
|
89
|
+
code=_error_code(data),
|
|
90
|
+
name=_error_name(data),
|
|
91
|
+
body=data,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def _raise_api_payload(self, response: httpx.Response, payload: dict[str, Any]) -> None:
|
|
95
|
+
message = _error_message(payload, response)
|
|
96
|
+
code = _error_code(payload)
|
|
97
|
+
name = _error_name(payload)
|
|
98
|
+
if response.status_code == 429 or code == 429:
|
|
99
|
+
raise RateLimitError(message, status_code=429, code=code, name=name, body=payload)
|
|
100
|
+
if name and "NOT_FOUND" in name.upper():
|
|
101
|
+
raise NotFoundError(message, status_code=404, code=code, name=name, body=payload)
|
|
102
|
+
raise APIError(
|
|
103
|
+
message,
|
|
104
|
+
status_code=response.status_code,
|
|
105
|
+
code=code,
|
|
106
|
+
name=name,
|
|
107
|
+
body=payload,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _error_message(data: Any, response: httpx.Response) -> str:
|
|
112
|
+
if isinstance(data, dict):
|
|
113
|
+
for key in ("errorMsg", "message", "error"):
|
|
114
|
+
value = data.get(key)
|
|
115
|
+
if isinstance(value, str) and value:
|
|
116
|
+
return value
|
|
117
|
+
if isinstance(value, dict):
|
|
118
|
+
return str(value)
|
|
119
|
+
if data.get("success") is False and isinstance(data.get("error"), dict):
|
|
120
|
+
return str(data["error"])
|
|
121
|
+
return f"HTTP {response.status_code} error for {response.request.url}"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _error_code(data: Any) -> int | str | None:
|
|
125
|
+
if isinstance(data, dict) and "code" in data:
|
|
126
|
+
return cast(int | str | None, data.get("code"))
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _error_name(data: Any) -> str | None:
|
|
131
|
+
if isinstance(data, dict) and isinstance(data.get("name"), str):
|
|
132
|
+
return cast(str, data["name"])
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
SyncHTTPClient = httpx.Client
|
|
137
|
+
AsyncHTTPClient = httpx.AsyncClient
|
|
138
|
+
HTTPMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE"]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
ENV_API_KEY = "DATA_GOV_SG_API_KEY"
|
|
6
|
+
HEADER_API_KEY = "x-api-key"
|
|
7
|
+
|
|
8
|
+
DEFAULT_TIMEOUT = 30.0
|
|
9
|
+
|
|
10
|
+
# data.gov.sg hosts
|
|
11
|
+
CATALOG_BASE_URL = "https://api-production.data.gov.sg/v2/public/api"
|
|
12
|
+
CKAN_BASE_URL = "https://data.gov.sg"
|
|
13
|
+
REALTIME_BASE_URL = "https://api-open.data.gov.sg/v2/real-time/api"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def default_api_key() -> str | None:
|
|
17
|
+
return os.environ.get(ENV_API_KEY)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PublicSGDataError(Exception):
|
|
7
|
+
"""Base exception for publicsgdata."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class APIError(PublicSGDataError):
|
|
11
|
+
"""Raised when the API returns an error response."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
message: str,
|
|
16
|
+
*,
|
|
17
|
+
status_code: int | None = None,
|
|
18
|
+
code: int | str | None = None,
|
|
19
|
+
name: str | None = None,
|
|
20
|
+
body: Any | None = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.status_code = status_code
|
|
24
|
+
self.code = code
|
|
25
|
+
self.name = name
|
|
26
|
+
self.body = body
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RateLimitError(APIError):
|
|
30
|
+
"""Raised when rate limits are exceeded (HTTP 429)."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AuthenticationError(APIError):
|
|
34
|
+
"""Raised when authentication fails (HTTP 401/403)."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class NotFoundError(APIError):
|
|
38
|
+
"""Raised when a resource is not found (HTTP 404)."""
|