polymo 0.1.0__tar.gz

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-0.1.0/PKG-INFO ADDED
@@ -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!
polymo-0.1.0/README.md ADDED
@@ -0,0 +1,60 @@
1
+ <p align="center">
2
+ <img src="builder-ui/public/logo.png" alt="Polymo" width="220">
3
+ </p>
4
+
5
+ Polymo makes it easy to read REST APIs into Spark DataFrames.
6
+ Just a config file and read it with Spark.
7
+
8
+ ## Quick start
9
+
10
+ 1. Define a config file:
11
+
12
+ ```yaml
13
+ version: 0.1
14
+ source:
15
+ type: rest
16
+ base_url: https://jsonplaceholder.typicode.com
17
+ stream:
18
+ path: /posts
19
+ params:
20
+ _limit: 20
21
+ infer_schema: true
22
+ ```
23
+
24
+ 2. Register the source and read data:
25
+
26
+ ```python
27
+ from pyspark.sql import SparkSession
28
+ from polymo import ApiReader
29
+
30
+ spark = SparkSession.builder.getOrCreate()
31
+ spark.dataSource.register(ApiReader)
32
+
33
+ df = (
34
+ spark.read.format("polymo")
35
+ .option("config_path", "./config.yml")
36
+ .option("token", "<YOUR_BEARER_TOKEN>")
37
+ .load()
38
+ )
39
+ ```
40
+
41
+ ## Builder UI
42
+
43
+ Want a friendly way to craft configs? Launch the local builder:
44
+
45
+ ```bash
46
+ polymo builder --port 9000
47
+ ```
48
+
49
+ # Installation
50
+ Base version without spark and builder:
51
+
52
+ `pip install polymo`
53
+
54
+ For builder UI:
55
+
56
+ `pip install polymo[builder]`
57
+
58
+ - Incremental cursors, partitioning, and advanced pagination strategies are on the roadmap.
59
+
60
+ Contributions and early feedback welcome!
@@ -0,0 +1,41 @@
1
+ [project]
2
+ name = "polymo"
3
+ version = "0.1.0"
4
+ description = "API ingestion for PySpark"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Daniel Tom", email = "d.e.tom89@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "httpx>=0.26",
12
+ "pyyaml>=6.0.1",
13
+ ]
14
+
15
+ [project.optional-dependencies]
16
+ builder = [
17
+ "fastapi>=0.110",
18
+ "uvicorn>=0.24",
19
+ "jinja2>=3.1.6",
20
+ "pyspark>=4",
21
+ "pyarrow>=13",
22
+ ]
23
+ smoke = [
24
+ "pyspark>=4",
25
+ "pyarrow>=13",
26
+ ]
27
+ all = [
28
+ "polymo[builder,smoke]",
29
+ ]
30
+
31
+ [dependency-groups]
32
+ dev = [
33
+ "pytest>=8.4.2",
34
+ ]
35
+
36
+ [project.scripts]
37
+ polymo = "polymo.cli:main"
38
+
39
+ [build-system]
40
+ requires = ["uv_build>=0.8.17,<0.9.0"]
41
+ build-backend = "uv_build"
@@ -0,0 +1,32 @@
1
+ """Public entrypoints for the polymo REST data source."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pyspark.sql import SparkSession
6
+
7
+ from .config import RestSourceConfig, config_to_dict, dump_config, load_config, parse_config
8
+ from .datasource import ApiReader
9
+
10
+ __all__ = [
11
+ "register",
12
+ "ApiReader",
13
+ "RestSourceConfig",
14
+ "config_to_dict",
15
+ "dump_config",
16
+ "load_config",
17
+ "parse_config",
18
+ ]
19
+
20
+
21
+ def _alias_datasource(name: str) -> type[ApiReader]:
22
+ alias_name = name
23
+
24
+ class AliasRestDataSource(ApiReader):
25
+ @classmethod
26
+ def name(cls) -> str: # type: ignore[override]
27
+ return alias_name
28
+
29
+ AliasRestDataSource.__name__ = f"RestDataSource_{name.replace('.', '_')}"
30
+ AliasRestDataSource.__qualname__ = AliasRestDataSource.__name__
31
+ AliasRestDataSource.__module__ = ApiReader.__module__
32
+ return AliasRestDataSource
@@ -0,0 +1,5 @@
1
+ """Web interface for building polymo REST configurations."""
2
+
3
+ from .app import create_app
4
+
5
+ __all__ = ["create_app"]
@@ -0,0 +1,339 @@
1
+ """FastAPI application powering the polymo web builder."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import partial
6
+ from importlib import resources
7
+ from typing import Any, Dict, List, Optional, Tuple
8
+
9
+ import yaml
10
+ from fastapi import FastAPI, HTTPException, Request
11
+ from fastapi.responses import FileResponse
12
+ from fastapi.staticfiles import StaticFiles
13
+ from fastapi.templating import Jinja2Templates
14
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
15
+ from starlette.concurrency import run_in_threadpool
16
+
17
+ from ..config import (
18
+ ConfigError,
19
+ RestSourceConfig,
20
+ config_to_dict,
21
+ dump_config,
22
+ parse_config,
23
+ )
24
+ from ..rest_client import RestClient
25
+
26
+ PACKAGE_ROOT = resources.files(__package__)
27
+ TEMPLATES = Jinja2Templates(directory=str(PACKAGE_ROOT.joinpath("templates")))
28
+ STATIC_PATH = PACKAGE_ROOT.joinpath("static")
29
+
30
+ SAMPLE_CONFIG = """\
31
+ version: 0.1
32
+ source:
33
+ type: rest
34
+ base_url: https://jsonplaceholder.typicode.com
35
+ stream:
36
+ name: posts
37
+ path: /posts
38
+ params:
39
+ _limit: 25
40
+ pagination:
41
+ type: none
42
+ incremental:
43
+ mode: null
44
+ cursor_param: null
45
+ cursor_field: null
46
+ infer_schema: true
47
+ schema: null
48
+ """
49
+
50
+ SAMPLE_CONFIG_OBJECT = parse_config(yaml.safe_load(SAMPLE_CONFIG))
51
+ SAMPLE_CONFIG_DICT = config_to_dict(SAMPLE_CONFIG_OBJECT)
52
+ SAMPLE_CONFIG_YAML = dump_config(SAMPLE_CONFIG_OBJECT)
53
+
54
+
55
+ class ValidationRequest(BaseModel):
56
+ config: Optional[str] = Field(None, description="YAML configuration text")
57
+ config_dict: Optional[Dict[str, Any]] = Field(
58
+ None, description="Configuration provided as a dictionary"
59
+ )
60
+ token: Optional[str] = Field(None, description="Bearer token supplied separately (not stored)")
61
+ options: Optional[Dict[str, Any]] = Field(
62
+ default=None, description="Spark reader options provided alongside the config"
63
+ )
64
+
65
+ model_config = ConfigDict(extra="ignore")
66
+
67
+ @model_validator(mode="after")
68
+ def _ensure_payload(self) -> "ValidationRequest":
69
+ if self.config is None and self.config_dict is None:
70
+ raise ValueError("Either 'config' or 'config_dict' must be provided")
71
+ return self
72
+
73
+
74
+ class ValidationResponse(BaseModel):
75
+ valid: bool
76
+ stream: str | None = None
77
+ message: Optional[str] = None
78
+ config: Optional[Dict[str, Any]] = None
79
+ yaml: Optional[str] = None
80
+
81
+
82
+ class SampleRequest(BaseModel):
83
+ config: Optional[str] = None
84
+ config_dict: Optional[Dict[str, Any]] = None
85
+ token: Optional[str] = None
86
+ limit: int = Field(20, ge=1, le=500, description="Maximum records to preview")
87
+ options: Optional[Dict[str, Any]] = Field(
88
+ default=None, description="Spark reader options provided alongside the config"
89
+ )
90
+
91
+ model_config = ConfigDict(extra="ignore")
92
+
93
+ @model_validator(mode="after")
94
+ def _ensure_payload(self) -> "SampleRequest":
95
+ if self.config is None and self.config_dict is None:
96
+ raise ValueError("Either 'config' or 'config_dict' must be provided")
97
+ return self
98
+
99
+
100
+ class SampleResponse(BaseModel):
101
+ stream: str
102
+ records: List[Dict[str, Any]]
103
+ dtypes: List[Dict[str, str]] = Field(default_factory=list, description="Spark column data types")
104
+ raw_pages: List[Dict[str, Any]] = Field(
105
+ default_factory=list, description="Raw REST API responses captured per page"
106
+ )
107
+ rest_error: Optional[str] = None
108
+
109
+
110
+ class FormatRequest(BaseModel):
111
+ config_dict: Dict[str, Any]
112
+
113
+
114
+ class FormatResponse(BaseModel):
115
+ yaml: str
116
+
117
+
118
+ def create_app() -> FastAPI:
119
+ app = FastAPI(title="polymo builder", version="0.1.0")
120
+
121
+ app.mount("/static", StaticFiles(directory=str(STATIC_PATH)), name="static")
122
+
123
+ @app.get("/favicon.ico", include_in_schema=False)
124
+ async def favicon() -> FileResponse: # pragma: no cover - static convenience endpoint
125
+ return FileResponse(STATIC_PATH / "favicon.ico")
126
+
127
+ @app.get("/apple-touch-icon.png", include_in_schema=False)
128
+ @app.get("/apple-touch-icon-precomposed.png", include_in_schema=False)
129
+ async def apple_touch_icon() -> FileResponse: # pragma: no cover - static convenience endpoint
130
+ # Re-use existing high-res logo as apple touch icon
131
+ return FileResponse(STATIC_PATH / "logo192.png")
132
+
133
+ @app.get("/")
134
+ async def index(request: Request) -> Any:
135
+ return TEMPLATES.TemplateResponse(
136
+ "index.html",
137
+ {
138
+ "request": request,
139
+ "sample_config": SAMPLE_CONFIG_YAML,
140
+ "sample_config_dict": SAMPLE_CONFIG_DICT,
141
+ },
142
+ )
143
+
144
+ @app.post("/api/validate", response_model=ValidationResponse)
145
+ async def validate_config(payload: ValidationRequest) -> ValidationResponse:
146
+ try:
147
+ config = _load_config_payload(
148
+ payload.config, payload.config_dict, payload.token, payload.options
149
+ )
150
+ except ConfigError as exc:
151
+ return ValidationResponse(valid=False, stream=None, message=str(exc))
152
+ except ValueError as exc:
153
+ return ValidationResponse(valid=False, stream=None, message=str(exc))
154
+
155
+ config_dict = config_to_dict(config)
156
+ return ValidationResponse(
157
+ valid=True,
158
+ stream=config.stream.name,
159
+ message="Configuration is valid",
160
+ config=config_dict,
161
+ yaml=dump_config(config),
162
+ )
163
+
164
+ @app.post("/api/sample", response_model=SampleResponse)
165
+ async def sample_records(payload: SampleRequest) -> SampleResponse:
166
+ try:
167
+ config = _load_config_payload(
168
+ payload.config, payload.config_dict, payload.token, payload.options
169
+ )
170
+ except ConfigError as exc:
171
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
172
+ except ValueError as exc:
173
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
174
+
175
+ stream_config = config.stream
176
+
177
+ raw_pages, rest_error = await run_in_threadpool(
178
+ partial(_collect_rest_preview, config, payload.limit)
179
+ )
180
+
181
+ if rest_error:
182
+ return SampleResponse(
183
+ stream=stream_config.name,
184
+ records=[],
185
+ dtypes=[],
186
+ raw_pages=raw_pages,
187
+ rest_error=rest_error,
188
+ )
189
+
190
+ try:
191
+ records, dtypes = await run_in_threadpool(
192
+ partial(_collect_records, config, payload.token, payload.limit)
193
+ )
194
+ except Exception as exc: # pragma: no cover - surfaced to UI
195
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
196
+
197
+ return SampleResponse(
198
+ stream=stream_config.name,
199
+ records=records,
200
+ dtypes=dtypes,
201
+ raw_pages=raw_pages,
202
+ rest_error=None,
203
+ )
204
+
205
+ @app.post("/api/format", response_model=FormatResponse)
206
+ async def format_config(payload: FormatRequest) -> FormatResponse:
207
+ try:
208
+ config = parse_config(payload.config_dict)
209
+ except ConfigError as exc:
210
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
211
+ return FormatResponse(yaml=dump_config(config))
212
+
213
+ return app
214
+
215
+
216
+ def _load_config_payload(
217
+ config_text: Optional[str],
218
+ config_dict: Optional[Dict[str, Any]],
219
+ token: Optional[str] = None,
220
+ options: Optional[Dict[str, Any]] = None,
221
+ ) -> RestSourceConfig:
222
+ if config_dict is not None:
223
+ return parse_config(config_dict, token=token, options=options)
224
+ if config_text is None:
225
+ raise ConfigError("Configuration payload is missing")
226
+ return _parse_yaml(config_text, token=token, options=options)
227
+
228
+
229
+ def _parse_yaml(
230
+ text: str,
231
+ token: Optional[str] = None,
232
+ options: Optional[Dict[str, Any]] = None,
233
+ ) -> RestSourceConfig:
234
+ try:
235
+ parsed = yaml.safe_load(text)
236
+ except yaml.YAMLError as exc:
237
+ raise ConfigError(f"Invalid YAML: {exc}") from exc
238
+ return parse_config(parsed, token=token, options=options)
239
+
240
+
241
+ def _collect_rest_preview(config: RestSourceConfig, limit: int) -> Tuple[List[Dict[str, Any]], Optional[str]]:
242
+ pages: List[Dict[str, Any]] = []
243
+ total_records = 0
244
+
245
+ try:
246
+ with RestClient(base_url=config.base_url, auth=config.auth, options=config.options) as client:
247
+ for index, page in enumerate(client.fetch_pages(config.stream), start=1):
248
+ remaining = max(0, limit - total_records)
249
+ if remaining <= 0:
250
+ break
251
+
252
+ page_records = list(page.records)
253
+ if remaining < len(page_records):
254
+ page_records = page_records[:remaining]
255
+
256
+ total_records += len(page_records)
257
+
258
+ pages.append(
259
+ {
260
+ "page": index,
261
+ "url": page.url,
262
+ "status_code": page.status_code,
263
+ "headers": dict(page.headers),
264
+ "records": page_records,
265
+ "payload": page.payload,
266
+ }
267
+ )
268
+
269
+ if total_records >= limit:
270
+ break
271
+ return pages, None
272
+ except Exception as exc:
273
+ return pages, str(exc)
274
+
275
+
276
+ def _collect_records(config: RestSourceConfig, token: str | None, limit: int) -> Tuple[List[Dict[str, Any]], List[Dict[str, str]]]:
277
+ """Collect processed records and dtypes using PySpark DataSource."""
278
+ import tempfile
279
+ import os
280
+ from ..config import dump_config
281
+
282
+ # Create a temporary config file for Spark to use
283
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.yml', delete=False) as f:
284
+ config_yaml = dump_config(config)
285
+ f.write(config_yaml)
286
+ config_path = f.name
287
+
288
+ spark = _get_or_create_spark()
289
+ try:
290
+ df = _get_preview_df(config_path, token, spark, config.options)
291
+ records = df.limit(limit).collect()
292
+ dtypes = df.dtypes
293
+ record_dicts = [row.asDict(recursive=True) for row in records]
294
+ dtype_dicts: List[Dict[str, str]] = []
295
+ sample_row = record_dicts[0] if record_dicts else {}
296
+ for column, dtype in dtypes:
297
+ if sample_row and column in sample_row:
298
+ dtype_dicts.append({"column": column, "type": str(dtype)})
299
+ return record_dicts, dtype_dicts
300
+ finally:
301
+ spark.stop()
302
+ os.unlink(config_path)
303
+
304
+ def _get_preview_df(
305
+ config_path: str,
306
+ token: str | None,
307
+ spark: "SparkSession",
308
+ reader_options: Dict[str, Any],
309
+ ):
310
+ """Get a Spark DataFrame for previewing data from the specified stream."""
311
+
312
+ from polymo import ApiReader
313
+
314
+ _get_or_create_spark()
315
+ spark.dataSource.register(ApiReader)
316
+
317
+ # Alternative approach: Use the DataSource directly without format registration
318
+ # Create the DataSource instance manually
319
+ options = {
320
+ "config_path": config_path,
321
+ }
322
+ if token is not None:
323
+ options["token"] = token
324
+ for key, value in reader_options.items():
325
+ if key in {"config_path", "token"}:
326
+ continue
327
+ options[key] = value
328
+ return spark.read.format("polymo").options(**options).load()
329
+
330
+ def _get_or_create_spark() -> Any:
331
+ """Get or create a Spark session."""
332
+ from pyspark.sql import SparkSession
333
+
334
+ spark = SparkSession.builder \
335
+ .appName("polymo-builder") \
336
+ .config("spark.sql.adaptive.enabled", "false") \
337
+ .config("spark.sql.adaptive.coalescePartitions.enabled", "false") \
338
+ .getOrCreate()
339
+ return spark
@@ -0,0 +1,15 @@
1
+ version: 0.1
2
+ source:
3
+ type: rest
4
+ base_url: https://api.github.com/orgs/godatadriven
5
+ stream:
6
+ path: /repos
7
+ infer_schema: true
8
+ schema: null
9
+ pagination:
10
+ type: none
11
+ record_selector:
12
+ field_path:
13
+ - owner
14
+ record_filter: null
15
+ cast_to_schema_types: false
@@ -0,0 +1,9 @@
1
+ version: 0.1
2
+ source:
3
+ type: rest
4
+ base_url: https://jsonplaceholder.typicode.com
5
+ stream:
6
+ path: /posts
7
+ params:
8
+ _limit: 20
9
+ infer_schema: true
@@ -0,0 +1,17 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" href="/favicon.ico" sizes="any" />
6
+ <link rel="icon" type="image/png" sizes="32x32" href="/favicon.ico" />
7
+ <link rel="icon" type="image/png" sizes="192x192" href="/favicon.ico" />
8
+ <link rel="apple-touch-icon" href="/favicon.ico" />
9
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
10
+ <title>polymo Connector Builder</title>
11
+ <script type="module" crossorigin src="/main.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/main.css">
13
+ </head>
14
+ <body>
15
+ <div id="root"></div>
16
+ </body>
17
+ </html>