powerdev 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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
+ ]
powerdev/client.py ADDED
@@ -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)
powerdev/config.py ADDED
@@ -0,0 +1,91 @@
1
+ """Client configuration: key and base URL resolution, URL scheme and timeout rules (design 14/P02)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from urllib.parse import urlsplit
8
+
9
+ from powerdev.exceptions import PowerDevConfigurationError
10
+
11
+ PRODUCTION_BASE_URL = "https://api.power.dev/v2/"
12
+ ENV_API_KEY = "POWERDEV_API_KEY"
13
+ ENV_BASE_URL = "POWERDEV_BASE_URL"
14
+
15
+ DEFAULT_CONNECT_TIMEOUT = 30.0
16
+ MAX_CONNECT_TIMEOUT = 180.0
17
+ DEFAULT_READ_TIMEOUT = 120.0
18
+
19
+ _LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class ClientConfig:
24
+ api_key: str | None
25
+ base_url: str
26
+ connect_timeout: float | None
27
+ read_timeout: float | None
28
+
29
+ @classmethod
30
+ def resolve(
31
+ cls,
32
+ api_key: str | None = None,
33
+ base_url: str | None = None,
34
+ connect_timeout: float | None = DEFAULT_CONNECT_TIMEOUT,
35
+ read_timeout: float | None = DEFAULT_READ_TIMEOUT,
36
+ ) -> "ClientConfig":
37
+ key = _clean(api_key) or _clean(os.environ.get(ENV_API_KEY))
38
+ url = _clean(base_url) or _clean(os.environ.get(ENV_BASE_URL)) or PRODUCTION_BASE_URL
39
+ return cls(key, normalize_base_url(url), validate_connect_timeout(connect_timeout), validate_read_timeout(read_timeout))
40
+
41
+ @property
42
+ def has_api_key(self) -> bool:
43
+ return bool(self.api_key)
44
+
45
+ def __repr__(self) -> str:
46
+ key = "set" if self.api_key else "missing"
47
+ return (
48
+ f"ClientConfig(api_key=<{key}>, base_url={self.base_url!r}, "
49
+ f"connect_timeout={self.connect_timeout!r}, read_timeout={self.read_timeout!r})"
50
+ )
51
+
52
+
53
+ def normalize_base_url(value: str) -> str:
54
+ parts = urlsplit(value)
55
+ host = (parts.hostname or "").lower()
56
+ if parts.scheme == "https" and host:
57
+ pass
58
+ elif parts.scheme == "http" and host in _LOCAL_HOSTS:
59
+ pass
60
+ else:
61
+ raise PowerDevConfigurationError(
62
+ "base_url must be an https:// URL; plain http:// is accepted only for localhost."
63
+ )
64
+ if parts.query or parts.fragment:
65
+ raise PowerDevConfigurationError("base_url cannot carry a query string or fragment.")
66
+ return value if value.endswith("/") else value + "/"
67
+
68
+
69
+ def validate_connect_timeout(value: float | None) -> float | None:
70
+ if value is None:
71
+ return None
72
+ if value <= 0 or value > MAX_CONNECT_TIMEOUT:
73
+ raise PowerDevConfigurationError(
74
+ f"connect_timeout must be between 0 and {MAX_CONNECT_TIMEOUT:g} seconds, or None to disable it explicitly."
75
+ )
76
+ return float(value)
77
+
78
+
79
+ def validate_read_timeout(value: float | None) -> float | None:
80
+ if value is None:
81
+ return None
82
+ if value <= 0:
83
+ raise PowerDevConfigurationError("read_timeout must be positive, or None to disable it explicitly.")
84
+ return float(value)
85
+
86
+
87
+ def _clean(value: str | None) -> str | None:
88
+ if value is None:
89
+ return None
90
+ value = value.strip()
91
+ return value or None
powerdev/exceptions.py ADDED
@@ -0,0 +1,106 @@
1
+ """Exception hierarchy of the client (design 14/P07). Secrets never enter messages or reprs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Mapping
6
+
7
+
8
+ class PowerDevError(Exception):
9
+ """Base class of every error raised by the client."""
10
+
11
+
12
+ class PowerDevConfigurationError(PowerDevError):
13
+ """Local misuse: missing API key, invalid base URL or timeout, conflicting query options."""
14
+
15
+
16
+ class PowerDevTransportError(PowerDevError):
17
+ """Network, DNS, TLS, timeout or an unexpected redirect; no response envelope was read."""
18
+
19
+
20
+ class PowerDevProtocolError(PowerDevError):
21
+ """The response does not follow the v2 contract (wrong content type, malformed JSON, row/header mismatch)."""
22
+
23
+
24
+ class PowerDevDeprecationWarning(UserWarning):
25
+ """Emitted once per process for responses that carry Deprecation or Sunset headers."""
26
+
27
+
28
+ class PowerDevApiError(PowerDevError):
29
+ """An HTTP error answered with the shared error envelope: {"error": {"code", "message", "details"}}."""
30
+
31
+ def __init__(
32
+ self,
33
+ status_code: int,
34
+ code: str,
35
+ message: str,
36
+ details: Mapping[str, Any] | None = None,
37
+ *,
38
+ retry_after: int | None = None,
39
+ headers: Mapping[str, str] | None = None,
40
+ body_preview: str | None = None,
41
+ ) -> None:
42
+ super().__init__(f"{status_code} {code}: {message}")
43
+ self.status_code = status_code
44
+ self.code = code
45
+ self.message = message
46
+ self.details = dict(details) if details else None
47
+ self.retry_after = retry_after
48
+ self.headers = dict(headers or {})
49
+ self.body_preview = body_preview
50
+
51
+ @property
52
+ def limit_type(self) -> str | None:
53
+ return self.details.get("limit_type") if self.details else None
54
+
55
+ @property
56
+ def fields(self) -> list[dict[str, Any]]:
57
+ value = self.details.get("fields") if self.details else None
58
+ return list(value) if isinstance(value, list) else []
59
+
60
+
61
+ class InvalidRequestError(PowerDevApiError):
62
+ """400 INVALID_REQUEST."""
63
+
64
+
65
+ class UnauthenticatedError(PowerDevApiError):
66
+ """401 UNAUTHENTICATED."""
67
+
68
+
69
+ class AccessDeniedError(PowerDevApiError):
70
+ """403 ACCESS_DENIED."""
71
+
72
+
73
+ class ResourceNotFoundError(PowerDevApiError):
74
+ """404 RESOURCE_NOT_FOUND."""
75
+
76
+
77
+ class ApiVersionUnavailableError(PowerDevApiError):
78
+ """410 API_VERSION_UNAVAILABLE."""
79
+
80
+
81
+ class UsageLimitExceededError(PowerDevApiError):
82
+ """429 USAGE_LIMIT_EXCEEDED; retry_after and details.reset_at say when the limit resets."""
83
+
84
+
85
+ class InternalServerError(PowerDevApiError):
86
+ """500 INTERNAL_ERROR."""
87
+
88
+
89
+ class DatasetUnavailableError(PowerDevApiError):
90
+ """503 DATASET_UNAVAILABLE."""
91
+
92
+
93
+ _BY_STATUS: dict[int, type[PowerDevApiError]] = {
94
+ 400: InvalidRequestError,
95
+ 401: UnauthenticatedError,
96
+ 403: AccessDeniedError,
97
+ 404: ResourceNotFoundError,
98
+ 410: ApiVersionUnavailableError,
99
+ 429: UsageLimitExceededError,
100
+ 500: InternalServerError,
101
+ 503: DatasetUnavailableError,
102
+ }
103
+
104
+
105
+ def api_error_class(status_code: int) -> type[PowerDevApiError]:
106
+ return _BY_STATUS.get(status_code, PowerDevApiError)
powerdev/models.py ADDED
@@ -0,0 +1,95 @@
1
+ """Small standard-library models that keep the server payload and the safe response metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Iterator, Mapping
7
+
8
+ import httpx
9
+
10
+ TRUNCATED_HEADER = "x-powerdev-truncated"
11
+ UNRESOLVED_LOCATIONS_HEADER = "x-powerdev-unresolved-location-count"
12
+ _KEPT_HEADERS = (
13
+ "content-type",
14
+ "retry-after",
15
+ "deprecation",
16
+ "sunset",
17
+ "link",
18
+ TRUNCATED_HEADER,
19
+ UNRESOLVED_LOCATIONS_HEADER,
20
+ )
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class ResponseMetadata:
25
+ """HTTP status and the contract headers of one response; never carries request headers."""
26
+
27
+ status_code: int
28
+ headers: Mapping[str, str] = field(default_factory=dict)
29
+
30
+ @classmethod
31
+ def from_response(cls, response: httpx.Response) -> "ResponseMetadata":
32
+ kept = {name: response.headers[name] for name in _KEPT_HEADERS if name in response.headers}
33
+ return cls(response.status_code, kept)
34
+
35
+ @property
36
+ def content_type(self) -> str | None:
37
+ return self.headers.get("content-type")
38
+
39
+ @property
40
+ def retry_after(self) -> int | None:
41
+ value = self.headers.get("retry-after")
42
+ return int(value) if value and value.isdigit() else None
43
+
44
+ @property
45
+ def deprecation(self) -> str | None:
46
+ return self.headers.get("deprecation")
47
+
48
+ @property
49
+ def sunset(self) -> str | None:
50
+ return self.headers.get("sunset")
51
+
52
+ @property
53
+ def link(self) -> str | None:
54
+ return self.headers.get("link")
55
+
56
+ @property
57
+ def truncated(self) -> bool:
58
+ return self.headers.get(TRUNCATED_HEADER, "").lower() == "true"
59
+
60
+ @property
61
+ def unresolved_location_count(self) -> int | None:
62
+ value = self.headers.get(UNRESOLVED_LOCATIONS_HEADER)
63
+ return int(value) if value and value.isdigit() else None
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class ApiResponse:
68
+ """Parsed JSON body plus metadata; `payload` is the full envelope, `data` its data member when present."""
69
+
70
+ payload: Any
71
+ metadata: ResponseMetadata
72
+
73
+ @property
74
+ def data(self) -> Any:
75
+ return self.payload.get("data") if isinstance(self.payload, Mapping) else self.payload
76
+
77
+
78
+ class CsvStream:
79
+ """Chunked access to a CSV response body inside a `with client.stream_csv(...)` block."""
80
+
81
+ def __init__(self, response: httpx.Response) -> None:
82
+ self._response = response
83
+ self.metadata = ResponseMetadata.from_response(response)
84
+
85
+ def iter_bytes(self, chunk_size: int | None = None) -> Iterator[bytes]:
86
+ return self._response.iter_bytes(chunk_size)
87
+
88
+ def iter_text(self, chunk_size: int | None = None) -> Iterator[str]:
89
+ return self._response.iter_text(chunk_size)
90
+
91
+ def iter_lines(self) -> Iterator[str]:
92
+ return self._response.iter_lines()
93
+
94
+ def read(self) -> bytes:
95
+ return self._response.read()
powerdev/py.typed ADDED
File without changes
powerdev/query.py ADDED
@@ -0,0 +1,73 @@
1
+ """Encodes query options for GET (repeated lists, URL-encoded JSON filters) and POST (native JSON) (design 14/P04–P06)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import date, datetime
7
+ from typing import Any, Mapping
8
+
9
+ from powerdev.exceptions import PowerDevConfigurationError
10
+
11
+ GET = "GET"
12
+ POST = "POST"
13
+ _METHODS = {GET, POST}
14
+
15
+ _CONFLICTS = (
16
+ ("location_ids", "location_names"),
17
+ ("include_columns", "exclude_columns"),
18
+ )
19
+
20
+
21
+ def normalize_method(method: str) -> str:
22
+ upper = (method or "").upper()
23
+ if upper not in _METHODS:
24
+ raise PowerDevConfigurationError("method must be 'GET' or 'POST'.")
25
+ return upper
26
+
27
+
28
+ def prepare(params: Mapping[str, Any], method: str) -> tuple[list[tuple[str, str]] | None, dict[str, Any] | None]:
29
+ """Returns (query_params, json_body) for the chosen method; None values are dropped."""
30
+ options = {key: value for key, value in params.items() if value is not None}
31
+ for left, right in _CONFLICTS:
32
+ if left in options and right in options:
33
+ raise PowerDevConfigurationError(f"{left} and {right} cannot be used in the same query.")
34
+
35
+ if method == POST:
36
+ return None, {key: _json_value(value) for key, value in options.items()}
37
+
38
+ pairs: list[tuple[str, str]] = []
39
+ for key, value in options.items():
40
+ pairs.extend(_get_pairs(key, value))
41
+ return pairs, None
42
+
43
+
44
+ def _get_pairs(key: str, value: Any) -> list[tuple[str, str]]:
45
+ if isinstance(value, Mapping):
46
+ return [(key, json.dumps(_json_value(value), separators=(",", ":")))]
47
+ if isinstance(value, (list, tuple, set, frozenset)):
48
+ return [(key, _scalar(item)) for item in value]
49
+ return [(key, _scalar(value))]
50
+
51
+
52
+ def _scalar(value: Any) -> str:
53
+ if isinstance(value, bool):
54
+ return "true" if value else "false"
55
+ if isinstance(value, datetime):
56
+ return value.isoformat()
57
+ if isinstance(value, date):
58
+ return value.isoformat()
59
+ return str(value)
60
+
61
+
62
+ def _json_value(value: Any) -> Any:
63
+ if isinstance(value, Mapping):
64
+ return {str(key): _json_value(item) for key, item in value.items()}
65
+ if isinstance(value, (list, tuple, set, frozenset)):
66
+ return [_json_value(item) for item in value]
67
+ if isinstance(value, bool):
68
+ return value
69
+ if isinstance(value, datetime):
70
+ return value.isoformat()
71
+ if isinstance(value, date):
72
+ return value.isoformat()
73
+ return value
powerdev/result.py ADDED
@@ -0,0 +1,124 @@
1
+ """QueryResult keeps the server structure and converts cells with the column metadata (design 14/P03)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date, datetime
6
+ from decimal import Decimal
7
+ from typing import Any, Callable, Iterator, Mapping, Sequence
8
+
9
+ from powerdev.exceptions import PowerDevProtocolError
10
+ from powerdev.models import ResponseMetadata
11
+
12
+ ARRAY_OF_ARRAYS = "ARRAY_OF_ARRAYS"
13
+ ARRAY_OF_OBJECTS = "ARRAY_OF_OBJECTS"
14
+
15
+ _CONVERTERS: dict[str, Callable[[Any], Any]] = {
16
+ "integer": int,
17
+ "float": float,
18
+ "decimal": lambda value: Decimal(str(value)),
19
+ "boolean": bool,
20
+ "string": str,
21
+ "date": lambda value: date.fromisoformat(str(value)),
22
+ "timestamp": lambda value: datetime.fromisoformat(str(value)),
23
+ }
24
+
25
+
26
+ class QueryResult:
27
+ """A JSON query response: meta, echoed request, column metadata and the rows in the server's row_format."""
28
+
29
+ def __init__(self, payload: Mapping[str, Any], metadata: ResponseMetadata, *, convert: bool = True) -> None:
30
+ data = _mapping(payload.get("data"), "data")
31
+ self.meta: dict[str, Any] = dict(_mapping(payload.get("meta"), "meta"))
32
+ self.request: dict[str, Any] = dict(_mapping(payload.get("request"), "request"))
33
+ self.metadata = metadata
34
+ self.row_format: str = str(data.get("row_format") or ARRAY_OF_ARRAYS)
35
+ self.header_keys: list[str] = [str(key) for key in _sequence(data.get("header_keys"), "header_keys")]
36
+ self.header_labels: list[str] = [str(label) for label in _sequence(data.get("header_labels") or [], "header_labels")]
37
+ self.column_info: list[dict[str, Any]] = [dict(_mapping(column, "column_info")) for column in _sequence(data.get("column_info") or [], "column_info")]
38
+ rows = _sequence(data.get("rows"), "rows")
39
+ self.rows: list[Any] = _convert_rows(rows, self.header_keys, self.column_info, self.row_format) if convert else list(rows)
40
+
41
+ @property
42
+ def dataset_key(self) -> str | None:
43
+ return self.meta.get("dataset_key")
44
+
45
+ @property
46
+ def granularity(self) -> str | None:
47
+ return self.meta.get("granularity")
48
+
49
+ @property
50
+ def row_count(self) -> int:
51
+ return int(self.meta.get("row_count", len(self.rows)))
52
+
53
+ @property
54
+ def truncated(self) -> bool:
55
+ return bool(self.meta.get("truncated", False))
56
+
57
+ @property
58
+ def column_types(self) -> dict[str, str]:
59
+ return {str(column["key"]): str(column["type"]) for column in self.column_info if "key" in column}
60
+
61
+ def __len__(self) -> int:
62
+ return len(self.rows)
63
+
64
+ def __iter__(self) -> Iterator[Any]:
65
+ return iter(self.rows)
66
+
67
+ def to_records(self) -> list[dict[str, Any]]:
68
+ if self.row_format == ARRAY_OF_OBJECTS:
69
+ return [dict(row) for row in self.rows]
70
+ return [dict(zip(self.header_keys, row)) for row in self.rows]
71
+
72
+ def to_dataframe(self) -> Any:
73
+ try:
74
+ import pandas
75
+ except ImportError as exc:
76
+ raise ImportError("QueryResult.to_dataframe() needs pandas; install the extra with: pip install 'powerdev[pandas]'") from exc
77
+ if self.row_format == ARRAY_OF_OBJECTS:
78
+ return pandas.DataFrame.from_records(self.rows, columns=self.header_keys)
79
+ return pandas.DataFrame(self.rows, columns=self.header_keys)
80
+
81
+ def __repr__(self) -> str:
82
+ return f"QueryResult(dataset_key={self.dataset_key!r}, rows={len(self.rows)}, columns={len(self.header_keys)}, row_format={self.row_format!r})"
83
+
84
+
85
+ def _convert_rows(rows: Sequence[Any], keys: list[str], column_info: list[dict[str, Any]], row_format: str) -> list[Any]:
86
+ types = {str(column.get("key")): str(column.get("type")) for column in column_info}
87
+ converters = [_CONVERTERS.get(types.get(key, ""), _identity) for key in keys]
88
+ width = len(keys)
89
+
90
+ if row_format == ARRAY_OF_OBJECTS:
91
+ by_key = dict(zip(keys, converters))
92
+ return [{key: _cell(by_key.get(key, _identity), value) for key, value in _mapping(row, "rows").items()} for row in rows]
93
+
94
+ converted: list[list[Any]] = []
95
+ for index, row in enumerate(rows):
96
+ if not isinstance(row, Sequence) or isinstance(row, (str, bytes)) or len(row) != width:
97
+ raise PowerDevProtocolError(f"Row {index} does not match the {width} header keys.")
98
+ converted.append([_cell(converters[position], value) for position, value in enumerate(row)])
99
+ return converted
100
+
101
+
102
+ def _cell(converter: Callable[[Any], Any], value: Any) -> Any:
103
+ if value is None:
104
+ return None
105
+ try:
106
+ return converter(value)
107
+ except (ValueError, TypeError, ArithmeticError) as exc:
108
+ raise PowerDevProtocolError(f"Cell value {value!r} cannot be converted with {getattr(converter, '__name__', 'converter')}.") from exc
109
+
110
+
111
+ def _identity(value: Any) -> Any:
112
+ return value
113
+
114
+
115
+ def _mapping(value: Any, name: str) -> Mapping[str, Any]:
116
+ if not isinstance(value, Mapping):
117
+ raise PowerDevProtocolError(f"The response has no '{name}' object.")
118
+ return value
119
+
120
+
121
+ def _sequence(value: Any, name: str) -> Sequence[Any]:
122
+ if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
123
+ raise PowerDevProtocolError(f"The response has no '{name}' list.")
124
+ return value
powerdev/transport.py ADDED
@@ -0,0 +1,161 @@
1
+ """One long-lived httpx.Client, header-based authentication and envelope-aware error mapping (design 14/P02, P07)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import warnings
7
+ from contextlib import contextmanager
8
+ from typing import Any, Iterator, Mapping, Sequence
9
+ from urllib.parse import urljoin
10
+
11
+ import httpx
12
+
13
+ from powerdev.config import ClientConfig
14
+ from powerdev.exceptions import (
15
+ PowerDevConfigurationError,
16
+ PowerDevDeprecationWarning,
17
+ PowerDevProtocolError,
18
+ PowerDevTransportError,
19
+ api_error_class,
20
+ )
21
+ from powerdev.models import ApiResponse, ResponseMetadata
22
+
23
+ API_KEY_HEADER = "x-api-key"
24
+ JSON_CONTENT_TYPE = "application/json"
25
+ _RESERVED_HEADERS = {API_KEY_HEADER, "apikey"}
26
+ _PREVIEW_LIMIT = 200
27
+
28
+ Params = Sequence[tuple[str, str]] | Mapping[str, Any] | None
29
+
30
+
31
+ class Transport:
32
+ def __init__(
33
+ self,
34
+ config: ClientConfig,
35
+ *,
36
+ user_agent: str,
37
+ http_client: httpx.Client | None = None,
38
+ headers: Mapping[str, str] | None = None,
39
+ ) -> None:
40
+ self.config = config
41
+ self._owns_client = http_client is None
42
+ self._extra_headers = _validated_extra_headers(headers)
43
+ self._user_agent = user_agent
44
+ self._client = http_client or httpx.Client(
45
+ timeout=httpx.Timeout(connect=config.connect_timeout, read=config.read_timeout, write=config.read_timeout, pool=config.connect_timeout),
46
+ follow_redirects=False,
47
+ )
48
+ self._closed = False
49
+ self.last_response: ResponseMetadata | None = None
50
+
51
+ def request_json(self, method: str, path: str, *, params: Params = None, body: Any = None, auth: bool = True) -> ApiResponse:
52
+ response = self._send(method, path, params=params, body=body, auth=auth, stream=False)
53
+ try:
54
+ self._raise_for_error(response)
55
+ metadata = self._record(response)
56
+ if JSON_CONTENT_TYPE not in response.headers.get("content-type", ""):
57
+ raise PowerDevProtocolError(
58
+ f"Expected a JSON response but received {response.headers.get('content-type') or 'no content type'}."
59
+ )
60
+ try:
61
+ payload = response.json()
62
+ except ValueError as exc:
63
+ raise PowerDevProtocolError("The response body is not valid JSON.") from exc
64
+ return ApiResponse(payload, metadata)
65
+ finally:
66
+ response.close()
67
+
68
+ @contextmanager
69
+ def stream(self, method: str, path: str, *, params: Params = None, body: Any = None, auth: bool = True) -> Iterator[httpx.Response]:
70
+ response = self._send(method, path, params=params, body=body, auth=auth, stream=True)
71
+ try:
72
+ if response.status_code >= 300:
73
+ response.read()
74
+ self._raise_for_error(response)
75
+ self._record(response)
76
+ yield response
77
+ finally:
78
+ response.close()
79
+
80
+ def close(self) -> None:
81
+ if self._closed:
82
+ return
83
+ self._closed = True
84
+ if self._owns_client:
85
+ self._client.close()
86
+
87
+ def _send(self, method: str, path: str, *, params: Params, body: Any, auth: bool, stream: bool) -> httpx.Response:
88
+ if self._closed:
89
+ raise PowerDevConfigurationError("The client is closed.")
90
+ headers = dict(self._extra_headers)
91
+ headers["User-Agent"] = self._user_agent
92
+ headers["Accept"] = JSON_CONTENT_TYPE
93
+ if auth:
94
+ if not self.config.api_key:
95
+ raise PowerDevConfigurationError(
96
+ "This call needs an API key. Pass api_key=... or set the POWERDEV_API_KEY environment variable."
97
+ )
98
+ headers[API_KEY_HEADER] = self.config.api_key
99
+
100
+ url = urljoin(self.config.base_url, path)
101
+ request = self._client.build_request(method, url, params=params, json=body, headers=headers)
102
+ try:
103
+ return self._client.send(request, stream=stream)
104
+ except httpx.TimeoutException as exc:
105
+ raise PowerDevTransportError(f"The request to {method} {path} timed out.") from exc
106
+ except httpx.TransportError as exc:
107
+ raise PowerDevTransportError(f"The request to {method} {path} failed: {type(exc).__name__}.") from exc
108
+
109
+ def _record(self, response: httpx.Response) -> ResponseMetadata:
110
+ metadata = ResponseMetadata.from_response(response)
111
+ self.last_response = metadata
112
+ if metadata.deprecation or metadata.sunset:
113
+ warnings.warn(
114
+ f"{response.request.method} {response.request.url.path} is deprecated"
115
+ + (f" (sunset {metadata.sunset})" if metadata.sunset else "")
116
+ + (f"; see {metadata.link}" if metadata.link else "") + ".",
117
+ PowerDevDeprecationWarning,
118
+ stacklevel=4,
119
+ )
120
+ return metadata
121
+
122
+ def _raise_for_error(self, response: httpx.Response) -> None:
123
+ status = response.status_code
124
+ if 300 <= status < 400:
125
+ raise PowerDevTransportError(f"Unexpected redirect ({status}); redirects are not followed.")
126
+ if status < 400:
127
+ return
128
+
129
+ metadata = ResponseMetadata.from_response(response)
130
+ envelope = _parse_error_envelope(response)
131
+ if envelope is None:
132
+ preview = response.text[:_PREVIEW_LIMIT] if response.text else None
133
+ raise api_error_class(status)(
134
+ status, f"HTTP_{status}", "The server returned an error without the shared error envelope.",
135
+ retry_after=metadata.retry_after, headers=metadata.headers, body_preview=preview,
136
+ )
137
+
138
+ code = str(envelope.get("code") or f"HTTP_{status}")
139
+ message = str(envelope.get("message") or "")
140
+ details = envelope.get("details") if isinstance(envelope.get("details"), Mapping) else None
141
+ raise api_error_class(status)(status, code, message, details, retry_after=metadata.retry_after, headers=metadata.headers)
142
+
143
+
144
+ def _parse_error_envelope(response: httpx.Response) -> Mapping[str, Any] | None:
145
+ if JSON_CONTENT_TYPE not in response.headers.get("content-type", ""):
146
+ return None
147
+ try:
148
+ payload = json.loads(response.text)
149
+ except ValueError:
150
+ return None
151
+ error = payload.get("error") if isinstance(payload, Mapping) else None
152
+ return error if isinstance(error, Mapping) else None
153
+
154
+
155
+ def _validated_extra_headers(headers: Mapping[str, str] | None) -> dict[str, str]:
156
+ result: dict[str, str] = {}
157
+ for name, value in (headers or {}).items():
158
+ if name.lower() in _RESERVED_HEADERS:
159
+ raise PowerDevConfigurationError("Authentication headers cannot be overridden; pass api_key instead.")
160
+ result[name] = value
161
+ return result
@@ -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,14 @@
1
+ powerdev/__init__.py,sha256=lUZ7xU4MNMk06932Jahj9ohwL6Wp2_ZbnyVAB--olX0,1263
2
+ powerdev/client.py,sha256=kI8t79W2ylJ9MgtfNiAnYnwzV2AO46ZVtmk8yGFekps,6608
3
+ powerdev/config.py,sha256=xlm3r6ZJoDPJoS_r8t5r2zHqhENCrRcJFAOYcWe_W3w,2954
4
+ powerdev/exceptions.py,sha256=mr0Ace0tJqZjE7ooo9EhTPrDcq-3pvfz-WoQOzUWHh0,3110
5
+ powerdev/models.py,sha256=4uSyFFf-LtLV6nwOTFqEt3Ux1SdyIFDpBrCiSCg9vwo,2870
6
+ powerdev/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ powerdev/query.py,sha256=A2_KOQUGlRWLTwu4K7yF_iLFhT6F4NZxB0mq2hlRR6A,2438
8
+ powerdev/result.py,sha256=2GUBtwVuCT-BqDc9AVrh18s7ZYS1U2kT9qVftLF_Hx0,5220
9
+ powerdev/transport.py,sha256=fo1u5FuF-jg1xFeX6IetyLtyb5k-drunGGjYbtTtC7g,6757
10
+ powerdev-0.1.0.dist-info/licenses/LICENSE,sha256=UG_PBf6E4zLx7GBs2SH0kvtfOhu_H7bh2KkFXMVyL0E,1065
11
+ powerdev-0.1.0.dist-info/METADATA,sha256=OqxXbZ0Nt4JJ0PHCX6C5t0qgqK2WYMSf5KLg1DtWuR0,8332
12
+ powerdev-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
13
+ powerdev-0.1.0.dist-info/top_level.txt,sha256=Np4foL5FwCL4Oeto1XO2rCnzxQRofijYHJERIWzwvAM,9
14
+ powerdev-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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 @@
1
+ powerdev