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/rest_client.py ADDED
@@ -0,0 +1,416 @@
1
+ """Minimal REST client capable of streaming pages for the connector."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime
7
+ from decimal import Decimal
8
+ from typing import Any, Dict, Iterable, Iterator, List, Mapping, Optional
9
+
10
+ import httpx
11
+ from jinja2 import Environment, StrictUndefined, TemplateError
12
+
13
+ from pyspark.sql.types import (
14
+ ArrayType,
15
+ BooleanType,
16
+ ByteType,
17
+ DateType,
18
+ DecimalType,
19
+ DoubleType,
20
+ FloatType,
21
+ IntegerType,
22
+ LongType,
23
+ MapType,
24
+ ShortType,
25
+ StringType,
26
+ StructType,
27
+ TimestampType,
28
+ )
29
+
30
+ from .config import (
31
+ AuthConfig,
32
+ PaginationConfig,
33
+ RecordSelectorConfig,
34
+ StreamConfig,
35
+ parse_schema_struct,
36
+ )
37
+
38
+
39
+ USER_AGENT = "polymo-rest-source/0.1"
40
+
41
+ _FILTER_ENV = Environment(undefined=StrictUndefined, autoescape=False)
42
+ _FILTER_CACHE: Dict[str, Any] = {}
43
+ _TEMPLATE_ENV = Environment(undefined=StrictUndefined, autoescape=False)
44
+
45
+
46
+ @dataclass
47
+ class RestPage:
48
+ """Representation of a single page returned by the REST API."""
49
+
50
+ records: List[Mapping[str, Any]]
51
+ payload: Any
52
+ url: str
53
+ status_code: int
54
+ headers: Mapping[str, str]
55
+
56
+
57
+ @dataclass
58
+ class RestClient:
59
+ """Thin HTTP client tailored for REST-to-DataFrame ingestion."""
60
+
61
+ base_url: str
62
+ auth: AuthConfig
63
+ timeout: float = 30.0
64
+ options: Mapping[str, Any] = field(default_factory=dict)
65
+
66
+ def __post_init__(self) -> None:
67
+ headers = {"User-Agent": USER_AGENT}
68
+ if self.auth.type == "bearer" and self.auth.token:
69
+ headers["Authorization"] = f"Bearer {self.auth.token}"
70
+
71
+ self._client = httpx.Client(base_url=self.base_url, headers=headers, timeout=self.timeout)
72
+
73
+ def close(self) -> None:
74
+ self._client.close()
75
+
76
+ def fetch_records(self, stream: StreamConfig) -> Iterator[List[Mapping[str, Any]]]:
77
+ """Yield pages of JSON records for the provided stream definition."""
78
+
79
+ for page in self.fetch_pages(stream):
80
+ yield page.records
81
+
82
+ def fetch_pages(self, stream: StreamConfig) -> Iterator[RestPage]:
83
+ """Yield pages with rich metadata for the provided stream definition."""
84
+
85
+ template_context: Dict[str, Any] = {
86
+ "options": dict(self.options or {}),
87
+ "params": dict(stream.params or {}),
88
+ "headers": dict(stream.headers or {}),
89
+ "raw_params": dict(stream.params or {}),
90
+ }
91
+
92
+ rendered_params = {
93
+ key: _render_template(value, template_context)
94
+ for key, value in stream.params.items()
95
+ } if stream.params else {}
96
+
97
+ template_context["params"] = rendered_params
98
+
99
+ formatter = _PathFormatter(rendered_params)
100
+ rendered_path = _render_template(stream.path, template_context)
101
+ path = formatter.render(rendered_path)
102
+
103
+ query_params = {
104
+ key: _render_template(value, template_context)
105
+ for key, value in formatter.remaining_params().items()
106
+ }
107
+ pagination = stream.pagination
108
+
109
+ request_headers: Dict[str, str] = {}
110
+ if stream.headers:
111
+ for key, value in stream.headers.items():
112
+ request_headers[key] = _render_template(value, template_context)
113
+
114
+ declared_schema = _resolve_schema(stream)
115
+
116
+ yield from self._iterate_pages(
117
+ initial_path=path,
118
+ query_params=query_params,
119
+ pagination=pagination,
120
+ request_headers=request_headers if request_headers else None,
121
+ stream=stream,
122
+ declared_schema=declared_schema,
123
+ )
124
+
125
+ def _iterate_pages(
126
+ self,
127
+ *,
128
+ initial_path: str,
129
+ query_params: Dict[str, Any],
130
+ pagination: PaginationConfig,
131
+ request_headers: Optional[Dict[str, str]],
132
+ stream: StreamConfig,
133
+ declared_schema: Optional[StructType],
134
+ ) -> Iterator[RestPage]:
135
+ next_url: Optional[str] = initial_path
136
+
137
+ while next_url:
138
+ response = self._client.get(
139
+ next_url,
140
+ params=query_params if next_url == initial_path else None,
141
+ headers=request_headers,
142
+ )
143
+ response.raise_for_status()
144
+ payload = response.json()
145
+
146
+ records = _extract_records(payload, stream.record_selector, declared_schema)
147
+ if not isinstance(records, list):
148
+ raise ValueError("Expected API response to be a list of records")
149
+
150
+ yield RestPage(
151
+ records=records,
152
+ payload=payload,
153
+ url=str(response.url),
154
+ status_code=response.status_code,
155
+ headers=dict(response.headers),
156
+ )
157
+
158
+ next_url = _next_page(response, pagination)
159
+
160
+ def __enter__(self) -> "RestClient":
161
+ return self
162
+
163
+ def __exit__(self, *_: object) -> None:
164
+ self.close()
165
+
166
+
167
+ def _render_template(value: Any, context: Mapping[str, Any]) -> Any:
168
+ if not isinstance(value, str):
169
+ return value
170
+ if "{{" not in value and "{%" not in value:
171
+ return value
172
+ try:
173
+ template = _TEMPLATE_ENV.from_string(value)
174
+ return template.render(**context)
175
+ except TemplateError as exc:
176
+ raise ValueError(f"Error rendering template: {exc}") from exc
177
+
178
+
179
+ def _extract_records(
180
+ payload: Any,
181
+ selector: RecordSelectorConfig,
182
+ declared_schema: Optional[StructType],
183
+ ) -> List[Mapping[str, Any]]:
184
+ """Apply record selector settings to a response payload."""
185
+
186
+ records: Any
187
+ if selector.field_path:
188
+ records = _select_field_path(payload, selector.field_path)
189
+ else:
190
+ records = _normalise_payload(payload)
191
+
192
+ if not isinstance(records, list):
193
+ records = [records]
194
+
195
+ if selector.record_filter:
196
+ records = _filter_records(records, selector.record_filter)
197
+
198
+ if selector.cast_to_schema_types and declared_schema is not None:
199
+ records = [_cast_record(record, declared_schema) for record in records]
200
+
201
+ # Ensure we always return list of mappings
202
+ final: List[Mapping[str, Any]] = []
203
+ for record in records:
204
+ if isinstance(record, Mapping):
205
+ final.append(dict(record))
206
+ else:
207
+ final.append({"record": record})
208
+ return final
209
+
210
+
211
+ def _normalise_payload(payload: Any) -> Any:
212
+ if isinstance(payload, list):
213
+ return payload
214
+ if isinstance(payload, dict):
215
+ # Accept top-level "data" or "items" wrappers.
216
+ for key in ("data", "items", "results"):
217
+ if key in payload and isinstance(payload[key], list):
218
+ return payload[key]
219
+ return payload
220
+
221
+
222
+ def _select_field_path(payload: Any, field_path: Iterable[str]) -> List[Any]:
223
+ """Traverse payload using Airbyte-style field path semantics."""
224
+
225
+ current: List[Any] = [payload]
226
+ # Always start with a * to handle top-level lists
227
+ if field_path[0] != "*":
228
+ field_path = ("*",) + tuple(field_path)
229
+
230
+ for segment in field_path:
231
+ next_level: List[Any] = []
232
+ if segment == "*":
233
+ for item in current:
234
+ if isinstance(item, list):
235
+ next_level.extend(item)
236
+ elif isinstance(item, Mapping):
237
+ next_level.extend(item.values())
238
+ else:
239
+ for item in current:
240
+ if isinstance(item, Mapping) and segment in item:
241
+ next_level.append(item[segment])
242
+ current = next_level
243
+
244
+ flattened: List[Any] = []
245
+ for item in current:
246
+ if isinstance(item, list):
247
+ flattened.extend(item)
248
+ else:
249
+ flattened.append(item)
250
+ return flattened
251
+
252
+
253
+ def _filter_records(records: List[Any], expression: str) -> List[Any]:
254
+ """Filter records using a cached Jinja expression."""
255
+
256
+ expr = expression.strip()
257
+ if not expr:
258
+ return records
259
+ if expr not in _FILTER_CACHE:
260
+ stripped = expr
261
+ if expr.startswith("{{") and expr.endswith("}}"):
262
+ stripped = expr[2:-2].strip()
263
+ try:
264
+ _FILTER_CACHE[expr] = _FILTER_ENV.compile_expression(stripped)
265
+ except TemplateError as exc:
266
+ raise ValueError(f"Invalid record filter expression: {exc}") from exc
267
+
268
+ compiled = _FILTER_CACHE[expr]
269
+ filtered: List[Any] = []
270
+ for record in records:
271
+ context = {"record": record}
272
+ try:
273
+ result = compiled(**context)
274
+ except TemplateError as exc:
275
+ raise ValueError(f"Error evaluating record filter: {exc}") from exc
276
+ include = _coerce_to_bool(result)
277
+ if include:
278
+ filtered.append(record)
279
+ return filtered
280
+
281
+
282
+ def _coerce_to_bool(value: Any) -> bool:
283
+ if isinstance(value, bool):
284
+ return value
285
+ if isinstance(value, (int, float)):
286
+ return value != 0
287
+ if isinstance(value, str):
288
+ return value.strip().lower() in {"true", "1", "yes", "y", "on"}
289
+ return bool(value)
290
+
291
+
292
+ def _resolve_schema(stream: StreamConfig) -> Optional[StructType]:
293
+ if not stream.record_selector.cast_to_schema_types:
294
+ return None
295
+ schema_text = stream.schema
296
+ if not schema_text:
297
+ return None
298
+ try:
299
+ return parse_schema_struct(schema_text)
300
+ except Exception:
301
+ return None
302
+ return None
303
+
304
+
305
+ def _cast_record(record: Mapping[str, Any], schema: StructType) -> Mapping[str, Any]:
306
+ if not isinstance(record, Mapping):
307
+ return record
308
+ casted: Dict[str, Any] = dict(record)
309
+ for field in schema.fields:
310
+ if field.name in casted:
311
+ casted[field.name] = _cast_value(casted[field.name], field.dataType)
312
+ return casted
313
+
314
+
315
+ def _cast_value(value: Any, datatype: Any) -> Any:
316
+ if value is None:
317
+ return None
318
+ if isinstance(datatype, (StringType,)):
319
+ return str(value)
320
+ if isinstance(datatype, BooleanType):
321
+ if isinstance(value, bool):
322
+ return value
323
+ if isinstance(value, (int, float)):
324
+ return value != 0
325
+ if isinstance(value, str):
326
+ lowered = value.strip().lower()
327
+ if lowered in {"true", "1", "yes", "y", "on"}:
328
+ return True
329
+ if lowered in {"false", "0", "no", "n", "off"}:
330
+ return False
331
+ return bool(value)
332
+ if isinstance(datatype, (ByteType, ShortType, IntegerType, LongType)):
333
+ try:
334
+ return int(value)
335
+ except (TypeError, ValueError):
336
+ return value
337
+ if isinstance(datatype, (FloatType, DoubleType)):
338
+ try:
339
+ return float(value)
340
+ except (TypeError, ValueError):
341
+ return value
342
+ if isinstance(datatype, DecimalType):
343
+ try:
344
+ return Decimal(str(value))
345
+ except (ArithmeticError, ValueError):
346
+ return value
347
+ if isinstance(datatype, TimestampType):
348
+ if isinstance(value, str):
349
+ try:
350
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
351
+ except ValueError:
352
+ return value
353
+ return value
354
+ if isinstance(datatype, DateType):
355
+ if isinstance(value, str):
356
+ try:
357
+ return datetime.fromisoformat(value.replace("Z", "")).date()
358
+ except ValueError:
359
+ return value
360
+ return value
361
+ if isinstance(datatype, ArrayType):
362
+ if isinstance(value, list):
363
+ return [_cast_value(item, datatype.elementType) for item in value]
364
+ return value
365
+ if isinstance(datatype, MapType):
366
+ if isinstance(value, Mapping):
367
+ return {key: _cast_value(val, datatype.valueType) for key, val in value.items()}
368
+ return value
369
+ if isinstance(datatype, StructType):
370
+ if isinstance(value, Mapping):
371
+ nested = dict(value)
372
+ for field in datatype.fields:
373
+ if field.name in nested:
374
+ nested[field.name] = _cast_value(nested[field.name], field.dataType)
375
+ return nested
376
+ return value
377
+ return value
378
+
379
+
380
+ def _next_page(response: httpx.Response, pagination: PaginationConfig) -> Optional[str]:
381
+ if pagination.type != "link_header":
382
+ return None
383
+
384
+ link_header = response.headers.get("Link")
385
+ if not link_header:
386
+ return None
387
+
388
+ for link in link_header.split(","):
389
+ parts = link.split(";")
390
+ if len(parts) < 2:
391
+ continue
392
+ url_part = parts[0].strip()
393
+ rel_part = ",".join(parts[1:]).strip()
394
+ if 'rel="next"' in rel_part:
395
+ return url_part.strip("<>")
396
+ return None
397
+
398
+
399
+ class _PathFormatter:
400
+ """Shallow helper to substitute params into the path while retaining query params."""
401
+
402
+ def __init__(self, params: Mapping[str, Any]):
403
+ self._params = dict(params)
404
+ self._consumed: Dict[str, Any] = {}
405
+
406
+ def render(self, path: str) -> str:
407
+ substituted = path
408
+ for key, value in list(self._params.items()):
409
+ placeholder = "{" + key + "}"
410
+ if placeholder in substituted:
411
+ substituted = substituted.replace(placeholder, str(value))
412
+ self._consumed[key] = self._params.pop(key)
413
+ return substituted
414
+
415
+ def remaining_params(self) -> Dict[str, Any]:
416
+ return dict(self._params)
File without changes
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env python3
2
+ """Quick smoke test for the polymo REST data source."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ from argparse import ArgumentParser, Namespace
8
+ from pathlib import Path
9
+
10
+ from pyspark.sql import SparkSession
11
+
12
+ from polymo import ApiReader
13
+
14
+
15
+ def parse_args() -> argparse.Namespace:
16
+ parser = argparse.ArgumentParser(description=__doc__)
17
+ parser.add_argument(
18
+ "--config",
19
+ default="examples/jsonplaceholder.yml",
20
+ help="Path to the YAML config file (default: %(default)s)",
21
+ )
22
+ parser.add_argument(
23
+ "--stream",
24
+ default=None,
25
+ help="Optional stream name to load (default: first stream in config)",
26
+ )
27
+ parser.add_argument(
28
+ "--format",
29
+ default="polymo",
30
+ help="Registered DataSource name to use (default: %(default)s)",
31
+ )
32
+ parser.add_argument(
33
+ "--limit",
34
+ type=int,
35
+ default=5,
36
+ help="Number of rows to show from the resulting DataFrame",
37
+ )
38
+ return parser.parse_args()
39
+
40
+
41
+ def main(args: Namespace) -> None:
42
+ config_path = Path(args.config).expanduser().resolve()
43
+ if not config_path.exists():
44
+ raise SystemExit(f"Config file not found: {config_path}")
45
+
46
+ spark = SparkSession.builder.appName("polymo-smoke").getOrCreate()
47
+ try:
48
+ spark.dataSource.register(ApiReader)
49
+ reader = spark.read.format(args.format).option("config_path", str(config_path))
50
+ if args.stream:
51
+ reader = reader.option("stream", args.stream)
52
+
53
+ df = reader.load()
54
+ df.printSchema()
55
+ df.show(args.limit, truncate=False)
56
+ finally:
57
+ spark.stop()
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.3
2
+ Name: polymo
3
+ Version: 0.1.0
4
+ Summary: API ingestion for PySpark
5
+ Author: Daniel Tom
6
+ Author-email: Daniel Tom <d.e.tom89@gmail.com>
7
+ Requires-Dist: httpx>=0.26
8
+ Requires-Dist: pyyaml>=6.0.1
9
+ Requires-Dist: polymo[builder,smoke] ; extra == 'all'
10
+ Requires-Dist: fastapi>=0.110 ; extra == 'builder'
11
+ Requires-Dist: uvicorn>=0.24 ; extra == 'builder'
12
+ Requires-Dist: jinja2>=3.1.6 ; extra == 'builder'
13
+ Requires-Dist: pyspark>=4 ; extra == 'builder'
14
+ Requires-Dist: pyarrow>=13 ; extra == 'builder'
15
+ Requires-Dist: pyspark>=4 ; extra == 'smoke'
16
+ Requires-Dist: pyarrow>=13 ; extra == 'smoke'
17
+ Requires-Python: >=3.10
18
+ Provides-Extra: all
19
+ Provides-Extra: builder
20
+ Provides-Extra: smoke
21
+ Description-Content-Type: text/markdown
22
+
23
+ <p align="center">
24
+ <img src="builder-ui/public/logo.png" alt="Polymo" width="220">
25
+ </p>
26
+
27
+ Polymo makes it easy to read REST APIs into Spark DataFrames.
28
+ Just a config file and read it with Spark.
29
+
30
+ ## Quick start
31
+
32
+ 1. Define a config file:
33
+
34
+ ```yaml
35
+ version: 0.1
36
+ source:
37
+ type: rest
38
+ base_url: https://jsonplaceholder.typicode.com
39
+ stream:
40
+ path: /posts
41
+ params:
42
+ _limit: 20
43
+ infer_schema: true
44
+ ```
45
+
46
+ 2. Register the source and read data:
47
+
48
+ ```python
49
+ from pyspark.sql import SparkSession
50
+ from polymo import ApiReader
51
+
52
+ spark = SparkSession.builder.getOrCreate()
53
+ spark.dataSource.register(ApiReader)
54
+
55
+ df = (
56
+ spark.read.format("polymo")
57
+ .option("config_path", "./config.yml")
58
+ .option("token", "<YOUR_BEARER_TOKEN>")
59
+ .load()
60
+ )
61
+ ```
62
+
63
+ ## Builder UI
64
+
65
+ Want a friendly way to craft configs? Launch the local builder:
66
+
67
+ ```bash
68
+ polymo builder --port 9000
69
+ ```
70
+
71
+ # Installation
72
+ Base version without spark and builder:
73
+
74
+ `pip install polymo`
75
+
76
+ For builder UI:
77
+
78
+ `pip install polymo[builder]`
79
+
80
+ - Incremental cursors, partitioning, and advanced pagination strategies are on the roadmap.
81
+
82
+ Contributions and early feedback welcome!
@@ -0,0 +1,24 @@
1
+ polymo/__init__.py,sha256=HgFqLRLcEzuPvv_vVZTrPp-WYp9DOh68UIOKYhuH84o,875
2
+ polymo/builder/__init__.py,sha256=WjIZcirIidqybAH2aG4IWyBEtZHqCnvBqJ5d50qfeHE,116
3
+ polymo/builder/app.py,sha256=sSHxjzbcn0jNpWlwT8wrVSYIUlYkTXMwbngeo8R7ezI,11465
4
+ polymo/builder/static/examples/githubrepos.yml,sha256=IFhg1L-ouG7F4zBE5VWGmqzo7AArYikN4DSNRQn7iys,279
5
+ polymo/builder/static/examples/jsonplaceholder.yml,sha256=IJ6bBMgWvRvG2byAtmCD1VqYk8spm3v07zeKqFVSPyc,152
6
+ polymo/builder/static/favicon.ico,sha256=pyq9Y3-B8px1-DnOC9OBa_GB6TYj6aPxl7pOofmcjk8,7228
7
+ polymo/builder/static/index.html,sha256=2yoCjdFL68yqKfz9m5epL8CL9OAqOvC3dZ467GhiFvw,639
8
+ polymo/builder/static/logo.png,sha256=pqY8EiFzAi4CscP6ztICh71KSez7RpZqmGzImDxvjm4,9966
9
+ polymo/builder/static/logo192.png,sha256=KOLbGhV4fSqvpstRoFv-g2SWUZMIFmRvcP5V338rIeE,11315
10
+ polymo/builder/static/main.css,sha256=4PVT_y7Fr8nMRilma7JVIPvOHvgkuXZ7ItuMB49OyaU,43044
11
+ polymo/builder/static/main.js,sha256=ZNNdi1m8EQOtQMYOdSr8Q9dP_EoIIHo7lf4J-GFNuv8,345510
12
+ polymo/builder/static/mockServiceWorker.js,sha256=GgbLmqW7CzjE6y-50lCLP9_n-Y_UKEZ4fXml5iEeOL8,8585
13
+ polymo/builder/templates/index.html,sha256=03h-U_WrBQ8k5Wnmq7WaieBxTK0CVndx6PStRkMd9d8,825
14
+ polymo/cli.py,sha256=bmAnb5vgSs7qP0qYvqNkqmyPhtxmtM4II0ByXr8oKgw,2296
15
+ polymo/config.py,sha256=u471Ysc2dd8W37KS4tEuXEQZUR9Sb6Nea5QrzQsWXLk,15043
16
+ polymo/datasource.py,sha256=qqTRPjItyZJ_5uYPl9qFQfnybzAQmtb14wVbJ62HO9g,6131
17
+ polymo/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
+ polymo/rest_client.py,sha256=wKSjGBQAs8ETAv0l1CCmlt704oMUpL4dQPUocJFsd7E,13220
19
+ polymo/scripts/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
+ polymo/scripts/smoke.py,sha256=Bxwp3Ao6sFgcaYNXC11cmKLxN449kwI2br71l-9oENk,1649
21
+ polymo-0.1.0.dist-info/WHEEL,sha256=-neZj6nU9KAMg2CnCY6T3w8J53nx1kFGw_9HfoSzM60,79
22
+ polymo-0.1.0.dist-info/entry_points.txt,sha256=IaRgj4PslweXTP6kothjd_J5H5KWByF3dzO3LOqEeF8,44
23
+ polymo-0.1.0.dist-info/METADATA,sha256=P4PHZUutG8HSK2uos5upAszub0cSxOgY6Siw-E7gl3k,1930
24
+ polymo-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.8.22
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ polymo = polymo.cli:main
3
+