parquetscope 0.3.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.
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0"
@@ -0,0 +1,176 @@
1
+ from .intervals import coverage, union
2
+ from .models import Capture, HTTPObservation, Metrics, ServerObservation, span
3
+ from .models_v2 import (
4
+ FileHTTPObservation,
5
+ FileInput,
6
+ FileMetrics,
7
+ FileServerObservation,
8
+ SetMetrics,
9
+ )
10
+
11
+
12
+ def derive_metrics(
13
+ observations: list[HTTPObservation], server: list[ServerObservation], capture: Capture
14
+ ) -> Metrics:
15
+ gets = [o for o in observations if o.method == "GET"]
16
+ requested = [o.requested_span.pair() for o in gets if o.requested_span]
17
+ confirmed = [o.response_span.pair() for o in gets if o.response_span]
18
+ known_requests = all(o.requested_span is not None for o in gets)
19
+ known_responses = all(o.response_span is not None for o in gets)
20
+ known_lengths = all(o.declared_body_bytes is not None for o in gets)
21
+ reasons = []
22
+ if not capture.complete:
23
+ reasons.append(
24
+ capture.reason or "Incomplete capture; metrics describe observed subset only"
25
+ )
26
+ if not known_requests:
27
+ reasons.append("Some GETs have no interpretable single requested range")
28
+ if not known_responses:
29
+ reasons.append("Some GET responses have no confirmed file interval")
30
+ if not known_lengths:
31
+ reasons.append("Some GET responses have no declared Content-Length")
32
+ total = sum(b - a for a, b in confirmed)
33
+ unique = coverage(confirmed)
34
+ return Metrics(
35
+ get_count=len(gets),
36
+ head_count=sum(o.method == "HEAD" for o in observations),
37
+ requested_range_bytes=str(sum(b - a for a, b in requested)) if known_requests else None,
38
+ confirmed_range_bytes=str(total) if known_responses else None,
39
+ declared_body_bytes=(
40
+ str(sum(int(o.declared_body_bytes) for o in gets if o.declared_body_bytes is not None))
41
+ if known_lengths
42
+ else None
43
+ ),
44
+ server_written_bytes=str(sum(int(o.written_body_bytes) for o in server)),
45
+ unique_coverage_bytes=str(unique) if known_responses else None,
46
+ repeated_coverage_bytes=str(total - unique) if known_responses else None,
47
+ coverage=[span(a, b) for a, b in union(confirmed)] if known_responses else None,
48
+ totals_scope="complete" if capture.complete else "observed_subset",
49
+ unknown_reasons=reasons,
50
+ )
51
+
52
+
53
+ def reconcile(observations: list[HTTPObservation], server: list[ServerObservation]) -> bool:
54
+ """Response IDs distinguish repeated and concurrent identical requests."""
55
+ return _reconcile(observations, server, lambda _o, _s: True)
56
+
57
+
58
+ def reconcile_set(
59
+ observations: list[FileHTTPObservation], server: list[FileServerObservation]
60
+ ) -> bool:
61
+ """As `reconcile`, and both independent sources must attribute a request to the same file."""
62
+ return _reconcile(observations, server, lambda o, s: o.file_id == s.file_id)
63
+
64
+
65
+ def _reconcile(observations, server, same_file) -> bool:
66
+ by_id = {o.id: o for o in server}
67
+ if (
68
+ len(by_id) != len(server)
69
+ or len(server) != len(observations)
70
+ or len({o.id for o in observations}) != len(observations)
71
+ ):
72
+ return False
73
+ for o in observations:
74
+ s = by_id.get(o.id)
75
+ if not s or (o.method, o.requested_range, o.response_span, o.status) != (
76
+ s.method,
77
+ s.requested_range,
78
+ s.response_span,
79
+ s.status,
80
+ ):
81
+ return False
82
+ if o.declared_body_bytes != s.declared_body_bytes or not same_file(o, s):
83
+ return False
84
+ return True
85
+
86
+
87
+ def _file_metrics(
88
+ file_id: str, observations: list[FileHTTPObservation], server: list[FileServerObservation]
89
+ ) -> FileMetrics:
90
+ """Intervals belong to one file; callers never pass observations of several files."""
91
+ gets = [o for o in observations if o.method == "GET"]
92
+ heads = len(observations) - len(gets)
93
+ requested = [o.requested_span.pair() for o in gets if o.requested_span]
94
+ confirmed = [o.response_span.pair() for o in gets if o.response_span]
95
+ known_requests = all(o.requested_span is not None for o in gets)
96
+ known_responses = all(o.response_span is not None for o in gets)
97
+ known_lengths = all(o.declared_body_bytes is not None for o in gets)
98
+ reasons = []
99
+ if not known_requests:
100
+ reasons.append("Some GETs have no interpretable single requested range")
101
+ if not known_responses:
102
+ reasons.append("Some GET responses have no confirmed file interval")
103
+ if not known_lengths:
104
+ reasons.append("Some GET responses have no declared Content-Length")
105
+ total = sum(b - a for a, b in confirmed)
106
+ unique = coverage(confirmed)
107
+ # Either independent source is enough to say a request for the file was observed.
108
+ server_gets = any(s.method == "GET" for s in server)
109
+ server_heads = any(s.method == "HEAD" for s in server)
110
+ if gets or server_gets:
111
+ access = "get"
112
+ elif heads or server_heads:
113
+ access = "head_only"
114
+ else:
115
+ access = "none"
116
+ return FileMetrics(
117
+ file_id=file_id,
118
+ observed_access=access,
119
+ get_count=len(gets),
120
+ head_count=heads,
121
+ requested_range_bytes=str(sum(b - a for a, b in requested)) if known_requests else None,
122
+ confirmed_range_bytes=str(total) if known_responses else None,
123
+ declared_body_bytes=(
124
+ str(sum(int(o.declared_body_bytes) for o in gets if o.declared_body_bytes is not None))
125
+ if known_lengths
126
+ else None
127
+ ),
128
+ server_written_bytes=str(sum(int(o.written_body_bytes) for o in server)),
129
+ unique_coverage_bytes=str(unique) if known_responses else None,
130
+ repeated_coverage_bytes=str(total - unique) if known_responses else None,
131
+ coverage=[span(a, b) for a, b in union(confirmed)] if known_responses else None,
132
+ unknown_reasons=reasons,
133
+ )
134
+
135
+
136
+ def _sum_known(values: list[str | None]) -> str | None:
137
+ return None if any(v is None for v in values) else str(sum(int(v) for v in values))
138
+
139
+
140
+ def derive_set_metrics(
141
+ files: list[FileInput],
142
+ observations: list[FileHTTPObservation],
143
+ server: list[FileServerObservation],
144
+ capture: Capture,
145
+ ) -> SetMetrics:
146
+ """Per-file metrics grouped by file_id in one pass; set totals are sums of file totals."""
147
+ by_file: dict[str, list[FileHTTPObservation]] = {f.id: [] for f in files}
148
+ server_by_file: dict[str, list[FileServerObservation]] = {f.id: [] for f in files}
149
+ for o in observations:
150
+ by_file[o.file_id].append(o)
151
+ for s in server:
152
+ server_by_file[s.file_id].append(s)
153
+ per_file = [_file_metrics(f.id, by_file[f.id], server_by_file[f.id]) for f in files]
154
+ reasons = []
155
+ if not capture.complete:
156
+ reasons.append(
157
+ capture.reason or "Incomplete capture; metrics describe observed subset only"
158
+ )
159
+ for m in per_file:
160
+ reasons.extend(r for r in m.unknown_reasons if r not in reasons)
161
+ return SetMetrics(
162
+ files_without_observed_access=sum(m.observed_access == "none" for m in per_file),
163
+ files_head_only=sum(m.observed_access == "head_only" for m in per_file),
164
+ files_with_get=sum(m.observed_access == "get" for m in per_file),
165
+ get_count=sum(m.get_count for m in per_file),
166
+ head_count=sum(m.head_count for m in per_file),
167
+ requested_range_bytes=_sum_known([m.requested_range_bytes for m in per_file]),
168
+ confirmed_range_bytes=_sum_known([m.confirmed_range_bytes for m in per_file]),
169
+ declared_body_bytes=_sum_known([m.declared_body_bytes for m in per_file]),
170
+ server_written_bytes=str(sum(int(m.server_written_bytes) for m in per_file)),
171
+ unique_coverage_bytes=_sum_known([m.unique_coverage_bytes for m in per_file]),
172
+ repeated_coverage_bytes=_sum_known([m.repeated_coverage_bytes for m in per_file]),
173
+ totals_scope="complete" if capture.complete else "observed_subset",
174
+ unknown_reasons=reasons,
175
+ files=per_file,
176
+ )
parquetscope/app.py ADDED
@@ -0,0 +1,204 @@
1
+ import asyncio
2
+ import threading
3
+ import uuid
4
+ from contextlib import asynccontextmanager
5
+ from pathlib import Path
6
+ from typing import Literal
7
+ from urllib.parse import urlparse
8
+
9
+ from fastapi import FastAPI, HTTPException, Request
10
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
11
+ from fastapi.staticfiles import StaticFiles
12
+ from pydantic import Field
13
+
14
+ from .capture import capture, capture_set
15
+ from .demo import SQL, generate
16
+ from .engines import SUPPORTED_ENGINES, engine_status
17
+ from .manifest import SetSpec
18
+ from .models import MAX_ARTIFACT_BYTES, Model, Run, Settings, load_artifact
19
+ from .models_v2 import SetRun
20
+ from .models_v3 import Settings3
21
+ from .offline import STATIC, viewer_html
22
+
23
+
24
+ class RunRequest(Model):
25
+ input_id: str
26
+ sql: str = Field(min_length=1, max_length=65536)
27
+ settings: Settings3 = Field(
28
+ default_factory=lambda: Settings3(
29
+ threads=4,
30
+ memory_limit_mb=512,
31
+ deadline_seconds=30,
32
+ preview_rows=30,
33
+ )
34
+ )
35
+ # None: the project's own interpreter. A version needs `parquetscope setup --engine` first.
36
+ engine: Literal[SUPPORTED_ENGINES] | None = None
37
+
38
+
39
+ def describe_inputs(inputs: dict[str, "Path | SetSpec"]) -> list[dict]:
40
+ return [
41
+ {"id": i, "name": p.label, "kind": "set", "file_count": len(p.entries)}
42
+ if isinstance(p, SetSpec)
43
+ else {"id": i, "name": p.name, "kind": "file", "file_count": 1}
44
+ for i, p in inputs.items()
45
+ ]
46
+
47
+
48
+ def create_app(
49
+ paths: list[Path] | None = None,
50
+ artifacts: list[Run | SetRun] | None = None,
51
+ demo_directory: Path | None = None,
52
+ manifests: list[SetSpec] | None = None,
53
+ ) -> FastAPI:
54
+ inputs: dict[str, Path | SetSpec] = {str(i): p.resolve() for i, p in enumerate(paths or [])}
55
+ inputs.update({f"set-{i}": spec for i, spec in enumerate(manifests or [])})
56
+ jobs: dict[str, dict] = {}
57
+ stored = {r.id: r for r in artifacts or []}
58
+ tasks: set[asyncio.Task] = set()
59
+
60
+ @asynccontextmanager
61
+ async def lifespan(_app):
62
+ yield
63
+ for job in jobs.values():
64
+ job["cancel"].set()
65
+ if tasks:
66
+ await asyncio.gather(*tasks, return_exceptions=True)
67
+
68
+ app = FastAPI(title="ParquetScope", lifespan=lifespan, docs_url=None, redoc_url=None)
69
+
70
+ @app.middleware("http")
71
+ async def loopback_boundary(request: Request, call_next):
72
+ host = request.url.hostname
73
+ if host not in ("127.0.0.1", "localhost", "::1", "testserver"):
74
+ return JSONResponse({"detail": "Loopback hosts only"}, status_code=403)
75
+ origin = request.headers.get("origin")
76
+ if origin and urlparse(origin).netloc != request.headers.get("host"):
77
+ return JSONResponse(
78
+ {"detail": "Cross-origin requests are not allowed"}, status_code=403
79
+ )
80
+ response = await call_next(request)
81
+ response.headers["X-Content-Type-Options"] = "nosniff"
82
+ response.headers["Referrer-Policy"] = "no-referrer"
83
+ return response
84
+
85
+ @app.get("/api/session")
86
+ def session():
87
+ return {
88
+ "inputs": describe_inputs(inputs),
89
+ "runs": [r.model_dump() for r in stored.values()],
90
+ "demo_available": demo_directory is not None,
91
+ "engines": engine_status(),
92
+ }
93
+
94
+ @app.post("/api/demo")
95
+ async def demo():
96
+ if demo_directory is None:
97
+ raise HTTPException(403, "Start with parquetscope serve --demo to enable the demo")
98
+ if any(j["status"] == "running" for j in jobs.values()):
99
+ raise HTTPException(409, "Wait for the current run to finish")
100
+ original, sorted_path, _ = await asyncio.to_thread(generate, demo_directory)
101
+ inputs["demo-original"], inputs["demo-sorted"] = original, sorted_path
102
+ return {"inputs": describe_inputs(inputs), "sql": SQL}
103
+
104
+ async def work(job_id: str, payload: RunRequest):
105
+ job = jobs[job_id]
106
+ try:
107
+ source = inputs[payload.input_id]
108
+ if isinstance(source, SetSpec):
109
+ run, preview = await asyncio.to_thread(
110
+ capture_set,
111
+ source,
112
+ payload.sql,
113
+ payload.settings,
114
+ job["cancel"],
115
+ payload.engine,
116
+ )
117
+ else:
118
+ run, preview = await asyncio.to_thread(
119
+ capture,
120
+ source,
121
+ payload.sql,
122
+ Settings(
123
+ **payload.settings.model_dump(exclude={"disable_parquet_prefetching"})
124
+ ),
125
+ job["cancel"],
126
+ )
127
+ stored[run.id] = run
128
+ job.update(status="finished", run=run.model_dump(), preview=preview)
129
+ except Exception as error:
130
+ job.update(status="error", error=str(error))
131
+
132
+ @app.post("/api/runs", status_code=202)
133
+ async def start(payload: RunRequest):
134
+ if payload.input_id not in inputs:
135
+ raise HTTPException(404, "Input is not explicitly selected for this session")
136
+ if not isinstance(inputs[payload.input_id], SetSpec) and (
137
+ payload.engine or payload.settings.disable_parquet_prefetching is not None
138
+ ):
139
+ raise HTTPException(422, "Engine and prefetch selection need a file-set input")
140
+ if any(j["status"] == "running" for j in jobs.values()):
141
+ raise HTTPException(409, "One capture at a time")
142
+ if len(stored) >= 20:
143
+ raise HTTPException(409, "Session limit: 20 runs. Export artifacts and restart.")
144
+ job_id = uuid.uuid4().hex
145
+ jobs[job_id] = {"status": "running", "cancel": threading.Event()}
146
+ task = asyncio.create_task(work(job_id, payload))
147
+ tasks.add(task)
148
+ task.add_done_callback(tasks.discard)
149
+ return {"id": job_id}
150
+
151
+ @app.get("/api/jobs/{job_id}")
152
+ def poll(job_id: str):
153
+ if job_id not in jobs:
154
+ raise HTTPException(404, "Unknown run")
155
+ return {key: value for key, value in jobs[job_id].items() if key != "cancel"}
156
+
157
+ @app.post("/api/jobs/{job_id}/cancel")
158
+ def cancel(job_id: str):
159
+ if job_id not in jobs:
160
+ raise HTTPException(404, "Unknown run")
161
+ jobs[job_id]["cancel"].set()
162
+ return {"status": "cancelling"}
163
+
164
+ @app.post("/api/import")
165
+ async def import_run(request: Request):
166
+ chunks = bytearray()
167
+ async for chunk in request.stream():
168
+ chunks.extend(chunk)
169
+ if len(chunks) > MAX_ARTIFACT_BYTES:
170
+ raise HTTPException(413, "Artifact exceeds 32 MiB limit")
171
+ try:
172
+ run = load_artifact(bytes(chunks))
173
+ except ValueError as error:
174
+ raise HTTPException(422, str(error)) from error
175
+ if len(stored) >= 20:
176
+ raise HTTPException(409, "Session limit: 20 runs")
177
+ stored[run.id] = run
178
+ return run.model_dump()
179
+
180
+ @app.get("/api/artifact/{run_id}")
181
+ def artifact(run_id: str):
182
+ if run_id not in stored:
183
+ raise HTTPException(404, "Unknown run")
184
+ return JSONResponse(
185
+ stored[run_id].model_dump(),
186
+ headers={
187
+ "Content-Disposition": f'attachment; filename="{run_id}.parquetscope.json"',
188
+ },
189
+ )
190
+
191
+ @app.get("/offline.html")
192
+ def offline():
193
+ return HTMLResponse(viewer_html())
194
+
195
+ @app.get("/")
196
+ def index():
197
+ if not (STATIC / "index.html").exists():
198
+ raise HTTPException(503, "Frontend missing. Run pnpm --dir frontend build")
199
+ return FileResponse(STATIC / "index.html")
200
+
201
+ if (STATIC / "assets").exists():
202
+ app.mount("/assets", StaticFiles(directory=STATIC / "assets"))
203
+ app.state.jobs = jobs
204
+ return app