powerdev 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.
powerdev-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PowerDev
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,194 @@
1
+ Metadata-Version: 2.4
2
+ Name: powerdev
3
+ Version: 0.1.0
4
+ Summary: Official Python client for the PowerDev Data API v2
5
+ Author-email: PowerDev <helpdesk@power.dev>
6
+ License-Expression: MIT
7
+ Project-URL: Documentation, https://docs.power.dev
8
+ Project-URL: Homepage, https://www.power.dev
9
+ Keywords: powerdev,energy,electricity,iso,lmp,data-api
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Topic :: Scientific/Engineering
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.11
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: httpx<1.0,>=0.27
23
+ Provides-Extra: pandas
24
+ Requires-Dist: pandas>=2.0; extra == "pandas"
25
+ Dynamic: license-file
26
+
27
+ # powerdev
28
+
29
+ Official Python client for the [PowerDev Data API v2](https://docs.power.dev). It is a thin, synchronous
30
+ wrapper: every method maps to one documented v2 endpoint, nothing is cached, retried or converted behind
31
+ your back, and the server stays the authority for validation.
32
+
33
+ - Python 3.11 or newer, one runtime dependency (`httpx`)
34
+ - Discovery-driven: catalogs, datasets, filters and columns come from the API, not from constants baked into the package
35
+ - JSON queries return a `QueryResult` with typed cells; CSV is streamed or saved as the server sends it
36
+ - Optional `pandas` extra for `to_dataframe()`
37
+ - Typed exceptions; no built-in retry
38
+
39
+ ## Installation
40
+
41
+ ```bash
42
+ pip install powerdev
43
+ pip install "powerdev[pandas]" # with DataFrame support
44
+ ```
45
+
46
+ ## Authentication
47
+
48
+ Pass the key explicitly or set `POWERDEV_API_KEY`. The key is sent only in the `x-api-key` header and never
49
+ appears in URLs, reprs, logs or exception messages.
50
+
51
+ ```python
52
+ from powerdev import PowerDevClient
53
+
54
+ with PowerDevClient(api_key="YOUR_API_KEY") as client:
55
+ print(client.get_usage())
56
+ ```
57
+
58
+ `base_url` defaults to `https://api.power.dev/v2/` and can be overridden with the `base_url` argument or the
59
+ `POWERDEV_BASE_URL` variable. Remote URLs must use HTTPS. Without a key only `get_info()` and `get_status()`
60
+ work; any other call raises `PowerDevConfigurationError` before a request is sent.
61
+
62
+ ## Discovery
63
+
64
+ ```python
65
+ catalogs = client.list_catalogs()
66
+ datasets = client.list_datasets("common", iso_or_provider_list=["SPP"], search="fuel")
67
+ info = client.get_dataset("spp_fuel_mix")
68
+
69
+ print(info["granularity"]["options"])
70
+ print(info["schema"]["default_columns"])
71
+ print([f["key"] for f in info["filters"]])
72
+ ```
73
+
74
+ Discovery methods return the `data` part of the response as plain dictionaries and lists, so new server
75
+ fields are available immediately. Fetch dataset info once per session; it does not count against the API
76
+ call quota, but calling it before every query is wasted latency.
77
+
78
+ ## Querying data
79
+
80
+ ```python
81
+ from datetime import date
82
+
83
+ result = client.query(
84
+ "spp_fuel_mix",
85
+ start_date=date(2026, 10, 21),
86
+ end_date=date(2026, 10, 22),
87
+ filters={"balancing_area": ["SPP"]},
88
+ include_columns=["interval_start", "fuel_type", "value"],
89
+ )
90
+
91
+ print(result.header_keys)
92
+ for row in result.rows:
93
+ print(row)
94
+ ```
95
+
96
+ Accepted options are the public query parameters: `start_date`, `end_date`, `publish_start`, `publish_end`,
97
+ `granularity`, `filters`, `location_ids`, `location_names`, `include_columns`, `exclude_columns`,
98
+ `row_format`, `generate_unique_hash_key`. Values are sent as you give them: `date` and `datetime` objects
99
+ become ISO strings, naive datetimes are not given a time zone, lists are repeated on GET and sent as JSON
100
+ arrays on POST, and `filters` is URL-encoded JSON on GET. Requests use GET by default; pass
101
+ `method="POST"` for long parameter lists. The client never switches methods on its own, and
102
+ `location_ids` with `location_names` or `include_columns` with `exclude_columns` is rejected locally.
103
+
104
+ `QueryResult` keeps the server structure:
105
+
106
+ | Attribute | Content |
107
+ |---|---|
108
+ | `meta`, `request` | The response `meta` and echoed `request` objects |
109
+ | `header_keys`, `header_labels`, `column_info` | Column keys, labels and metadata in response order |
110
+ | `row_format`, `rows` | `ARRAY_OF_ARRAYS` (lists) or `ARRAY_OF_OBJECTS` (dicts), as requested |
111
+ | `row_count`, `truncated`, `granularity`, `dataset_key` | Shortcuts into `meta` |
112
+ | `to_records()` | Rows as dictionaries keyed by `header_keys` |
113
+ | `to_dataframe()` | A pandas DataFrame in `header_keys` order (needs the `pandas` extra) |
114
+
115
+ Cells are converted with the column metadata: `integer` to `int`, `float` to `float`, `decimal` to
116
+ `decimal.Decimal` (no binary rounding, trailing zeros kept), `boolean` to `bool`, `date` to `datetime.date`,
117
+ `timestamp` to a timezone-aware `datetime` with the offset the server sent, `string` to `str`. `null` stays
118
+ `None`. Pass `convert=False` to keep the raw JSON values.
119
+
120
+ ## CSV
121
+
122
+ ```python
123
+ client.save_csv("spp_fuel_mix", "spp_fuel_mix.csv", start_date="2026-10-21", end_date="2026-10-22")
124
+
125
+ with client.stream_csv("spp_fuel_mix", start_date="2026-10-21", end_date="2026-10-22") as stream:
126
+ for chunk in stream.iter_bytes():
127
+ ...
128
+ ```
129
+
130
+ CSV bodies are streamed without conversion. If the server answers with an error, the JSON envelope is
131
+ parsed and raised; nothing is written to the file.
132
+
133
+ ## Locations (LMP datasets)
134
+
135
+ ```python
136
+ nodes = client.list_locations(iso_or_provider_list=["PJM"], search="western", limit=20)
137
+ node = client.get_location(nodes[0]["id"])
138
+ prices = client.query("pjm_lmp_da", start_date="2026-10-21", end_date="2026-10-22", location_ids=[node["id"]])
139
+ print(prices.meta["locations"], client.last_response.unresolved_location_count)
140
+ ```
141
+
142
+ ## Errors
143
+
144
+ All exceptions derive from `PowerDevError`:
145
+
146
+ | Exception | When |
147
+ |---|---|
148
+ | `PowerDevConfigurationError` | Missing key, invalid base URL or timeout, conflicting query options, closed client |
149
+ | `PowerDevTransportError` | DNS, connection, TLS or timeout failure, or an unexpected redirect |
150
+ | `PowerDevProtocolError` | The response does not match the v2 contract |
151
+ | `PowerDevApiError` | Any HTTP error with the shared envelope; `status_code`, `code`, `message`, `details`, `retry_after`, `headers` |
152
+
153
+ `PowerDevApiError` has one subclass per public status: `InvalidRequestError` (400), `UnauthenticatedError`
154
+ (401), `AccessDeniedError` (403), `ResourceNotFoundError` (404), `ApiVersionUnavailableError` (410),
155
+ `UsageLimitExceededError` (429), `InternalServerError` (500), `DatasetUnavailableError` (503).
156
+
157
+ ```python
158
+ from powerdev import UsageLimitExceededError
159
+
160
+ try:
161
+ result = client.query("spp_fuel_mix", start_date="2026-10-21", end_date="2026-10-22")
162
+ except UsageLimitExceededError as error:
163
+ print(error.limit_type, error.retry_after, error.details)
164
+ ```
165
+
166
+ The client makes exactly one HTTP attempt per call. If you need retries, wrap the call in your own policy
167
+ and honour `retry_after`; the client will not wait or resend for you.
168
+
169
+ Responses that carry `Deprecation` or `Sunset` headers raise a `PowerDevDeprecationWarning` and keep the
170
+ headers in `client.last_response`.
171
+
172
+ ## API key management
173
+
174
+ Credentials are passed per call and never stored. The created key is shown once in the response.
175
+
176
+ ```python
177
+ keys = client.list_api_keys(email="you@example.com", password="...")
178
+ created = client.create_api_key(email="you@example.com", password="...", key_name="etl")
179
+ client.revoke_api_key(email="you@example.com", password="...", key_id=created["key_id"])
180
+ ```
181
+
182
+ ## Transport details
183
+
184
+ - One long-lived `httpx.Client`; pass your own with `http_client=` for proxies or tests (it is not closed for you)
185
+ - Connect timeout 30 s by default (at most 180 s), read timeout 120 s; `None` disables a timeout explicitly
186
+ - Redirects are not followed; TLS verification is on
187
+ - `headers=` adds request headers, but authentication headers cannot be overridden
188
+
189
+ ## Versioning
190
+
191
+ The package follows Semantic Versioning and targets the public v2 contract. Additive server fields never
192
+ break the client because payloads are kept as raw mappings. See `CHANGELOG.md` for release notes.
193
+
194
+ Questions: helpdesk@power.dev
@@ -0,0 +1,168 @@
1
+ # powerdev
2
+
3
+ Official Python client for the [PowerDev Data API v2](https://docs.power.dev). It is a thin, synchronous
4
+ wrapper: every method maps to one documented v2 endpoint, nothing is cached, retried or converted behind
5
+ your back, and the server stays the authority for validation.
6
+
7
+ - Python 3.11 or newer, one runtime dependency (`httpx`)
8
+ - Discovery-driven: catalogs, datasets, filters and columns come from the API, not from constants baked into the package
9
+ - JSON queries return a `QueryResult` with typed cells; CSV is streamed or saved as the server sends it
10
+ - Optional `pandas` extra for `to_dataframe()`
11
+ - Typed exceptions; no built-in retry
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ pip install powerdev
17
+ pip install "powerdev[pandas]" # with DataFrame support
18
+ ```
19
+
20
+ ## Authentication
21
+
22
+ Pass the key explicitly or set `POWERDEV_API_KEY`. The key is sent only in the `x-api-key` header and never
23
+ appears in URLs, reprs, logs or exception messages.
24
+
25
+ ```python
26
+ from powerdev import PowerDevClient
27
+
28
+ with PowerDevClient(api_key="YOUR_API_KEY") as client:
29
+ print(client.get_usage())
30
+ ```
31
+
32
+ `base_url` defaults to `https://api.power.dev/v2/` and can be overridden with the `base_url` argument or the
33
+ `POWERDEV_BASE_URL` variable. Remote URLs must use HTTPS. Without a key only `get_info()` and `get_status()`
34
+ work; any other call raises `PowerDevConfigurationError` before a request is sent.
35
+
36
+ ## Discovery
37
+
38
+ ```python
39
+ catalogs = client.list_catalogs()
40
+ datasets = client.list_datasets("common", iso_or_provider_list=["SPP"], search="fuel")
41
+ info = client.get_dataset("spp_fuel_mix")
42
+
43
+ print(info["granularity"]["options"])
44
+ print(info["schema"]["default_columns"])
45
+ print([f["key"] for f in info["filters"]])
46
+ ```
47
+
48
+ Discovery methods return the `data` part of the response as plain dictionaries and lists, so new server
49
+ fields are available immediately. Fetch dataset info once per session; it does not count against the API
50
+ call quota, but calling it before every query is wasted latency.
51
+
52
+ ## Querying data
53
+
54
+ ```python
55
+ from datetime import date
56
+
57
+ result = client.query(
58
+ "spp_fuel_mix",
59
+ start_date=date(2026, 10, 21),
60
+ end_date=date(2026, 10, 22),
61
+ filters={"balancing_area": ["SPP"]},
62
+ include_columns=["interval_start", "fuel_type", "value"],
63
+ )
64
+
65
+ print(result.header_keys)
66
+ for row in result.rows:
67
+ print(row)
68
+ ```
69
+
70
+ Accepted options are the public query parameters: `start_date`, `end_date`, `publish_start`, `publish_end`,
71
+ `granularity`, `filters`, `location_ids`, `location_names`, `include_columns`, `exclude_columns`,
72
+ `row_format`, `generate_unique_hash_key`. Values are sent as you give them: `date` and `datetime` objects
73
+ become ISO strings, naive datetimes are not given a time zone, lists are repeated on GET and sent as JSON
74
+ arrays on POST, and `filters` is URL-encoded JSON on GET. Requests use GET by default; pass
75
+ `method="POST"` for long parameter lists. The client never switches methods on its own, and
76
+ `location_ids` with `location_names` or `include_columns` with `exclude_columns` is rejected locally.
77
+
78
+ `QueryResult` keeps the server structure:
79
+
80
+ | Attribute | Content |
81
+ |---|---|
82
+ | `meta`, `request` | The response `meta` and echoed `request` objects |
83
+ | `header_keys`, `header_labels`, `column_info` | Column keys, labels and metadata in response order |
84
+ | `row_format`, `rows` | `ARRAY_OF_ARRAYS` (lists) or `ARRAY_OF_OBJECTS` (dicts), as requested |
85
+ | `row_count`, `truncated`, `granularity`, `dataset_key` | Shortcuts into `meta` |
86
+ | `to_records()` | Rows as dictionaries keyed by `header_keys` |
87
+ | `to_dataframe()` | A pandas DataFrame in `header_keys` order (needs the `pandas` extra) |
88
+
89
+ Cells are converted with the column metadata: `integer` to `int`, `float` to `float`, `decimal` to
90
+ `decimal.Decimal` (no binary rounding, trailing zeros kept), `boolean` to `bool`, `date` to `datetime.date`,
91
+ `timestamp` to a timezone-aware `datetime` with the offset the server sent, `string` to `str`. `null` stays
92
+ `None`. Pass `convert=False` to keep the raw JSON values.
93
+
94
+ ## CSV
95
+
96
+ ```python
97
+ client.save_csv("spp_fuel_mix", "spp_fuel_mix.csv", start_date="2026-10-21", end_date="2026-10-22")
98
+
99
+ with client.stream_csv("spp_fuel_mix", start_date="2026-10-21", end_date="2026-10-22") as stream:
100
+ for chunk in stream.iter_bytes():
101
+ ...
102
+ ```
103
+
104
+ CSV bodies are streamed without conversion. If the server answers with an error, the JSON envelope is
105
+ parsed and raised; nothing is written to the file.
106
+
107
+ ## Locations (LMP datasets)
108
+
109
+ ```python
110
+ nodes = client.list_locations(iso_or_provider_list=["PJM"], search="western", limit=20)
111
+ node = client.get_location(nodes[0]["id"])
112
+ prices = client.query("pjm_lmp_da", start_date="2026-10-21", end_date="2026-10-22", location_ids=[node["id"]])
113
+ print(prices.meta["locations"], client.last_response.unresolved_location_count)
114
+ ```
115
+
116
+ ## Errors
117
+
118
+ All exceptions derive from `PowerDevError`:
119
+
120
+ | Exception | When |
121
+ |---|---|
122
+ | `PowerDevConfigurationError` | Missing key, invalid base URL or timeout, conflicting query options, closed client |
123
+ | `PowerDevTransportError` | DNS, connection, TLS or timeout failure, or an unexpected redirect |
124
+ | `PowerDevProtocolError` | The response does not match the v2 contract |
125
+ | `PowerDevApiError` | Any HTTP error with the shared envelope; `status_code`, `code`, `message`, `details`, `retry_after`, `headers` |
126
+
127
+ `PowerDevApiError` has one subclass per public status: `InvalidRequestError` (400), `UnauthenticatedError`
128
+ (401), `AccessDeniedError` (403), `ResourceNotFoundError` (404), `ApiVersionUnavailableError` (410),
129
+ `UsageLimitExceededError` (429), `InternalServerError` (500), `DatasetUnavailableError` (503).
130
+
131
+ ```python
132
+ from powerdev import UsageLimitExceededError
133
+
134
+ try:
135
+ result = client.query("spp_fuel_mix", start_date="2026-10-21", end_date="2026-10-22")
136
+ except UsageLimitExceededError as error:
137
+ print(error.limit_type, error.retry_after, error.details)
138
+ ```
139
+
140
+ The client makes exactly one HTTP attempt per call. If you need retries, wrap the call in your own policy
141
+ and honour `retry_after`; the client will not wait or resend for you.
142
+
143
+ Responses that carry `Deprecation` or `Sunset` headers raise a `PowerDevDeprecationWarning` and keep the
144
+ headers in `client.last_response`.
145
+
146
+ ## API key management
147
+
148
+ Credentials are passed per call and never stored. The created key is shown once in the response.
149
+
150
+ ```python
151
+ keys = client.list_api_keys(email="you@example.com", password="...")
152
+ created = client.create_api_key(email="you@example.com", password="...", key_name="etl")
153
+ client.revoke_api_key(email="you@example.com", password="...", key_id=created["key_id"])
154
+ ```
155
+
156
+ ## Transport details
157
+
158
+ - One long-lived `httpx.Client`; pass your own with `http_client=` for proxies or tests (it is not closed for you)
159
+ - Connect timeout 30 s by default (at most 180 s), read timeout 120 s; `None` disables a timeout explicitly
160
+ - Redirects are not followed; TLS verification is on
161
+ - `headers=` adds request headers, but authentication headers cannot be overridden
162
+
163
+ ## Versioning
164
+
165
+ The package follows Semantic Versioning and targets the public v2 contract. Additive server fields never
166
+ break the client because payloads are kept as raw mappings. See `CHANGELOG.md` for release notes.
167
+
168
+ Questions: helpdesk@power.dev
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "powerdev"
7
+ version = "0.1.0"
8
+ description = "Official Python client for the PowerDev Data API v2"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.11"
13
+ authors = [{ name = "PowerDev", email = "helpdesk@power.dev" }]
14
+ keywords = ["powerdev", "energy", "electricity", "iso", "lmp", "data-api"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Programming Language :: Python :: 3.14",
23
+ "Topic :: Scientific/Engineering",
24
+ "Typing :: Typed",
25
+ ]
26
+ dependencies = ["httpx>=0.27,<1.0"]
27
+
28
+ [project.optional-dependencies]
29
+ pandas = ["pandas>=2.0"]
30
+
31
+ [project.urls]
32
+ Documentation = "https://docs.power.dev"
33
+ Homepage = "https://www.power.dev"
34
+
35
+ [dependency-groups]
36
+ dev = ["pytest>=8"]
37
+
38
+ [tool.setuptools.packages.find]
39
+ where = ["src"]
40
+
41
+ [tool.setuptools.package-data]
42
+ powerdev = ["py.typed"]
43
+
44
+ [tool.pytest.ini_options]
45
+ testpaths = ["tests"]
46
+ pythonpath = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,48 @@
1
+ """Official Python client for the PowerDev Data API v2."""
2
+
3
+ from powerdev.client import PowerDevClient
4
+ from powerdev.config import PRODUCTION_BASE_URL, ClientConfig
5
+ from powerdev.exceptions import (
6
+ AccessDeniedError,
7
+ ApiVersionUnavailableError,
8
+ DatasetUnavailableError,
9
+ InternalServerError,
10
+ InvalidRequestError,
11
+ PowerDevApiError,
12
+ PowerDevConfigurationError,
13
+ PowerDevDeprecationWarning,
14
+ PowerDevError,
15
+ PowerDevProtocolError,
16
+ PowerDevTransportError,
17
+ ResourceNotFoundError,
18
+ UnauthenticatedError,
19
+ UsageLimitExceededError,
20
+ )
21
+ from powerdev.models import CsvStream, ResponseMetadata
22
+ from powerdev.result import QueryResult
23
+
24
+ __version__ = "0.1.0"
25
+
26
+ __all__ = [
27
+ "PRODUCTION_BASE_URL",
28
+ "AccessDeniedError",
29
+ "ApiVersionUnavailableError",
30
+ "ClientConfig",
31
+ "CsvStream",
32
+ "DatasetUnavailableError",
33
+ "InternalServerError",
34
+ "InvalidRequestError",
35
+ "PowerDevApiError",
36
+ "PowerDevClient",
37
+ "PowerDevConfigurationError",
38
+ "PowerDevDeprecationWarning",
39
+ "PowerDevError",
40
+ "PowerDevProtocolError",
41
+ "PowerDevTransportError",
42
+ "QueryResult",
43
+ "ResourceNotFoundError",
44
+ "ResponseMetadata",
45
+ "UnauthenticatedError",
46
+ "UsageLimitExceededError",
47
+ "__version__",
48
+ ]
@@ -0,0 +1,160 @@
1
+ """PowerDevClient: thin synchronous client over the v2 endpoints (design 14/P01, P04)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from contextlib import contextmanager
7
+ from pathlib import Path
8
+ from typing import Any, BinaryIO, Iterator, Mapping
9
+ from urllib.parse import quote
10
+
11
+ import httpx
12
+
13
+ from powerdev import query as query_encoding
14
+ from powerdev.config import DEFAULT_CONNECT_TIMEOUT, DEFAULT_READ_TIMEOUT, ClientConfig
15
+ from powerdev.models import CsvStream, ResponseMetadata
16
+ from powerdev.result import QueryResult
17
+ from powerdev.transport import Transport
18
+
19
+ CSV_FORMAT = "CSV"
20
+
21
+
22
+ class PowerDevClient:
23
+ """Synchronous client; use it as a context manager or call close() when done."""
24
+
25
+ def __init__(
26
+ self,
27
+ api_key: str | None = None,
28
+ *,
29
+ base_url: str | None = None,
30
+ connect_timeout: float | None = DEFAULT_CONNECT_TIMEOUT,
31
+ read_timeout: float | None = DEFAULT_READ_TIMEOUT,
32
+ headers: Mapping[str, str] | None = None,
33
+ http_client: httpx.Client | None = None,
34
+ ) -> None:
35
+ from powerdev import __version__
36
+
37
+ self.config = ClientConfig.resolve(api_key, base_url, connect_timeout, read_timeout)
38
+ self._transport = Transport(self.config, user_agent=f"powerdev-python/{__version__}", http_client=http_client, headers=headers)
39
+
40
+ def __enter__(self) -> "PowerDevClient":
41
+ return self
42
+
43
+ def __exit__(self, *exc_info: object) -> None:
44
+ self.close()
45
+
46
+ def close(self) -> None:
47
+ self._transport.close()
48
+
49
+ def __repr__(self) -> str:
50
+ return f"PowerDevClient({self.config!r})"
51
+
52
+ @property
53
+ def last_response(self) -> ResponseMetadata | None:
54
+ """Status and contract headers of the most recent successful call."""
55
+ return self._transport.last_response
56
+
57
+ # System (anonymous)
58
+
59
+ def get_info(self) -> dict[str, Any]:
60
+ return self._get("info", auth=False)
61
+
62
+ def get_status(self) -> dict[str, Any]:
63
+ return self._get("status", auth=False)
64
+
65
+ # Discovery
66
+
67
+ def list_catalogs(self) -> list[dict[str, Any]]:
68
+ return self._get("catalogs")
69
+
70
+ def list_datasets(
71
+ self,
72
+ catalog: str | None = None,
73
+ *,
74
+ iso_or_provider_list: list[str] | None = None,
75
+ search: str | None = None,
76
+ include_details: bool | None = None,
77
+ ) -> list[dict[str, Any]]:
78
+ path = f"catalogs/datasets/{_catalog_route(catalog)}" if catalog else "catalogs/datasets"
79
+ return self._get(path, iso_or_provider_list=iso_or_provider_list, search=search, include_details=include_details)
80
+
81
+ def get_dataset(self, dataset_key: str, *, exclude_schema: bool | None = None, exclude_filters: bool | None = None) -> dict[str, Any]:
82
+ return self._get(f"datasets/{_route_segment(dataset_key)}", exclude_schema=exclude_schema, exclude_filters=exclude_filters)
83
+
84
+ # Query
85
+
86
+ def query(self, dataset_key: str, *, method: str = query_encoding.GET, convert: bool = True, **params: Any) -> QueryResult:
87
+ params.pop("format", None)
88
+ verb, path, kwargs = self._query_request(dataset_key, params, method)
89
+ response = self._transport.request_json(verb, path, **kwargs)
90
+ return QueryResult(response.payload, response.metadata, convert=convert)
91
+
92
+ @contextmanager
93
+ def stream_csv(self, dataset_key: str, *, method: str = query_encoding.GET, **params: Any) -> Iterator[CsvStream]:
94
+ params["format"] = CSV_FORMAT
95
+ verb, path, kwargs = self._query_request(dataset_key, params, method)
96
+ with self._transport.stream(verb, path, **kwargs) as response:
97
+ yield CsvStream(response)
98
+
99
+ def save_csv(self, dataset_key: str, target: str | os.PathLike[str] | BinaryIO, *, method: str = query_encoding.GET, **params: Any) -> ResponseMetadata:
100
+ with self.stream_csv(dataset_key, method=method, **params) as stream:
101
+ if hasattr(target, "write"):
102
+ _copy(stream, target) # type: ignore[arg-type]
103
+ else:
104
+ with Path(target).open("wb") as handle:
105
+ _copy(stream, handle)
106
+ return stream.metadata
107
+
108
+ # Locations
109
+
110
+ def list_locations(self, *, iso_or_provider_list: list[str] | None = None, search: str | None = None, limit: int | None = None) -> list[dict[str, Any]]:
111
+ return self._get("lookups/locations", iso_or_provider_list=iso_or_provider_list, search=search, limit=limit)
112
+
113
+ def get_location(self, location_id: int | str) -> dict[str, Any]:
114
+ return self._get(f"lookups/location/{_route_segment(str(location_id))}")
115
+
116
+ # Usage
117
+
118
+ def get_usage(self) -> dict[str, Any]:
119
+ return self._get("usage")
120
+
121
+ # API key management (credentials per call; nothing is stored)
122
+
123
+ def list_api_keys(self, *, email: str, password: str) -> list[dict[str, Any]]:
124
+ return self._credentials_post("api-keys/list", email, password, {})
125
+
126
+ def create_api_key(self, *, email: str, password: str, key_name: str | None = None) -> dict[str, Any]:
127
+ return self._credentials_post("api-keys/create", email, password, {"key_name": key_name})
128
+
129
+ def revoke_api_key(self, *, email: str, password: str, key_id: str) -> dict[str, Any]:
130
+ return self._credentials_post("api-keys/revoke", email, password, {"key_id": key_id})
131
+
132
+ # Internals
133
+
134
+ def _get(self, path: str, *, auth: bool = True, **params: Any) -> Any:
135
+ pairs, _ = query_encoding.prepare(params, query_encoding.GET)
136
+ return self._transport.request_json(query_encoding.GET, path, params=pairs or None, auth=auth).data
137
+
138
+ def _credentials_post(self, path: str, email: str, password: str, extra: Mapping[str, Any]) -> Any:
139
+ body = {"email": email, "password": password, **extra}
140
+ return self._transport.request_json(query_encoding.POST, path, body=body, auth=False).data
141
+
142
+ def _query_request(self, dataset_key: str, params: Mapping[str, Any], method: str) -> tuple[str, str, dict[str, Any]]:
143
+ verb = query_encoding.normalize_method(method)
144
+ pairs, body = query_encoding.prepare(params, verb)
145
+ path = f"datasets/{_route_segment(dataset_key)}/query"
146
+ return verb, path, {"params": pairs or None, "body": body}
147
+
148
+
149
+ def _route_segment(value: str) -> str:
150
+ return quote(value.strip(), safe="")
151
+
152
+
153
+ def _catalog_route(catalog: str) -> str:
154
+ """Catalog keys are snake_case; their route segments are kebab-case (forecast_versions -> forecast-versions)."""
155
+ return _route_segment(catalog.lower().replace("_", "-"))
156
+
157
+
158
+ def _copy(stream: CsvStream, handle: BinaryIO) -> None:
159
+ for chunk in stream.iter_bytes():
160
+ handle.write(chunk)