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/__init__.py +32 -0
- polymo/builder/__init__.py +5 -0
- polymo/builder/app.py +339 -0
- polymo/builder/static/examples/githubrepos.yml +15 -0
- polymo/builder/static/examples/jsonplaceholder.yml +9 -0
- polymo/builder/static/favicon.ico +0 -0
- polymo/builder/static/index.html +17 -0
- polymo/builder/static/logo.png +0 -0
- polymo/builder/static/logo192.png +0 -0
- polymo/builder/static/main.css +1 -0
- polymo/builder/static/main.js +117 -0
- polymo/builder/static/mockServiceWorker.js +335 -0
- polymo/builder/templates/index.html +21 -0
- polymo/cli.py +73 -0
- polymo/config.py +480 -0
- polymo/datasource.py +184 -0
- polymo/py.typed +0 -0
- polymo/rest_client.py +416 -0
- polymo/scripts/__init__.py +0 -0
- polymo/scripts/smoke.py +57 -0
- polymo-0.1.0.dist-info/METADATA +82 -0
- polymo-0.1.0.dist-info/RECORD +24 -0
- polymo-0.1.0.dist-info/WHEEL +4 -0
- polymo-0.1.0.dist-info/entry_points.txt +3 -0
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
|
polymo/scripts/smoke.py
ADDED
|
@@ -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,,
|