kpubdata 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.
- kpubdata/__init__.py +52 -0
- kpubdata/catalog.py +101 -0
- kpubdata/client.py +179 -0
- kpubdata/config.py +119 -0
- kpubdata/core/__init__.py +25 -0
- kpubdata/core/capability.py +56 -0
- kpubdata/core/dataset.py +153 -0
- kpubdata/core/models.py +156 -0
- kpubdata/core/protocol.py +60 -0
- kpubdata/core/representation.py +19 -0
- kpubdata/exceptions.py +116 -0
- kpubdata/providers/__init__.py +5 -0
- kpubdata/providers/_common.py +204 -0
- kpubdata/providers/bok/__init__.py +7 -0
- kpubdata/providers/bok/adapter.py +333 -0
- kpubdata/providers/bok/catalogue.json +25 -0
- kpubdata/providers/datago/__init__.py +7 -0
- kpubdata/providers/datago/adapter.py +313 -0
- kpubdata/providers/datago/catalogue.json +92 -0
- kpubdata/providers/kosis/__init__.py +7 -0
- kpubdata/providers/kosis/adapter.py +254 -0
- kpubdata/providers/kosis/catalogue.json +26 -0
- kpubdata/providers/lofin/__init__.py +7 -0
- kpubdata/providers/lofin/adapter.py +329 -0
- kpubdata/providers/lofin/catalogue.json +127 -0
- kpubdata/py.typed +0 -0
- kpubdata/registry.py +135 -0
- kpubdata/transport/__init__.py +16 -0
- kpubdata/transport/decode.py +74 -0
- kpubdata/transport/http.py +380 -0
- kpubdata/transport/retry.py +68 -0
- kpubdata-0.1.0.dist-info/METADATA +380 -0
- kpubdata-0.1.0.dist-info/RECORD +35 -0
- kpubdata-0.1.0.dist-info/WHEEL +4 -0
- kpubdata-0.1.0.dist-info/licenses/LICENSE +21 -0
kpubdata/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""KPubData — Korean public data access framework for Python 3.10+."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from kpubdata.client import Client
|
|
6
|
+
from kpubdata.core.capability import Operation, PaginationMode, QuerySupport
|
|
7
|
+
from kpubdata.core.models import DatasetRef, FieldDescriptor, Query, RecordBatch, SchemaDescriptor
|
|
8
|
+
from kpubdata.core.representation import Representation
|
|
9
|
+
from kpubdata.exceptions import (
|
|
10
|
+
AuthError,
|
|
11
|
+
ConfigError,
|
|
12
|
+
DatasetNotFoundError,
|
|
13
|
+
InvalidRequestError,
|
|
14
|
+
ParseError,
|
|
15
|
+
ProviderNotRegisteredError,
|
|
16
|
+
ProviderResponseError,
|
|
17
|
+
PublicDataError,
|
|
18
|
+
RateLimitError,
|
|
19
|
+
ServiceUnavailableError,
|
|
20
|
+
TransportError,
|
|
21
|
+
TransportTimeoutError,
|
|
22
|
+
UnsupportedCapabilityError,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"__version__",
|
|
27
|
+
"Client",
|
|
28
|
+
"DatasetRef",
|
|
29
|
+
"Query",
|
|
30
|
+
"RecordBatch",
|
|
31
|
+
"SchemaDescriptor",
|
|
32
|
+
"FieldDescriptor",
|
|
33
|
+
"Operation",
|
|
34
|
+
"PaginationMode",
|
|
35
|
+
"QuerySupport",
|
|
36
|
+
"Representation",
|
|
37
|
+
"PublicDataError",
|
|
38
|
+
"ConfigError",
|
|
39
|
+
"AuthError",
|
|
40
|
+
"TransportError",
|
|
41
|
+
"TransportTimeoutError",
|
|
42
|
+
"RateLimitError",
|
|
43
|
+
"ServiceUnavailableError",
|
|
44
|
+
"ParseError",
|
|
45
|
+
"InvalidRequestError",
|
|
46
|
+
"ProviderResponseError",
|
|
47
|
+
"UnsupportedCapabilityError",
|
|
48
|
+
"DatasetNotFoundError",
|
|
49
|
+
"ProviderNotRegisteredError",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
__version__ = "0.1.0a0"
|
kpubdata/catalog.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Catalog — dataset discovery, search, and resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import builtins
|
|
6
|
+
from typing import cast
|
|
7
|
+
|
|
8
|
+
from kpubdata.core.models import DatasetRef
|
|
9
|
+
from kpubdata.core.protocol import ProviderAdapter
|
|
10
|
+
from kpubdata.exceptions import DatasetNotFoundError, ProviderNotRegisteredError
|
|
11
|
+
from kpubdata.registry import ProviderRegistry
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Catalog:
|
|
15
|
+
"""Provides dataset discovery across registered providers."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, registry: ProviderRegistry) -> None:
|
|
18
|
+
"""Initialize catalog bound to a provider registry."""
|
|
19
|
+
|
|
20
|
+
self._registry = registry
|
|
21
|
+
|
|
22
|
+
def list(self, *, provider: str | None = None) -> builtins.list[DatasetRef]:
|
|
23
|
+
"""Return discoverable datasets, optionally filtered by provider.
|
|
24
|
+
|
|
25
|
+
Raises:
|
|
26
|
+
ProviderNotRegisteredError: If ``provider`` is given but unknown.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
if provider is not None:
|
|
30
|
+
adapter = self._get_adapter(provider)
|
|
31
|
+
return adapter.list_datasets()
|
|
32
|
+
|
|
33
|
+
datasets: builtins.list[DatasetRef] = []
|
|
34
|
+
for provider_name in self._registry:
|
|
35
|
+
adapter = self._get_adapter(provider_name)
|
|
36
|
+
datasets.extend(adapter.list_datasets())
|
|
37
|
+
return datasets
|
|
38
|
+
|
|
39
|
+
def search(self, text: str, *, provider: str | None = None) -> builtins.list[DatasetRef]:
|
|
40
|
+
"""Search datasets by delegating to each adapter's search logic.
|
|
41
|
+
|
|
42
|
+
Each adapter implements its own ``search_datasets(text)`` method,
|
|
43
|
+
allowing provider-specific search semantics.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
ProviderNotRegisteredError: If ``provider`` is given but unknown.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
if provider is not None:
|
|
50
|
+
adapter = self._get_adapter(provider)
|
|
51
|
+
return adapter.search_datasets(text)
|
|
52
|
+
|
|
53
|
+
results: builtins.list[DatasetRef] = []
|
|
54
|
+
for provider_name in self._registry:
|
|
55
|
+
adapter = self._get_adapter(provider_name)
|
|
56
|
+
results.extend(adapter.search_datasets(text))
|
|
57
|
+
return results
|
|
58
|
+
|
|
59
|
+
def resolve(self, dataset_id: str) -> tuple[ProviderAdapter, DatasetRef]:
|
|
60
|
+
"""Resolve ``provider.dataset_key`` into an adapter and dataset ref.
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
DatasetNotFoundError: If the dataset id is malformed or not found.
|
|
64
|
+
ProviderNotRegisteredError: If the provider is not registered.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
provider_name, dataset_key = self._split_dataset_id(dataset_id)
|
|
68
|
+
adapter = self._get_adapter(provider_name)
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
return adapter, adapter.get_dataset(dataset_key)
|
|
72
|
+
except DatasetNotFoundError:
|
|
73
|
+
raise
|
|
74
|
+
except Exception as exc:
|
|
75
|
+
raise DatasetNotFoundError(
|
|
76
|
+
f"Dataset not found: {dataset_id}",
|
|
77
|
+
provider=provider_name,
|
|
78
|
+
dataset_id=dataset_id,
|
|
79
|
+
) from exc
|
|
80
|
+
|
|
81
|
+
def _get_adapter(self, provider: str) -> ProviderAdapter:
|
|
82
|
+
"""Fetch provider adapter from registry or raise canonical error."""
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
return cast(ProviderAdapter, self._registry.get(provider))
|
|
86
|
+
except ProviderNotRegisteredError:
|
|
87
|
+
raise
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def _split_dataset_id(dataset_id: str) -> tuple[str, str]:
|
|
91
|
+
"""Split canonical dataset id into ``(provider, dataset_key)``."""
|
|
92
|
+
|
|
93
|
+
parts = dataset_id.split(".", 1)
|
|
94
|
+
if len(parts) != 2 or not parts[0] or not parts[1]:
|
|
95
|
+
raise DatasetNotFoundError(
|
|
96
|
+
f"Invalid dataset id format: {dataset_id}", dataset_id=dataset_id
|
|
97
|
+
)
|
|
98
|
+
return parts[0], parts[1]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
__all__ = ["Catalog"]
|
kpubdata/client.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Client — the top-level entry point for KPubData."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import cast
|
|
7
|
+
|
|
8
|
+
from typing_extensions import override
|
|
9
|
+
|
|
10
|
+
from kpubdata.catalog import Catalog
|
|
11
|
+
from kpubdata.config import KPubDataConfig
|
|
12
|
+
from kpubdata.core.dataset import Dataset
|
|
13
|
+
from kpubdata.core.protocol import ProviderAdapter
|
|
14
|
+
from kpubdata.registry import ProviderRegistry
|
|
15
|
+
from kpubdata.transport.http import HttpTransport, TransportConfig, TransportRequirements
|
|
16
|
+
|
|
17
|
+
_BUILTIN_PROVIDERS: tuple[tuple[str, str, str], ...] = (
|
|
18
|
+
("datago", "kpubdata.providers.datago", "DataGoAdapter"),
|
|
19
|
+
("bok", "kpubdata.providers.bok", "BokAdapter"),
|
|
20
|
+
("kosis", "kpubdata.providers.kosis", "KosisAdapter"),
|
|
21
|
+
("lofin", "kpubdata.providers.lofin", "LofinAdapter"),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Client:
|
|
26
|
+
"""Top-level entry point for dataset discovery and bound operations."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
*,
|
|
31
|
+
provider_keys: dict[str, str] | None = None,
|
|
32
|
+
timeout: float = 30.0,
|
|
33
|
+
max_retries: int = 3,
|
|
34
|
+
**extra: object,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Initialize a client with explicit provider and transport configuration.
|
|
37
|
+
|
|
38
|
+
Use ``provider_keys`` to supply credentials directly, and configure
|
|
39
|
+
transport behavior with ``timeout`` and ``max_retries``.
|
|
40
|
+
Built-in providers (datago, bok, kosis, lofin) are lazily registered by default.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
self._config: KPubDataConfig = KPubDataConfig(
|
|
44
|
+
provider_keys=provider_keys or {},
|
|
45
|
+
timeout=timeout,
|
|
46
|
+
max_retries=max_retries,
|
|
47
|
+
extra=dict(extra),
|
|
48
|
+
)
|
|
49
|
+
self._registry: ProviderRegistry = ProviderRegistry()
|
|
50
|
+
self._transport_config: TransportConfig = TransportConfig(
|
|
51
|
+
timeout=self._config.timeout,
|
|
52
|
+
max_retries=self._config.max_retries,
|
|
53
|
+
)
|
|
54
|
+
self._transport: HttpTransport = HttpTransport(self._transport_config)
|
|
55
|
+
self._provider_transports: list[HttpTransport] = []
|
|
56
|
+
self._register_builtin_providers()
|
|
57
|
+
self._catalog: Catalog = Catalog(self._registry)
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def from_env(cls, **overrides: object) -> Client:
|
|
61
|
+
"""Create a client from environment variables and explicit overrides."""
|
|
62
|
+
|
|
63
|
+
config = KPubDataConfig.from_env(**overrides)
|
|
64
|
+
return cls(
|
|
65
|
+
provider_keys=config.provider_keys,
|
|
66
|
+
timeout=config.timeout,
|
|
67
|
+
max_retries=config.max_retries,
|
|
68
|
+
**config.extra,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def __enter__(self) -> Client:
|
|
72
|
+
"""Enter context manager and initialize transport client."""
|
|
73
|
+
|
|
74
|
+
_ = self._transport.__enter__()
|
|
75
|
+
return self
|
|
76
|
+
|
|
77
|
+
def __exit__(self, *exc: object) -> None:
|
|
78
|
+
"""Exit context manager and close transport resources."""
|
|
79
|
+
|
|
80
|
+
self.close()
|
|
81
|
+
|
|
82
|
+
def close(self) -> None:
|
|
83
|
+
"""Close underlying transport resources for this client."""
|
|
84
|
+
|
|
85
|
+
self._transport.close()
|
|
86
|
+
for provider_transport in self._provider_transports:
|
|
87
|
+
provider_transport.close()
|
|
88
|
+
self._provider_transports.clear()
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def datasets(self) -> Catalog:
|
|
92
|
+
"""Return catalog interface for discovery, search, and resolution."""
|
|
93
|
+
|
|
94
|
+
return self._catalog
|
|
95
|
+
|
|
96
|
+
def dataset(self, dataset_id: str) -> Dataset:
|
|
97
|
+
"""Bind and return a dataset object by canonical identifier.
|
|
98
|
+
|
|
99
|
+
Raises:
|
|
100
|
+
DatasetNotFoundError: If the dataset id is invalid or unknown.
|
|
101
|
+
ProviderNotRegisteredError: If the provider is not registered.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
adapter, ref = self._catalog.resolve(dataset_id)
|
|
105
|
+
return Dataset(ref=ref, adapter=adapter)
|
|
106
|
+
|
|
107
|
+
def register_provider(self, adapter: object) -> None:
|
|
108
|
+
"""Register a provider adapter in this client's registry.
|
|
109
|
+
|
|
110
|
+
Raises:
|
|
111
|
+
TypeError: If the adapter does not satisfy the required protocol.
|
|
112
|
+
ValueError: If the provider is already registered.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
self._registry.register(adapter)
|
|
116
|
+
|
|
117
|
+
def _register_builtin_providers(self) -> None:
|
|
118
|
+
config = self._config
|
|
119
|
+
transport = self._transport
|
|
120
|
+
transport_config = self._transport_config
|
|
121
|
+
provider_transports = self._provider_transports
|
|
122
|
+
|
|
123
|
+
for provider_name, module_path, class_name in _BUILTIN_PROVIDERS:
|
|
124
|
+
|
|
125
|
+
def _make_factory(
|
|
126
|
+
mod: str,
|
|
127
|
+
cls: str,
|
|
128
|
+
cfg: KPubDataConfig,
|
|
129
|
+
tpt: HttpTransport,
|
|
130
|
+
base_transport_config: TransportConfig,
|
|
131
|
+
owned_transports: list[HttpTransport],
|
|
132
|
+
) -> Callable[[], ProviderAdapter]:
|
|
133
|
+
def _factory() -> ProviderAdapter:
|
|
134
|
+
import importlib
|
|
135
|
+
|
|
136
|
+
module = importlib.import_module(mod)
|
|
137
|
+
adapter_cls = cast(Callable[..., ProviderAdapter], getattr(module, cls))
|
|
138
|
+
adapter = adapter_cls(config=cfg, transport=tpt)
|
|
139
|
+
requirements = _get_transport_requirements(adapter)
|
|
140
|
+
if requirements is None:
|
|
141
|
+
return adapter
|
|
142
|
+
|
|
143
|
+
custom_transport = HttpTransport.with_requirements(
|
|
144
|
+
base_transport_config,
|
|
145
|
+
requirements,
|
|
146
|
+
)
|
|
147
|
+
owned_transports.append(custom_transport)
|
|
148
|
+
return adapter_cls(config=cfg, transport=custom_transport)
|
|
149
|
+
|
|
150
|
+
return _factory
|
|
151
|
+
|
|
152
|
+
self._registry.register_lazy(
|
|
153
|
+
provider_name,
|
|
154
|
+
_make_factory(
|
|
155
|
+
module_path,
|
|
156
|
+
class_name,
|
|
157
|
+
config,
|
|
158
|
+
transport,
|
|
159
|
+
transport_config,
|
|
160
|
+
provider_transports,
|
|
161
|
+
),
|
|
162
|
+
skip_if_exists=True,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
@override
|
|
166
|
+
def __repr__(self) -> str:
|
|
167
|
+
"""Return concise representation with known providers."""
|
|
168
|
+
|
|
169
|
+
return f"Client(providers=[{', '.join(self._registry)}])"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
__all__ = ["Client"]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _get_transport_requirements(adapter: ProviderAdapter) -> TransportRequirements | None:
|
|
176
|
+
requirements = getattr(adapter, "transport_requirements", None)
|
|
177
|
+
if requirements is None:
|
|
178
|
+
return None
|
|
179
|
+
return cast(TransportRequirements | None, requirements)
|
kpubdata/config.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Configuration management — explicit construction and environment-based loading.
|
|
2
|
+
|
|
3
|
+
Key lookup order for provider keys:
|
|
4
|
+
1. Explicit `provider_keys` dict passed to constructor
|
|
5
|
+
2. Environment variable: KPUBDATA_{PROVIDER}_API_KEY (uppercased)
|
|
6
|
+
3. Environment variable: {PROVIDER}_API_KEY (uppercased, fallback)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from kpubdata.exceptions import ConfigError
|
|
17
|
+
|
|
18
|
+
_ENV_KEY_PATTERN = re.compile(r"^KPUBDATA_([A-Z0-9_]+)_API_KEY$")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class KPubDataConfig:
|
|
23
|
+
"""Framework configuration."""
|
|
24
|
+
|
|
25
|
+
provider_keys: dict[str, str] = field(default_factory=dict)
|
|
26
|
+
timeout: float = 30.0
|
|
27
|
+
max_retries: int = 3
|
|
28
|
+
extra: dict[str, object] = field(default_factory=dict)
|
|
29
|
+
|
|
30
|
+
def __repr__(self) -> str:
|
|
31
|
+
"""Return concise debug representation without exposing secrets."""
|
|
32
|
+
providers = sorted(self.provider_keys.keys())
|
|
33
|
+
return (
|
|
34
|
+
"KPubDataConfig("
|
|
35
|
+
f"providers={providers}, "
|
|
36
|
+
f"timeout={self.timeout}, "
|
|
37
|
+
f"max_retries={self.max_retries}, "
|
|
38
|
+
f"extra_keys={sorted(self.extra.keys())}"
|
|
39
|
+
")"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def get_provider_key(self, provider: str) -> str | None:
|
|
43
|
+
"""Look up API key for a provider following documented precedence."""
|
|
44
|
+
normalized_provider = _normalize_provider_name(provider)
|
|
45
|
+
|
|
46
|
+
explicit = _get_explicit_key(self.provider_keys, normalized_provider)
|
|
47
|
+
if explicit:
|
|
48
|
+
return explicit
|
|
49
|
+
|
|
50
|
+
provider_token = _provider_env_token(normalized_provider)
|
|
51
|
+
kpub_var = f"KPUBDATA_{provider_token}_API_KEY"
|
|
52
|
+
value = os.environ.get(kpub_var)
|
|
53
|
+
if value:
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
fallback_var = f"{provider_token}_API_KEY"
|
|
57
|
+
fallback_value = os.environ.get(fallback_var)
|
|
58
|
+
if fallback_value:
|
|
59
|
+
return fallback_value
|
|
60
|
+
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
def require_provider_key(self, provider: str) -> str:
|
|
64
|
+
"""Like get_provider_key but raises ConfigError if missing."""
|
|
65
|
+
key = self.get_provider_key(provider)
|
|
66
|
+
if key is not None:
|
|
67
|
+
return key
|
|
68
|
+
raise ConfigError(f"Missing provider API key for '{provider}'")
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def from_env(cls, **overrides: Any) -> KPubDataConfig:
|
|
72
|
+
"""Build config from environment variables.
|
|
73
|
+
|
|
74
|
+
Scans for KPUBDATA_*_API_KEY patterns.
|
|
75
|
+
Overrides can be passed as kwargs.
|
|
76
|
+
"""
|
|
77
|
+
scanned_keys: dict[str, str] = {}
|
|
78
|
+
for env_name, env_value in os.environ.items():
|
|
79
|
+
match = _ENV_KEY_PATTERN.match(env_name)
|
|
80
|
+
if match is None:
|
|
81
|
+
continue
|
|
82
|
+
if not env_value:
|
|
83
|
+
continue
|
|
84
|
+
provider_name = match.group(1).lower()
|
|
85
|
+
scanned_keys[provider_name] = env_value
|
|
86
|
+
|
|
87
|
+
provider_overrides_raw = overrides.pop("provider_keys", None)
|
|
88
|
+
provider_overrides: dict[str, str] = {}
|
|
89
|
+
if isinstance(provider_overrides_raw, dict):
|
|
90
|
+
for key, value in provider_overrides_raw.items():
|
|
91
|
+
if isinstance(key, str) and isinstance(value, str) and value:
|
|
92
|
+
provider_overrides[_normalize_provider_name(key)] = value
|
|
93
|
+
|
|
94
|
+
merged_provider_keys = scanned_keys.copy()
|
|
95
|
+
merged_provider_keys.update(provider_overrides)
|
|
96
|
+
|
|
97
|
+
return cls(provider_keys=merged_provider_keys, **overrides)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _normalize_provider_name(provider: str) -> str:
|
|
101
|
+
return provider.strip().lower()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _provider_env_token(provider: str) -> str:
|
|
105
|
+
token = re.sub(r"[^A-Za-z0-9]", "_", provider)
|
|
106
|
+
return token.upper()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _get_explicit_key(provider_keys: dict[str, str], provider: str) -> str | None:
|
|
110
|
+
if provider in provider_keys and provider_keys[provider]:
|
|
111
|
+
return provider_keys[provider]
|
|
112
|
+
|
|
113
|
+
for name, value in provider_keys.items():
|
|
114
|
+
if name.lower() == provider and value:
|
|
115
|
+
return value
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
__all__ = ["KPubDataConfig"]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Core canonical models and capability metadata for KPubData."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from kpubdata.core.capability import Operation, PaginationMode, QuerySupport
|
|
6
|
+
from kpubdata.core.models import (
|
|
7
|
+
DatasetRef,
|
|
8
|
+
FieldDescriptor,
|
|
9
|
+
Query,
|
|
10
|
+
RecordBatch,
|
|
11
|
+
SchemaDescriptor,
|
|
12
|
+
)
|
|
13
|
+
from kpubdata.core.representation import Representation
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"DatasetRef",
|
|
17
|
+
"FieldDescriptor",
|
|
18
|
+
"Operation",
|
|
19
|
+
"PaginationMode",
|
|
20
|
+
"Query",
|
|
21
|
+
"QuerySupport",
|
|
22
|
+
"RecordBatch",
|
|
23
|
+
"Representation",
|
|
24
|
+
"SchemaDescriptor",
|
|
25
|
+
]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Capability metadata describing dataset operations and query support."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass as _stdlib_dataclass
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import TypeVar
|
|
9
|
+
|
|
10
|
+
from typing_extensions import dataclass_transform
|
|
11
|
+
|
|
12
|
+
_T = TypeVar("_T")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass_transform()
|
|
16
|
+
def _dataclass(
|
|
17
|
+
*,
|
|
18
|
+
slots: bool = False,
|
|
19
|
+
frozen: bool = False,
|
|
20
|
+
) -> Callable[[type[_T]], type[_T]]:
|
|
21
|
+
def _decorate(cls: type[_T]) -> type[_T]:
|
|
22
|
+
return _stdlib_dataclass(slots=slots, frozen=frozen)(cls) # pyright: ignore[reportCallIssue]
|
|
23
|
+
|
|
24
|
+
return _decorate
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Operation(str, Enum):
|
|
28
|
+
"""Major operations a dataset can support."""
|
|
29
|
+
|
|
30
|
+
LIST = "list"
|
|
31
|
+
GET = "get"
|
|
32
|
+
SCHEMA = "schema"
|
|
33
|
+
RAW = "raw"
|
|
34
|
+
DOWNLOAD = "download"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PaginationMode(str, Enum):
|
|
38
|
+
"""How a dataset supports pagination."""
|
|
39
|
+
|
|
40
|
+
OFFSET = "offset"
|
|
41
|
+
CURSOR = "cursor"
|
|
42
|
+
NONE = "none"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@_dataclass(slots=True, frozen=True)
|
|
46
|
+
class QuerySupport:
|
|
47
|
+
"""Structured metadata about the list-query features a dataset supports."""
|
|
48
|
+
|
|
49
|
+
pagination: PaginationMode = PaginationMode.NONE
|
|
50
|
+
filterable_fields: frozenset[str] = frozenset()
|
|
51
|
+
sortable_fields: frozenset[str] = frozenset()
|
|
52
|
+
time_range: bool = False
|
|
53
|
+
max_page_size: int | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
__all__ = ["Operation", "PaginationMode", "QuerySupport"]
|
kpubdata/core/dataset.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Bound Dataset — the user-facing object for dataset operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from kpubdata.core.capability import Operation
|
|
6
|
+
from kpubdata.core.models import DatasetRef, Query, RecordBatch, SchemaDescriptor
|
|
7
|
+
from kpubdata.core.protocol import ProviderAdapter
|
|
8
|
+
from kpubdata.exceptions import UnsupportedCapabilityError
|
|
9
|
+
|
|
10
|
+
_CANONICAL_QUERY_KEYS = frozenset(
|
|
11
|
+
{"page", "page_size", "cursor", "start_date", "end_date", "fields", "sort"}
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Dataset:
|
|
16
|
+
"""Bound dataset that routes operations to a provider adapter."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, ref: DatasetRef, adapter: ProviderAdapter) -> None:
|
|
19
|
+
"""Initialize a dataset bound to its canonical ref and adapter."""
|
|
20
|
+
|
|
21
|
+
self._ref = ref
|
|
22
|
+
self._adapter = adapter
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def ref(self) -> DatasetRef:
|
|
26
|
+
"""Return immutable canonical dataset reference."""
|
|
27
|
+
|
|
28
|
+
return self._ref
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def id(self) -> str:
|
|
32
|
+
"""Return canonical dataset identifier."""
|
|
33
|
+
|
|
34
|
+
return self._ref.id
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def name(self) -> str:
|
|
38
|
+
"""Return human-readable dataset name."""
|
|
39
|
+
|
|
40
|
+
return self._ref.name
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def provider(self) -> str:
|
|
44
|
+
"""Return provider identifier serving this dataset."""
|
|
45
|
+
|
|
46
|
+
return self._ref.provider
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def operations(self) -> frozenset[Operation]:
|
|
50
|
+
"""Return declared operation capabilities for this dataset."""
|
|
51
|
+
|
|
52
|
+
return self._ref.operations
|
|
53
|
+
|
|
54
|
+
def list(self, **kwargs: object) -> RecordBatch:
|
|
55
|
+
"""Query records from this dataset using canonical list semantics.
|
|
56
|
+
|
|
57
|
+
Canonical query parameters (``page``, ``page_size``, ``cursor``,
|
|
58
|
+
``start_date``, ``end_date``, ``fields``, ``sort``) are extracted
|
|
59
|
+
into the corresponding ``Query`` fields. Remaining kwargs are
|
|
60
|
+
passed as provider-specific ``filters``.
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
UnsupportedCapabilityError: If this dataset does not support ``list``.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
if Operation.LIST not in self._ref.operations:
|
|
67
|
+
raise UnsupportedCapabilityError(
|
|
68
|
+
f"Dataset does not support list: {self._ref.id}",
|
|
69
|
+
provider=self._ref.provider,
|
|
70
|
+
dataset_id=self._ref.id,
|
|
71
|
+
operation=Operation.LIST.value,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
page: int | None = None
|
|
75
|
+
page_size: int | None = None
|
|
76
|
+
cursor: str | None = None
|
|
77
|
+
start_date: str | None = None
|
|
78
|
+
end_date: str | None = None
|
|
79
|
+
fields_list: list[str] | None = None
|
|
80
|
+
sort_list: list[str] | None = None
|
|
81
|
+
filters: dict[str, object] = {}
|
|
82
|
+
|
|
83
|
+
for key, value in kwargs.items():
|
|
84
|
+
if key == "page" and isinstance(value, int):
|
|
85
|
+
page = value
|
|
86
|
+
elif key == "page_size" and isinstance(value, int):
|
|
87
|
+
page_size = value
|
|
88
|
+
elif key == "cursor" and isinstance(value, str):
|
|
89
|
+
cursor = value
|
|
90
|
+
elif key == "start_date" and isinstance(value, str):
|
|
91
|
+
start_date = value
|
|
92
|
+
elif key == "end_date" and isinstance(value, str):
|
|
93
|
+
end_date = value
|
|
94
|
+
elif key == "fields" and isinstance(value, list):
|
|
95
|
+
fields_list = value
|
|
96
|
+
elif key == "sort" and isinstance(value, list):
|
|
97
|
+
sort_list = value
|
|
98
|
+
else:
|
|
99
|
+
filters[key] = value
|
|
100
|
+
|
|
101
|
+
query = Query(
|
|
102
|
+
filters=filters,
|
|
103
|
+
page=page,
|
|
104
|
+
page_size=page_size,
|
|
105
|
+
cursor=cursor,
|
|
106
|
+
start_date=start_date,
|
|
107
|
+
end_date=end_date,
|
|
108
|
+
fields=fields_list,
|
|
109
|
+
sort=sort_list,
|
|
110
|
+
)
|
|
111
|
+
return self._adapter.query_records(self._ref, query)
|
|
112
|
+
|
|
113
|
+
def get(self, **key: object) -> dict[str, object] | None:
|
|
114
|
+
"""Return a single record matching the provided key fields.
|
|
115
|
+
|
|
116
|
+
Return ``None`` when no matching record is found.
|
|
117
|
+
|
|
118
|
+
Raises:
|
|
119
|
+
UnsupportedCapabilityError: If this dataset does not support ``get``.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
if Operation.GET not in self._ref.operations:
|
|
123
|
+
raise UnsupportedCapabilityError(
|
|
124
|
+
f"Dataset does not support get: {self._ref.id}",
|
|
125
|
+
provider=self._ref.provider,
|
|
126
|
+
dataset_id=self._ref.id,
|
|
127
|
+
operation=Operation.GET.value,
|
|
128
|
+
)
|
|
129
|
+
key_payload: dict[str, object] = {k: v for k, v in key.items()}
|
|
130
|
+
return self._adapter.get_record(self._ref, key_payload)
|
|
131
|
+
|
|
132
|
+
def schema(self) -> SchemaDescriptor | None:
|
|
133
|
+
"""Return canonical schema metadata when the provider exposes it."""
|
|
134
|
+
|
|
135
|
+
return self._adapter.get_schema(self._ref)
|
|
136
|
+
|
|
137
|
+
def call_raw(self, operation: str, **params: object) -> object:
|
|
138
|
+
"""Execute a provider-native operation without canonical normalization.
|
|
139
|
+
|
|
140
|
+
Use this escape hatch for provider features not represented in the
|
|
141
|
+
canonical model.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
payload: dict[str, object] = {k: v for k, v in params.items()}
|
|
145
|
+
return self._adapter.call_raw(self._ref, operation, payload)
|
|
146
|
+
|
|
147
|
+
def __repr__(self) -> str:
|
|
148
|
+
"""Return concise debug representation."""
|
|
149
|
+
|
|
150
|
+
return f"Dataset({self._ref.id!r})"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
__all__ = ["Dataset"]
|