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.
- parquetscope/__init__.py +1 -0
- parquetscope/analysis.py +176 -0
- parquetscope/app.py +204 -0
- parquetscope/capture.py +463 -0
- parquetscope/cli.py +159 -0
- parquetscope/demo.py +93 -0
- parquetscope/engines.py +139 -0
- parquetscope/intervals.py +78 -0
- parquetscope/manifest.py +236 -0
- parquetscope/metadata.py +96 -0
- parquetscope/models.py +213 -0
- parquetscope/models_v2.py +217 -0
- parquetscope/models_v3.py +69 -0
- parquetscope/offline.py +15 -0
- parquetscope/range_server.py +281 -0
- parquetscope/static/assets/index-C3pRTokB.css +1 -0
- parquetscope/static/assets/index-CdwSmqlF.js +72 -0
- parquetscope/static/index.html +6 -0
- parquetscope/static/offline.html +78 -0
- parquetscope/worker.py +225 -0
- parquetscope-0.3.0.dist-info/METADATA +164 -0
- parquetscope-0.3.0.dist-info/RECORD +25 -0
- parquetscope-0.3.0.dist-info/WHEEL +4 -0
- parquetscope-0.3.0.dist-info/entry_points.txt +2 -0
- parquetscope-0.3.0.dist-info/licenses/LICENSE +201 -0
parquetscope/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.0"
|
parquetscope/analysis.py
ADDED
|
@@ -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
|