polymo 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.
polymo/config.py ADDED
@@ -0,0 +1,480 @@
1
+ """Configuration loading and validation for REST-backed data sources."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List, Literal, Optional, Mapping
8
+ import re
9
+
10
+ import yaml
11
+ from pyspark.sql.types import (
12
+ BooleanType,
13
+ ByteType,
14
+ DateType,
15
+ DecimalType,
16
+ DoubleType,
17
+ FloatType,
18
+ IntegerType,
19
+ LongType,
20
+ ShortType,
21
+ StringType,
22
+ StructField,
23
+ StructType,
24
+ TimestampType,
25
+ )
26
+
27
+
28
+ class ConfigError(ValueError):
29
+ """Raised when the user-provided YAML configuration is invalid."""
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class AuthConfig:
34
+ """Authentication configuration for REST requests."""
35
+
36
+ type: Literal["none", "bearer"] = "none"
37
+ token: str | None = None
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class PaginationConfig:
42
+ """Pagination strategy definition."""
43
+
44
+ type: Literal["none", "link_header"] = "none"
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class SchemaConfig:
49
+ """Schema hints supplied by the user."""
50
+
51
+ infer: bool = False
52
+ ddl: str | None = None
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class IncrementalConfig:
57
+ """Incremental loading hints for future extensions."""
58
+
59
+ mode: Optional[str] = None
60
+ cursor_param: Optional[str] = None
61
+ cursor_field: Optional[str] = None
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class RecordSelectorConfig:
66
+ """Record selector configuration inspired by Airbyte's builder."""
67
+
68
+ field_path: List[str] = field(default_factory=list)
69
+ record_filter: Optional[str] = None
70
+ cast_to_schema_types: bool = False
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class StreamConfig:
75
+ """Definition of a logical stream within the REST connector."""
76
+
77
+ name: str # internal identifier (derived from path if not provided)
78
+ path: str
79
+ params: Dict[str, Any] = field(default_factory=dict)
80
+ headers: Dict[str, str] = field(default_factory=dict)
81
+ pagination: PaginationConfig = field(default_factory=PaginationConfig)
82
+ incremental: IncrementalConfig = field(default_factory=IncrementalConfig)
83
+ infer_schema: bool = True
84
+ schema: str | None = None
85
+ record_selector: RecordSelectorConfig = field(default_factory=RecordSelectorConfig)
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class RestSourceConfig:
90
+ """Top-level configuration mapping for the connector."""
91
+
92
+ version: str
93
+ base_url: str
94
+ auth: AuthConfig
95
+ stream: StreamConfig
96
+ options: Dict[str, Any] = field(default_factory=dict)
97
+
98
+
99
+ def load_config(
100
+ path: str | Path,
101
+ token: str | None = None,
102
+ options: Optional[Mapping[str, Any]] = None,
103
+ ) -> RestSourceConfig:
104
+ """Load and validate a REST source configuration from YAML.
105
+
106
+ Authentication details (token) are supplied separately and are NOT part of the YAML.
107
+ """
108
+
109
+ config_path = Path(path)
110
+ if not config_path.exists():
111
+ raise ConfigError(f"Configuration file not found: {config_path}")
112
+
113
+ raw = yaml.safe_load(config_path.read_text())
114
+ return parse_config(raw, token=token, options=options)
115
+
116
+
117
+ def parse_config(
118
+ raw: Any,
119
+ token: str | None = None,
120
+ options: Optional[Mapping[str, Any]] = None,
121
+ ) -> RestSourceConfig:
122
+ """Validate a configuration object previously parsed from YAML.
123
+
124
+ Auth info is provided separately via the token argument.
125
+ """
126
+
127
+ if not isinstance(raw, dict):
128
+ raise ConfigError("Configuration root must be a mapping")
129
+
130
+ version = str(raw.get("version"))
131
+ if version not in {"0.1"}:
132
+ raise ConfigError("Only version '0.1' configurations are supported")
133
+
134
+ source = raw.get("source")
135
+ if not isinstance(source, dict):
136
+ raise ConfigError("'source' section must be provided")
137
+
138
+ if source.get("type") != "rest":
139
+ raise ConfigError("Only REST sources are supported for now")
140
+
141
+ # token decides.
142
+ if token:
143
+ auth = AuthConfig(type="bearer", token=token)
144
+ else:
145
+ auth = AuthConfig()
146
+
147
+ base_url = source.get("base_url")
148
+ if not isinstance(base_url, str) or not base_url:
149
+ raise ConfigError("'source.base_url' must be a non-empty string")
150
+
151
+ # Only support single stream format
152
+ stream_raw = raw.get("stream")
153
+ if not stream_raw:
154
+ raise ConfigError("A stream must be defined")
155
+
156
+ stream = _parse_stream(stream_raw)
157
+
158
+ runtime_options = dict(options or {})
159
+
160
+ return RestSourceConfig(
161
+ version=version,
162
+ base_url=base_url.rstrip("/"),
163
+ auth=auth,
164
+ stream=stream,
165
+ options=runtime_options,
166
+ )
167
+
168
+
169
+ def config_to_dict(config: RestSourceConfig) -> Dict[str, Any]:
170
+ """Convert a RestSourceConfig instance into a canonical plain dict.
171
+
172
+ Includes auth type (without secret) so UIs can remember selection.
173
+ """
174
+
175
+ source: Dict[str, Any] = {
176
+ "type": "rest",
177
+ "base_url": config.base_url,
178
+ }
179
+ if config.auth.type == "bearer":
180
+ # Expose only the auth type, never the token.
181
+ source["auth"] = {"type": config.auth.type}
182
+
183
+ stream = config.stream
184
+ stream_dict: Dict[str, Any] = {
185
+ # 'name' intentionally omitted from external representation
186
+ "path": stream.path,
187
+ "infer_schema": stream.infer_schema,
188
+ "schema": stream.schema,
189
+ "pagination": {"type": stream.pagination.type},
190
+ }
191
+
192
+ if stream.params:
193
+ stream_dict["params"] = dict(stream.params)
194
+
195
+ if stream.headers:
196
+ stream_dict["headers"] = dict(stream.headers)
197
+
198
+ # Always include incremental object, even if all fields are null
199
+ incremental: Dict[str, Any] = {
200
+ "mode": stream.incremental.mode,
201
+ "cursor_param": stream.incremental.cursor_param,
202
+ "cursor_field": stream.incremental.cursor_field,
203
+ }
204
+ stream_dict["incremental"] = incremental
205
+
206
+ selector = stream.record_selector
207
+ stream_dict["record_selector"] = {
208
+ "field_path": list(selector.field_path),
209
+ "record_filter": selector.record_filter,
210
+ "cast_to_schema_types": selector.cast_to_schema_types,
211
+ }
212
+
213
+ return {
214
+ "version": config.version,
215
+ "source": source,
216
+ "stream": stream_dict,
217
+ }
218
+
219
+
220
+ def dump_config(config: RestSourceConfig) -> str:
221
+ """Render a configuration as canonical YAML.
222
+
223
+ Auth is intentionally stripped to avoid persisting secrets or auth type in YAML.
224
+ """
225
+
226
+ data = config_to_dict(config)
227
+ data["source"].pop("auth", None)
228
+ return yaml.safe_dump(data, sort_keys=False)
229
+
230
+
231
+ def _parse_auth(auth: dict) -> AuthConfig: # Deprecated – retained for backward compatibility (unused)
232
+ token = auth.get("token")
233
+ auth_type = auth.get("type")
234
+ if auth_type == "bearer":
235
+ if not isinstance(token, str):
236
+ raise ConfigError("'token' must be a string")
237
+ if token == "":
238
+ raise ConfigError("'token' cannot be empty")
239
+ return AuthConfig(type=auth_type, token=token)
240
+
241
+
242
+ def _parse_stream(raw: Any) -> StreamConfig:
243
+ if not isinstance(raw, dict):
244
+ raise ConfigError("Each stream must be a mapping")
245
+
246
+ path = raw.get("path")
247
+ if not isinstance(path, str) or not path.startswith("/"):
248
+ raise ConfigError("Stream 'path' must be an absolute path starting with '/'")
249
+
250
+ # Derive name if not supplied
251
+ raw_name = raw.get("name")
252
+ if isinstance(raw_name, str) and raw_name.strip():
253
+ name = raw_name.strip()
254
+ else:
255
+ # derive from path: strip leading '/', replace '/' with '_', fallback to 'stream'
256
+ derived = path.lstrip("/").replace("/", "_") or "stream"
257
+ name = derived
258
+
259
+ params = raw.get("params", {})
260
+ if params is None:
261
+ params = {}
262
+ if not isinstance(params, dict):
263
+ raise ConfigError("Stream 'params' must be a mapping when provided")
264
+
265
+ headers = raw.get("headers", {})
266
+ if headers is None:
267
+ headers = {}
268
+ if not isinstance(headers, dict):
269
+ raise ConfigError("Stream 'headers' must be a mapping when provided")
270
+
271
+ pagination = _parse_pagination(raw.get("pagination"))
272
+ incremental = _parse_incremental(raw.get("incremental"))
273
+ record_selector = _parse_record_selector(raw.get("record_selector"))
274
+ infer_schema = raw.get("infer_schema")
275
+ schema = raw.get("schema")
276
+ if not infer_schema and not schema:
277
+ # Default to true if neither is provided
278
+ infer_schema = True
279
+ if schema:
280
+ if not isinstance(schema, str) or not schema.strip():
281
+ raise ConfigError("'schema' must be a non-empty string when provided")
282
+ try:
283
+ _validate_ddl(schema)
284
+ except Exception as e:
285
+ raise ConfigError(f"Invalid schema DDL: {e}") from e
286
+
287
+ resolved_params = {key: _coerce_env(value) for key, value in params.items()}
288
+ resolved_headers = {key: _coerce_env(value) for key, value in headers.items()}
289
+
290
+ return StreamConfig(
291
+ name=name,
292
+ path=path,
293
+ params=resolved_params,
294
+ headers=resolved_headers,
295
+ pagination=pagination,
296
+ incremental=incremental,
297
+ infer_schema=infer_schema,
298
+ schema=schema,
299
+ record_selector=record_selector,
300
+ )
301
+
302
+
303
+ def _parse_pagination(raw: Any) -> PaginationConfig:
304
+ if raw is None:
305
+ return PaginationConfig()
306
+ if not isinstance(raw, dict):
307
+ raise ConfigError("'pagination' must be a mapping when provided")
308
+
309
+ pag_type = raw.get("type", "none")
310
+ if pag_type not in {"none", "link_header"}:
311
+ raise ConfigError(f"Unsupported pagination type: {pag_type}")
312
+
313
+ return PaginationConfig(type=pag_type)
314
+
315
+
316
+ def _parse_incremental(raw: Any) -> IncrementalConfig:
317
+ if raw is None:
318
+ return IncrementalConfig()
319
+ if not isinstance(raw, dict):
320
+ raise ConfigError("'incremental' must be a mapping when provided")
321
+
322
+ mode = raw.get("mode")
323
+ cursor_param = raw.get("cursor_param")
324
+ cursor_field = raw.get("cursor_field")
325
+
326
+ return IncrementalConfig(
327
+ mode=str(mode) if mode else None,
328
+ cursor_param=str(cursor_param) if cursor_param else None,
329
+ cursor_field=str(cursor_field) if cursor_field else None,
330
+ )
331
+
332
+
333
+ def _parse_record_selector(raw: Any) -> RecordSelectorConfig:
334
+ if raw is None:
335
+ return RecordSelectorConfig()
336
+ if not isinstance(raw, dict):
337
+ raise ConfigError("'record_selector' must be a mapping when provided")
338
+
339
+ field_path_raw = raw.get("field_path", [])
340
+ if isinstance(field_path_raw, str):
341
+ field_path = [field_path_raw]
342
+ elif isinstance(field_path_raw, list):
343
+ field_path = []
344
+ for entry in field_path_raw:
345
+ if not isinstance(entry, str) or not entry.strip():
346
+ raise ConfigError("Each entry in 'record_selector.field_path' must be a non-empty string")
347
+ field_path.append(entry.strip())
348
+ else:
349
+ raise ConfigError("'record_selector.field_path' must be a list of strings or a string")
350
+
351
+ record_filter = raw.get("record_filter")
352
+ if record_filter is not None:
353
+ if not isinstance(record_filter, str) or not record_filter.strip():
354
+ raise ConfigError("'record_selector.record_filter' must be a non-empty string when provided")
355
+ record_filter = record_filter.strip()
356
+
357
+ cast_to_schema_types = bool(raw.get("cast_to_schema_types", False))
358
+
359
+ return RecordSelectorConfig(
360
+ field_path=field_path,
361
+ record_filter=record_filter,
362
+ cast_to_schema_types=cast_to_schema_types,
363
+ )
364
+
365
+
366
+ def _validate_ddl(ddl: str) -> None:
367
+ """Validate schema DDL without requiring a running Spark session."""
368
+ parse_schema_struct(ddl)
369
+
370
+
371
+ def parse_schema_struct(schema_text: str) -> StructType:
372
+ """Parse a Spark SQL DDL string into a StructType without needing Spark."""
373
+
374
+ try:
375
+ return StructType.fromDDL(schema_text)
376
+ except Exception as original_exc: # pragma: no cover - requires Spark
377
+ try:
378
+ return _parse_ddl_without_spark(schema_text)
379
+ except Exception as fallback_exc:
380
+ raise ValueError(f"Unable to parse schema: {fallback_exc}") from original_exc
381
+
382
+ def _coerce_env(value: Any) -> Any:
383
+ if isinstance(value, str) and value.startswith("${env:") and value.endswith("}"):
384
+ env_var = value[len("${env:") : -1]
385
+ return _resolve_env(env_var)
386
+ if isinstance(value, list):
387
+ return [_coerce_env(item) for item in value]
388
+ if isinstance(value, dict):
389
+ return {key: _coerce_env(item) for key, item in value.items()}
390
+ return value
391
+
392
+
393
+ def _resolve_env(name: str) -> str:
394
+ from os import getenv
395
+
396
+ resolved = getenv(name)
397
+ if resolved is None:
398
+ raise ConfigError(f"Environment variable '{name}' is not set")
399
+ return resolved
400
+
401
+
402
+ def _parse_ddl_without_spark(schema_text: str) -> StructType:
403
+ if not schema_text or not schema_text.strip():
404
+ raise ValueError("Schema definition is empty")
405
+
406
+ field_defs = _split_top_level(schema_text)
407
+ if not field_defs:
408
+ raise ValueError("Schema definition has no fields")
409
+
410
+ fields: List[StructField] = []
411
+ for field_def in field_defs:
412
+ parts = field_def.split(None, 1)
413
+ if len(parts) < 2:
414
+ raise ValueError(f"Invalid field definition: '{field_def}'")
415
+ name, type_spec = parts[0], parts[1].strip()
416
+ data_type = _parse_simple_type(type_spec)
417
+ fields.append(StructField(name, data_type, nullable=True))
418
+
419
+ return StructType(fields)
420
+
421
+
422
+ def _split_top_level(schema_text: str) -> List[str]:
423
+ parts: List[str] = []
424
+ current: List[str] = []
425
+ depth = 0
426
+ for ch in schema_text:
427
+ if ch == "<" or ch == "(":
428
+ depth += 1
429
+ elif ch == ">" or ch == ")":
430
+ depth = max(0, depth - 1)
431
+ if ch == "," and depth == 0:
432
+ part = "".join(current).strip()
433
+ if part:
434
+ parts.append(part)
435
+ current = []
436
+ continue
437
+ current.append(ch)
438
+
439
+ tail = "".join(current).strip()
440
+ if tail:
441
+ parts.append(tail)
442
+ return parts
443
+
444
+
445
+ _DECIMAL_PATTERN = re.compile(r"decimal\s*\((\d+)\s*,\s*(\d+)\)", re.IGNORECASE)
446
+
447
+
448
+ def _parse_simple_type(type_spec: str):
449
+ normalized = type_spec.strip().lower()
450
+
451
+ if normalized.startswith("decimal") or normalized.startswith("numeric"):
452
+ match = _DECIMAL_PATTERN.search(normalized)
453
+ if match:
454
+ precision = int(match.group(1))
455
+ scale = int(match.group(2))
456
+ return DecimalType(precision, scale)
457
+ return DecimalType(38, 18)
458
+
459
+ if normalized in {"string", "varchar", "char", "text"}:
460
+ return StringType()
461
+ if normalized in {"boolean", "bool"}:
462
+ return BooleanType()
463
+ if normalized in {"double", "float64"}:
464
+ return DoubleType()
465
+ if normalized in {"float", "real"}:
466
+ return FloatType()
467
+ if normalized in {"tinyint"}:
468
+ return ByteType()
469
+ if normalized in {"smallint"}:
470
+ return ShortType()
471
+ if normalized in {"int", "integer"}:
472
+ return IntegerType()
473
+ if normalized in {"bigint", "long"}:
474
+ return LongType()
475
+ if normalized == "timestamp":
476
+ return TimestampType()
477
+ if normalized == "date":
478
+ return DateType()
479
+
480
+ raise ValueError(f"Unsupported type expression '{type_spec}' without Spark runtime")
polymo/datasource.py ADDED
@@ -0,0 +1,184 @@
1
+ """PySpark DataSource v2 implementation for REST-backed datasets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any, Dict, Iterator, List, Mapping, Optional, Sequence
7
+
8
+ import pyarrow as pa
9
+ from pyspark.sql import SparkSession
10
+ from pyspark.sql.datasource import DataSource, DataSourceReader, InputPartition
11
+ from pyspark.sql.types import (
12
+ BooleanType,
13
+ DoubleType,
14
+ LongType,
15
+ StringType,
16
+ StructField,
17
+ StructType,
18
+ _parse_datatype_string
19
+ )
20
+
21
+ from .config import ConfigError, RestSourceConfig, load_config
22
+ from .rest_client import RestClient
23
+
24
+
25
+
26
+
27
+ class ApiReader(DataSource):
28
+ """Expose `spark.read.format("polymo")` over YAML-defined REST streams."""
29
+
30
+ def __init__(self, options: Dict[str, str]) -> None:
31
+ super().__init__(options)
32
+ self._config = _load_source_config(self.options)
33
+ self._schema: Optional[StructType] = None
34
+
35
+ @classmethod
36
+ def name(cls) -> str:
37
+ return "polymo"
38
+
39
+ def schema(self) -> StructType:
40
+ if self._config.stream.schema:
41
+ # Use user-provided schema if available
42
+ SparkSession.builder.getOrCreate()
43
+ self._schema = _parse_datatype_string(self._config.stream.schema)
44
+ if self._schema is None:
45
+ # Always infer schema when no explicit schema is provided
46
+ self._schema = _infer_schema(self._config)
47
+
48
+ return self._schema
49
+
50
+ def reader(self, schema: StructType) -> DataSourceReader:
51
+ return RestDataSourceReader(self._config, self.schema())
52
+
53
+ class RestInputPartition(InputPartition):
54
+ def __init__(self, config: RestSourceConfig) -> None:
55
+ super().__init__(value=None)
56
+ self.config = config
57
+
58
+
59
+ class RestDataSourceReader(DataSourceReader):
60
+ """Materialises REST API responses as Arrow record batches."""
61
+
62
+ def __init__(self, config: RestSourceConfig, schema: StructType) -> None:
63
+ self._config = config
64
+ self._schema = schema
65
+
66
+ def partitions(self) -> Sequence[InputPartition]:
67
+ # Create one partition for the stream
68
+ return [
69
+ RestInputPartition(self._config)
70
+ ]
71
+
72
+ def read(self, partition: InputPartition) -> Iterator[pa.RecordBatch]:
73
+ assert isinstance(partition, RestInputPartition)
74
+ yield from _read_partition(partition.config, self._schema)
75
+
76
+
77
+ def _load_source_config(options: Mapping[str, str]) -> RestSourceConfig:
78
+ config_path = options.get("config_path")
79
+ token = options.get("token")
80
+ if not config_path:
81
+ raise ConfigError("Option 'config_path' is required")
82
+
83
+ runtime_options = {
84
+ key: value
85
+ for key, value in options.items()
86
+ if key not in {"config_path", "token"}
87
+ }
88
+
89
+ return load_config(config_path, token, runtime_options)
90
+
91
+
92
+ def _infer_schema(config: RestSourceConfig) -> StructType:
93
+ sample_records = _sample_stream(config)
94
+ if not sample_records:
95
+ return StructType([])
96
+
97
+ seen: Dict[str, None] = {}
98
+ ordered_keys: List[str] = []
99
+ for record in sample_records:
100
+ for key in record.keys():
101
+ if key not in seen:
102
+ seen[key] = None
103
+ ordered_keys.append(key)
104
+
105
+ fields = []
106
+ for key in ordered_keys:
107
+ sample_value = next((row.get(key) for row in sample_records if row.get(key) is not None), None)
108
+ dtype = _infer_type(sample_value)
109
+ fields.append(StructField(key, dtype, nullable=True))
110
+ return StructType(fields)
111
+
112
+
113
+ def _sample_stream(config: RestSourceConfig) -> List[Mapping[str, Any]]:
114
+ with RestClient(base_url=config.base_url, auth=config.auth, options=config.options) as client:
115
+ iterator = client.fetch_records(config.stream)
116
+ first_page = next(iterator, [])
117
+ if isinstance(first_page, list):
118
+ return first_page[:50]
119
+ return []
120
+
121
+
122
+ def _read_partition(config: RestSourceConfig, schema: StructType) -> Iterator[pa.RecordBatch]:
123
+ """Read data from the stream."""
124
+ with RestClient(base_url=config.base_url, auth=config.auth, options=config.options) as client:
125
+ for page in client.fetch_records(config.stream):
126
+ if not page:
127
+ continue
128
+
129
+ # Single stream format: use original record structure
130
+ batch = _records_to_batch(page, schema)
131
+
132
+ if batch.num_rows:
133
+ yield batch
134
+
135
+
136
+ def _records_to_batch(records: List[Mapping[str, Any]], schema: StructType) -> pa.RecordBatch:
137
+ arrays = []
138
+ field_names = []
139
+
140
+ for field in schema:
141
+ column = [_coerce_value(record.get(field.name), field.dataType) for record in records]
142
+ arrays.append(_to_arrow_array(column, field.dataType))
143
+ field_names.append(field.name)
144
+
145
+ return pa.record_batch(arrays, names=field_names)
146
+
147
+
148
+ def _infer_type(value: Any) -> StringType | LongType | DoubleType | BooleanType:
149
+ if isinstance(value, bool):
150
+ return BooleanType()
151
+ if isinstance(value, int):
152
+ return LongType()
153
+ if isinstance(value, float):
154
+ return DoubleType()
155
+ # For nested structures default to string JSON payloads.
156
+ return StringType()
157
+
158
+
159
+ def _coerce_value(value: Any, data_type: Any) -> Any:
160
+ if value is None:
161
+ return None
162
+ if isinstance(data_type, StringType):
163
+ if isinstance(value, (dict, list)):
164
+ return json.dumps(value, separators=(",", ":"), sort_keys=True)
165
+ return str(value)
166
+ if isinstance(data_type, LongType):
167
+ return int(value)
168
+ if isinstance(data_type, DoubleType):
169
+ return float(value)
170
+ if isinstance(data_type, BooleanType):
171
+ return bool(value)
172
+ return str(value)
173
+
174
+
175
+ def _to_arrow_array(values: List[Any], data_type: Any) -> pa.Array:
176
+ if isinstance(data_type, StringType):
177
+ return pa.array(values, type=pa.string())
178
+ if isinstance(data_type, LongType):
179
+ return pa.array(values, type=pa.int64())
180
+ if isinstance(data_type, DoubleType):
181
+ return pa.array(values, type=pa.float64())
182
+ if isinstance(data_type, BooleanType):
183
+ return pa.array(values, type=pa.bool_())
184
+ return pa.array([str(v) if v is not None else None for v in values], type=pa.string())
polymo/py.typed ADDED
File without changes