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 +82 -0
- polymo-0.1.0/README.md +60 -0
- polymo-0.1.0/pyproject.toml +41 -0
- polymo-0.1.0/src/polymo/__init__.py +32 -0
- polymo-0.1.0/src/polymo/builder/__init__.py +5 -0
- polymo-0.1.0/src/polymo/builder/app.py +339 -0
- polymo-0.1.0/src/polymo/builder/static/examples/githubrepos.yml +15 -0
- polymo-0.1.0/src/polymo/builder/static/examples/jsonplaceholder.yml +9 -0
- polymo-0.1.0/src/polymo/builder/static/favicon.ico +0 -0
- polymo-0.1.0/src/polymo/builder/static/index.html +17 -0
- polymo-0.1.0/src/polymo/builder/static/logo.png +0 -0
- polymo-0.1.0/src/polymo/builder/static/logo192.png +0 -0
- polymo-0.1.0/src/polymo/builder/static/main.css +1 -0
- polymo-0.1.0/src/polymo/builder/static/main.js +117 -0
- polymo-0.1.0/src/polymo/builder/static/mockServiceWorker.js +335 -0
- polymo-0.1.0/src/polymo/builder/templates/index.html +21 -0
- polymo-0.1.0/src/polymo/cli.py +73 -0
- polymo-0.1.0/src/polymo/config.py +480 -0
- polymo-0.1.0/src/polymo/datasource.py +184 -0
- polymo-0.1.0/src/polymo/py.typed +0 -0
- polymo-0.1.0/src/polymo/rest_client.py +416 -0
- polymo-0.1.0/src/polymo/scripts/__init__.py +0 -0
- polymo-0.1.0/src/polymo/scripts/smoke.py +57 -0
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,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
|
|
Binary file
|
|
@@ -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>
|
|
Binary file
|
|
Binary file
|