feat: add perf module — OTel pipeline telemetry with Grafana dashboards
Add `perf` as the 12th skinny package with full OpenTelemetry instrumentation for pipeline traces, Prometheus metrics, and Loki-correlated logs. - src/perf/: TracerProvider, MeterProvider, LoggerProvider bridge, PipelineCollector, psutil system metrics, FileExporter fallback, FastAPI middleware, auto-file Gitea issues on step/test failure - infra/otel/: OTel Collector fan-out (traces→Jaeger, metrics→Prometheus, logs→Loki), all services migrated from jaeger:4317 to collector - infra/grafana/dashboards/: 8-panel pipeline performance dashboard - Pipeline runner auto-instruments all 189 steps with zero-cost MockTracer/MockSpan no-ops when telemetry is disabled - 82 new tests, 12,055 total passing Closes #203, closes #204, closes #205, closes #206, closes #207, closes #208, closes #209, closes #210, closes #211, closes #212, closes #213
This commit is contained in:
34
compose.yml
34
compose.yml
@@ -254,9 +254,9 @@ services:
|
||||
- POLARIS_ROOT_SECRET=${POLARIS_ROOT_SECRET}
|
||||
volumes:
|
||||
- ./notebooks:/home/kert/notebooks
|
||||
- ./notebooks/.marimo-config:/home/kert/.config/marimo
|
||||
- ./infra/marimo:/home/kert/.config/marimo:ro
|
||||
- ./assets/css/marimo.css:/home/kert/.config/marimo/loch.css:ro
|
||||
- ./notebooks/home-page-patched.js:/home/kert/workspace/.venv/lib/python3.13/site-packages/marimo/_static/assets/home-page-itW0tRmv.js:ro
|
||||
- ./infra/marimo/home-page-patched.js:/home/kert/workspace/.venv/lib/python3.13/site-packages/marimo/_static/assets/home-page-itW0tRmv.js:ro
|
||||
- ./data:/home/kert/data
|
||||
- ./data/zotero/data:/home/kert/zotero:ro
|
||||
- ./src:/home/kert/src:ro
|
||||
@@ -358,7 +358,7 @@ services:
|
||||
- NESSIE_CATALOG_SERVICE_S3_DEFAULT_OPTIONS_REGION=${S3_REGION:-us-east-1}
|
||||
# OpenTelemetry tracing
|
||||
- QUARKUS_OTEL_ENABLED=true
|
||||
- QUARKUS_OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://jaeger:4317
|
||||
- QUARKUS_OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://otel-collector:4317
|
||||
- QUARKUS_OTEL_SERVICE_NAME=nessie
|
||||
depends_on:
|
||||
- postgres
|
||||
@@ -416,7 +416,7 @@ services:
|
||||
- AWS_REGION=${S3_REGION:-us-east-1}
|
||||
# OpenTelemetry
|
||||
- QUARKUS_OTEL_ENABLED=true
|
||||
- QUARKUS_OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://jaeger:4317
|
||||
- QUARKUS_OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://otel-collector:4317
|
||||
- QUARKUS_OTEL_SERVICE_NAME=polaris
|
||||
depends_on:
|
||||
- postgres
|
||||
@@ -468,7 +468,7 @@ services:
|
||||
- S3_WAREHOUSE=${S3_WAREHOUSE:-s3://lakehouse/}
|
||||
# OpenTelemetry
|
||||
- OTEL_SERVICE_NAME=api
|
||||
- OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4317
|
||||
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
depends_on:
|
||||
@@ -534,6 +534,30 @@ services:
|
||||
- loki
|
||||
restart: unless-stopped
|
||||
|
||||
otel-collector:
|
||||
image: otel/opentelemetry-collector-contrib:latest
|
||||
container_name: otel-collector
|
||||
networks:
|
||||
- observability
|
||||
volumes:
|
||||
- ./infra/otel/otel-collector.yml:/etc/otelcol-contrib/config.yaml:ro
|
||||
depends_on:
|
||||
- jaeger
|
||||
- loki
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"wget --no-verbose --tries=1 --spider http://localhost:13133 || exit 1",
|
||||
]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
restart: unless-stopped
|
||||
|
||||
prometheus:
|
||||
image: prom/prometheus:latest
|
||||
container_name: prometheus
|
||||
|
||||
217
infra/grafana/dashboards/pipeline-performance.json
Normal file
217
infra/grafana/dashboards/pipeline-performance.json
Normal file
@@ -0,0 +1,217 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": []
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 1,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"title": "Pipeline Run Duration",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "s",
|
||||
"color": { "mode": "palette-classic" }
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "histogram_quantile(0.95, sum(rate(stack_stack_pipeline_duration_seconds_bucket{pipeline=~\"$pipeline\"}[$__rate_interval])) by (le, pipeline))",
|
||||
"legendFormat": "p95 {{pipeline}}",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"expr": "histogram_quantile(0.50, sum(rate(stack_stack_pipeline_duration_seconds_bucket{pipeline=~\"$pipeline\"}[$__rate_interval])) by (le, pipeline))",
|
||||
"legendFormat": "p50 {{pipeline}}",
|
||||
"refId": "B"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Step Duration Heatmap",
|
||||
"type": "heatmap",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(stack_stack_step_duration_seconds_bucket{pipeline=~\"$pipeline\"}[$__rate_interval])) by (le, step)",
|
||||
"legendFormat": "{{step}}",
|
||||
"refId": "A",
|
||||
"format": "heatmap"
|
||||
}
|
||||
],
|
||||
"options": {
|
||||
"calculate": false,
|
||||
"yAxis": { "unit": "s" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "Cache Hit Rate",
|
||||
"type": "gauge",
|
||||
"gridPos": { "h": 6, "w": 6, "x": 0, "y": 8 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percentunit",
|
||||
"min": 0,
|
||||
"max": 1,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "red", "value": null },
|
||||
{ "color": "yellow", "value": 0.3 },
|
||||
{ "color": "green", "value": 0.7 }
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(stack_stack_cache_hits_total{pipeline=~\"$pipeline\"}[$__rate_interval])) / (sum(rate(stack_stack_cache_hits_total{pipeline=~\"$pipeline\"}[$__rate_interval])) + sum(rate(stack_stack_cache_misses_total{pipeline=~\"$pipeline\"}[$__rate_interval])))",
|
||||
"legendFormat": "hit rate",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Memory RSS",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 6, "w": 6, "x": 6, "y": 8 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "decmbytes",
|
||||
"color": { "mode": "palette-classic" }
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "stack_stack_system_rss_mb{service=~\"$pipeline\"}",
|
||||
"legendFormat": "{{service}}",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "CPU Utilization",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 6, "w": 6, "x": 12, "y": 8 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent",
|
||||
"color": { "mode": "palette-classic" }
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "stack_stack_system_cpu_percent{service=~\"$pipeline\"}",
|
||||
"legendFormat": "{{service}}",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Row Throughput per Step",
|
||||
"type": "bargauge",
|
||||
"gridPos": { "h": 6, "w": 6, "x": 18, "y": 8 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short",
|
||||
"color": { "mode": "palette-classic" }
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(stack_stack_step_rows_out_sum{pipeline=~\"$pipeline\"}[$__rate_interval])) by (step)",
|
||||
"legendFormat": "{{step}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"options": {
|
||||
"orientation": "horizontal",
|
||||
"displayMode": "gradient",
|
||||
"showUnfilled": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "Recent Pipeline Traces",
|
||||
"type": "table",
|
||||
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 14 },
|
||||
"datasource": { "type": "jaeger", "uid": "jaeger" },
|
||||
"targets": [
|
||||
{
|
||||
"query": "pipeline.run",
|
||||
"queryType": "search",
|
||||
"refId": "A",
|
||||
"limit": 20,
|
||||
"service": "stack"
|
||||
}
|
||||
],
|
||||
"options": {
|
||||
"showHeader": true,
|
||||
"sortBy": [{ "displayName": "Start Time", "desc": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "Pipeline Logs",
|
||||
"type": "logs",
|
||||
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 22 },
|
||||
"datasource": { "type": "loki", "uid": "loki" },
|
||||
"targets": [
|
||||
{
|
||||
"expr": "{container_name=\"api\"} |= \"trace_id\"",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"options": {
|
||||
"showTime": true,
|
||||
"showLabels": false,
|
||||
"showCommonLabels": false,
|
||||
"wrapLogMessage": true,
|
||||
"prettifyLogMessage": false,
|
||||
"enableLogDetails": true,
|
||||
"sortOrder": "Descending"
|
||||
}
|
||||
}
|
||||
],
|
||||
"schemaVersion": 39,
|
||||
"tags": ["pipeline", "telemetry", "otel"],
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"name": "pipeline",
|
||||
"type": "query",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"query": "label_values(stack_stack_step_duration_seconds_count, pipeline)",
|
||||
"refresh": 2,
|
||||
"includeAll": true,
|
||||
"multi": true,
|
||||
"current": {
|
||||
"selected": true,
|
||||
"text": "All",
|
||||
"value": "$__all"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"time": {
|
||||
"from": "now-6h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "browser",
|
||||
"title": "Pipeline Performance",
|
||||
"uid": "stack-pipeline-perf",
|
||||
"version": 1
|
||||
}
|
||||
56
infra/otel/otel-collector.yml
Normal file
56
infra/otel/otel-collector.yml
Normal file
@@ -0,0 +1,56 @@
|
||||
# OpenTelemetry Collector configuration
|
||||
# Fan-out: traces → Jaeger, metrics → Prometheus scrape, logs → Loki
|
||||
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc:
|
||||
endpoint: 0.0.0.0:4317
|
||||
http:
|
||||
endpoint: 0.0.0.0:4318
|
||||
|
||||
processors:
|
||||
batch:
|
||||
timeout: 5s
|
||||
send_batch_size: 1024
|
||||
|
||||
resource:
|
||||
attributes:
|
||||
- key: deployment.environment
|
||||
value: homelab
|
||||
action: upsert
|
||||
|
||||
exporters:
|
||||
otlp/jaeger:
|
||||
endpoint: jaeger:4317
|
||||
tls:
|
||||
insecure: true
|
||||
|
||||
loki:
|
||||
endpoint: http://loki:3100/loki/api/v1/push
|
||||
|
||||
prometheus:
|
||||
endpoint: 0.0.0.0:8889
|
||||
namespace: stack
|
||||
resource_to_telemetry_conversion:
|
||||
enabled: true
|
||||
|
||||
extensions:
|
||||
health_check:
|
||||
endpoint: 0.0.0.0:13133
|
||||
|
||||
service:
|
||||
extensions: [health_check]
|
||||
pipelines:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
processors: [batch, resource]
|
||||
exporters: [otlp/jaeger]
|
||||
metrics:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [prometheus]
|
||||
logs:
|
||||
receivers: [otlp]
|
||||
processors: [batch, resource]
|
||||
exporters: [loki]
|
||||
@@ -35,3 +35,8 @@
|
||||
labels:
|
||||
job: trino
|
||||
__metrics_path__: /v1/status
|
||||
|
||||
# OTel Collector
|
||||
- targets: ['otel-collector:8889']
|
||||
labels:
|
||||
job: otel-collector
|
||||
|
||||
@@ -37,5 +37,5 @@ experimental:
|
||||
tracing:
|
||||
otlp:
|
||||
grpc:
|
||||
endpoint: jaeger:4317
|
||||
endpoint: otel-collector:4317
|
||||
insecure: true
|
||||
|
||||
@@ -6,4 +6,4 @@ discovery.uri=http://localhost:8080
|
||||
|
||||
# OpenTelemetry tracing
|
||||
tracing.enabled=true
|
||||
tracing.exporter.endpoint=http://jaeger:4317
|
||||
tracing.exporter.endpoint=http://otel-collector:4317
|
||||
|
||||
@@ -70,6 +70,16 @@ rex = [
|
||||
"pyarrow>=23.0.0",
|
||||
"fsspec>=2024.1.0",
|
||||
]
|
||||
perf = [
|
||||
"stack[conf]",
|
||||
"opentelemetry-api>=1.25.0",
|
||||
"opentelemetry-sdk>=1.25.0",
|
||||
"opentelemetry-exporter-otlp-proto-grpc>=1.25.0",
|
||||
"opentelemetry-exporter-prometheus>=0.46b0",
|
||||
"opentelemetry-instrumentation-fastapi>=0.46b0",
|
||||
"opentelemetry-semantic-conventions>=0.46b0",
|
||||
"psutil>=5.9.0",
|
||||
]
|
||||
lake = [
|
||||
"stack[aco]",
|
||||
"databricks-cli>=0.18.0",
|
||||
@@ -96,6 +106,7 @@ all = [
|
||||
"stack[ccw]",
|
||||
"stack[cli]",
|
||||
"stack[cms]",
|
||||
"stack[perf]",
|
||||
"stack[pfs]",
|
||||
"stack[rex]",
|
||||
"stack[lake]",
|
||||
@@ -135,6 +146,6 @@ ignore = ["E501", "E741"]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.uv.build-backend]
|
||||
module-name = ["aco", "api", "bcda", "bib", "bls", "ccw", "cli", "cms", "conf", "pfs", "rex"]
|
||||
module-name = ["aco", "api", "bcda", "bib", "bls", "ccw", "cli", "cms", "conf", "perf", "pfs", "rex"]
|
||||
namespace = true
|
||||
source-exclude = ["compose.yml","infra/**","data/**","notebooks/**","tuva/**","assets/**","docs/**","dev/**","bundle/**","cloud/**"]
|
||||
|
||||
@@ -135,6 +135,25 @@ def run_pipeline(
|
||||
cache: dict[str, Any] = {}
|
||||
new_fingerprints: dict[str, str] = {}
|
||||
|
||||
# Optional telemetry — graceful no-op if perf not installed
|
||||
try:
|
||||
from perf.collector import PipelineCollector
|
||||
|
||||
_perf_available = True
|
||||
except ImportError:
|
||||
_perf_available = False
|
||||
|
||||
# Optional issue filing on failure
|
||||
try:
|
||||
from perf.hooks import on_step_failure
|
||||
|
||||
_hooks_available = True
|
||||
except ImportError:
|
||||
_hooks_available = False
|
||||
|
||||
pipeline_name = _infer_pipeline_name(exprs)
|
||||
|
||||
def _run_steps(collector: Any = None) -> None:
|
||||
for expr in exprs:
|
||||
if len(expr) == 3:
|
||||
output_name, fn, output_cls = expr
|
||||
@@ -154,18 +173,39 @@ def run_pipeline(
|
||||
kwargs[param] = load(table_ref)
|
||||
|
||||
# Idempotency: skip if inputs haven't changed
|
||||
cache_hit = False
|
||||
if fingerprints is not None:
|
||||
fp = _fingerprint_inputs(kwargs, fn)
|
||||
new_fingerprints[output_name] = fp
|
||||
if fingerprints.get(output_name) == fp:
|
||||
# Inputs unchanged — try to load cached output
|
||||
try:
|
||||
cache[output_name] = load(output_name)
|
||||
continue
|
||||
cache_hit = True
|
||||
except Exception:
|
||||
pass # Cache miss — re-execute
|
||||
pass
|
||||
|
||||
if cache_hit:
|
||||
# Record cache hit in telemetry if available
|
||||
if collector is not None:
|
||||
with collector.step(output_name) as s:
|
||||
s.record(
|
||||
rows=len(cache[output_name]),
|
||||
columns=list(cache[output_name].columns),
|
||||
cache_hit=True,
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
if collector is not None:
|
||||
with collector.step(output_name) as s:
|
||||
result = fn(**kwargs)
|
||||
s.record(
|
||||
rows=len(result),
|
||||
columns=list(result.columns),
|
||||
)
|
||||
else:
|
||||
result = fn(**kwargs)
|
||||
|
||||
_validate_output(
|
||||
output_name,
|
||||
result,
|
||||
@@ -174,11 +214,21 @@ def run_pipeline(
|
||||
input_tables=input_tables,
|
||||
)
|
||||
cache[output_name] = result
|
||||
except Exception as exc:
|
||||
if _hooks_available:
|
||||
on_step_failure(output_name, exc, pipeline=pipeline_name)
|
||||
raise
|
||||
|
||||
if fingerprints is not None:
|
||||
fp = _fingerprint_inputs(kwargs, fn)
|
||||
new_fingerprints[output_name] = fp
|
||||
|
||||
if _perf_available:
|
||||
with PipelineCollector(pipeline_name) as pc:
|
||||
_run_steps(pc)
|
||||
else:
|
||||
_run_steps()
|
||||
|
||||
# Attach fingerprints to cache for persistence
|
||||
if fingerprints is not None:
|
||||
cache["__fingerprints__"] = new_fingerprints
|
||||
@@ -186,6 +236,18 @@ def run_pipeline(
|
||||
return cache
|
||||
|
||||
|
||||
def _infer_pipeline_name(
|
||||
exprs: list[tuple[str, Callable] | tuple[str, Callable, type | None]],
|
||||
) -> str:
|
||||
"""Infer the pipeline name from the first expression's schema prefix."""
|
||||
if exprs:
|
||||
name = exprs[0][0] if isinstance(exprs[0][0], str) else "unknown"
|
||||
if "." in name:
|
||||
return name.split(".")[0]
|
||||
return name
|
||||
return "unknown"
|
||||
|
||||
|
||||
def load_fingerprints(path: str) -> dict[str, str]:
|
||||
"""Load fingerprints from a JSON file."""
|
||||
try:
|
||||
|
||||
@@ -19,6 +19,16 @@ app = FastAPI(
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
# OTel instrumentation — no-op if perf not installed or telemetry disabled
|
||||
try:
|
||||
from perf import init as _perf_init
|
||||
from perf.middleware import instrument as _perf_instrument
|
||||
|
||||
_perf_init()
|
||||
_perf_instrument(app)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(health.router)
|
||||
app.include_router(pipelines.router)
|
||||
|
||||
@@ -20,6 +20,7 @@ from cli.generate import app as generate_app
|
||||
from cli.health import health
|
||||
from cli.lake import app as lake_app
|
||||
from cli.load import app as load_app
|
||||
from cli.perf import app as perf_app
|
||||
from cli.run import run
|
||||
from cli.validate import validate
|
||||
|
||||
@@ -39,6 +40,7 @@ app.add_typer(lake_app, name="lake", help="Lakehouse schema and data.")
|
||||
app.add_typer(db_app, name="db", help="DuckDB utilities.")
|
||||
app.add_typer(docs_app, name="docs", help="Documentation generation.")
|
||||
app.add_typer(api_app, name="api", help="API server.")
|
||||
app.add_typer(perf_app, name="perf", help="Pipeline telemetry utilities.")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
62
src/cli/perf.py
Normal file
62
src/cli/perf.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""CLI subcommand for pipeline telemetry utilities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
|
||||
app = typer.Typer(no_args_is_help=True)
|
||||
|
||||
|
||||
@app.command()
|
||||
def show(
|
||||
path: str = typer.Option(
|
||||
"traces/spans.jsonl",
|
||||
help="Path to JSONL span file.",
|
||||
),
|
||||
last: int = typer.Option(
|
||||
20,
|
||||
help="Show last N spans.",
|
||||
),
|
||||
) -> None:
|
||||
"""Pretty-print spans from the local JSONL trace file."""
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
typer.echo(f"No trace file at {p}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
lines = p.read_text().strip().split("\n")
|
||||
spans = [json.loads(line) for line in lines[-last:]]
|
||||
|
||||
if not spans:
|
||||
typer.echo("No spans found.")
|
||||
raise typer.Exit(0)
|
||||
|
||||
for span in spans:
|
||||
trace_id = span.get("trace_id", "?")[:12]
|
||||
name = span.get("name", "?")
|
||||
attrs = span.get("attributes", {})
|
||||
status = span.get("status", {}).get("status_code", "?")
|
||||
|
||||
# Duration
|
||||
start = span.get("start_time", 0)
|
||||
end = span.get("end_time", 0)
|
||||
if start and end:
|
||||
dur_ms = (end - start) / 1_000_000
|
||||
dur_str = f"{dur_ms:.1f}ms"
|
||||
else:
|
||||
dur_str = "?"
|
||||
|
||||
# Key attributes
|
||||
parts = []
|
||||
if "rows.out" in attrs:
|
||||
parts.append(f"rows={attrs['rows.out']}")
|
||||
if "cache.hit" in attrs:
|
||||
parts.append("cache=HIT" if attrs["cache.hit"] else "cache=MISS")
|
||||
if "memory.delta_mb" in attrs:
|
||||
parts.append(f"mem={attrs['memory.delta_mb']:+.1f}MB")
|
||||
attr_str = " ".join(parts)
|
||||
|
||||
typer.echo(f" {trace_id} {name:<40} {dur_str:>10} {status:<4} {attr_str}")
|
||||
100
src/perf/__init__.py
Normal file
100
src/perf/__init__.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Pipeline telemetry — OpenTelemetry traces, metrics, and logs.
|
||||
|
||||
Opt-in via ``STACK_TELEMETRY=true`` environment variable or
|
||||
``[telemetry] enabled = true`` in stack.toml. When disabled, all
|
||||
public functions return no-op objects with zero overhead.
|
||||
|
||||
Quick start::
|
||||
|
||||
from perf import init, shutdown
|
||||
|
||||
init() # configure providers (reads stack.toml + env)
|
||||
# ... run pipeline ...
|
||||
shutdown() # flush pending spans/metrics
|
||||
|
||||
For pipeline instrumentation::
|
||||
|
||||
from perf import tracer, meter
|
||||
|
||||
t = tracer("stack.pipeline")
|
||||
m = meter("stack.pipeline")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lazy state — providers are created once by init()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_initialised: bool = False
|
||||
|
||||
|
||||
def _enabled() -> bool:
|
||||
"""Check whether telemetry is enabled via env var or stack.toml."""
|
||||
env = os.environ.get("STACK_TELEMETRY", "").lower()
|
||||
if env in ("true", "1"):
|
||||
return True
|
||||
if env in ("false", "0"):
|
||||
return False
|
||||
# Fall back to stack.toml
|
||||
try:
|
||||
from conf import cfg
|
||||
|
||||
return bool(cfg.telemetry.enabled)
|
||||
except (ImportError, AttributeError, KeyError):
|
||||
return False
|
||||
|
||||
|
||||
def init() -> None:
|
||||
"""Initialise OpenTelemetry providers.
|
||||
|
||||
Safe to call multiple times — only the first call takes effect.
|
||||
When telemetry is disabled this is a no-op.
|
||||
"""
|
||||
global _initialised
|
||||
if _initialised:
|
||||
return
|
||||
_initialised = True
|
||||
|
||||
if not _enabled():
|
||||
return
|
||||
|
||||
from perf._meter import setup_meter_provider
|
||||
from perf._tracer import setup_tracer_provider
|
||||
|
||||
setup_tracer_provider()
|
||||
setup_meter_provider()
|
||||
|
||||
|
||||
def shutdown() -> None:
|
||||
"""Flush and shut down all providers."""
|
||||
global _initialised
|
||||
if not _initialised or not _enabled():
|
||||
return
|
||||
|
||||
from perf._meter import shutdown_meter_provider
|
||||
from perf._tracer import shutdown_tracer_provider
|
||||
|
||||
shutdown_tracer_provider()
|
||||
shutdown_meter_provider()
|
||||
_initialised = False
|
||||
|
||||
|
||||
def tracer(name: str) -> Any:
|
||||
"""Return a tracer (real or mock) for the given instrumentation scope."""
|
||||
from perf._tracer import create_tracer
|
||||
|
||||
return create_tracer(name)
|
||||
|
||||
|
||||
def meter(name: str) -> Any:
|
||||
"""Return a meter (real or mock) for the given instrumentation scope."""
|
||||
from perf._meter import create_meter
|
||||
|
||||
return create_meter(name)
|
||||
82
src/perf/_logs.py
Normal file
82
src/perf/_logs.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""Logging bridge — inject OTel trace context into stdlib log records.
|
||||
|
||||
Adds ``trace_id`` and ``span_id`` fields to every log record emitted
|
||||
while a span is active. Promtail/Loki can extract these as labels
|
||||
for trace-log correlation in Grafana.
|
||||
|
||||
Usage::
|
||||
|
||||
from perf._logs import attach_trace_filter
|
||||
|
||||
attach_trace_filter() # patches the root logger
|
||||
# Now every log line includes trace_id and span_id
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
|
||||
class TraceContextFilter(logging.Filter):
|
||||
"""Inject OTel trace_id and span_id into log records."""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
trace_id = "0" * 32
|
||||
span_id = "0" * 16
|
||||
|
||||
try:
|
||||
from opentelemetry import trace
|
||||
|
||||
span = trace.get_current_span()
|
||||
ctx = span.get_span_context()
|
||||
if ctx and ctx.trace_id:
|
||||
trace_id = format(ctx.trace_id, "032x")
|
||||
span_id = format(ctx.span_id, "016x")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
record.trace_id = trace_id # type: ignore[attr-defined]
|
||||
record.span_id = span_id # type: ignore[attr-defined]
|
||||
return True
|
||||
|
||||
|
||||
class TraceContextFormatter(logging.Formatter):
|
||||
"""Formatter that includes trace context for structured log lines."""
|
||||
|
||||
DEFAULT_FORMAT = (
|
||||
"%(asctime)s %(levelname)s [trace_id=%(trace_id)s span_id=%(span_id)s] "
|
||||
"%(name)s — %(message)s"
|
||||
)
|
||||
|
||||
def __init__(self, fmt: str | None = None, **kwargs):
|
||||
super().__init__(fmt or self.DEFAULT_FORMAT, **kwargs)
|
||||
|
||||
|
||||
_attached: bool = False
|
||||
|
||||
|
||||
def attach_trace_filter(logger: logging.Logger | None = None) -> None:
|
||||
"""Add the trace context filter to a logger (default: root logger).
|
||||
|
||||
Safe to call multiple times — only attaches once per logger.
|
||||
"""
|
||||
global _attached
|
||||
target = logger or logging.getLogger()
|
||||
|
||||
# Avoid double-attach
|
||||
for f in target.filters:
|
||||
if isinstance(f, TraceContextFilter):
|
||||
return
|
||||
|
||||
target.addFilter(TraceContextFilter())
|
||||
_attached = True
|
||||
|
||||
|
||||
def detach_trace_filter(logger: logging.Logger | None = None) -> None:
|
||||
"""Remove the trace context filter from a logger."""
|
||||
global _attached
|
||||
target = logger or logging.getLogger()
|
||||
target.filters = [
|
||||
f for f in target.filters if not isinstance(f, TraceContextFilter)
|
||||
]
|
||||
_attached = False
|
||||
103
src/perf/_meter.py
Normal file
103
src/perf/_meter.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""Meter provider setup with MockMeter no-ops.
|
||||
|
||||
Mirrors ``_tracer.py`` — when telemetry is disabled, ``create_meter()``
|
||||
returns a ``MockMeter`` whose instruments are zero-cost no-ops.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mock objects
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _MockInstrument:
|
||||
"""No-op instrument (counter, histogram, gauge)."""
|
||||
|
||||
def add(self, amount: float, attributes: dict | None = None) -> None:
|
||||
pass
|
||||
|
||||
def record(self, amount: float, attributes: dict | None = None) -> None:
|
||||
pass
|
||||
|
||||
def set(self, amount: float, attributes: dict | None = None) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class MockMeter:
|
||||
"""No-op meter returned when telemetry is disabled."""
|
||||
|
||||
def create_counter(self, name: str, **kw: Any) -> _MockInstrument:
|
||||
return _MockInstrument()
|
||||
|
||||
def create_histogram(self, name: str, **kw: Any) -> _MockInstrument:
|
||||
return _MockInstrument()
|
||||
|
||||
def create_up_down_counter(self, name: str, **kw: Any) -> _MockInstrument:
|
||||
return _MockInstrument()
|
||||
|
||||
def create_observable_gauge(
|
||||
self, name: str, callbacks: Any = None, **kw: Any
|
||||
) -> _MockInstrument:
|
||||
return _MockInstrument()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_provider_ready: bool = False
|
||||
|
||||
|
||||
def setup_meter_provider() -> None:
|
||||
"""Configure the global OTel MeterProvider with Prometheus exporter."""
|
||||
global _provider_ready
|
||||
|
||||
from opentelemetry import metrics
|
||||
from opentelemetry.sdk.metrics import MeterProvider
|
||||
|
||||
from perf._resource import build_resource
|
||||
|
||||
resource = build_resource()
|
||||
readers = []
|
||||
|
||||
try:
|
||||
from opentelemetry.exporter.prometheus import PrometheusMetricReader
|
||||
|
||||
reader = PrometheusMetricReader()
|
||||
readers.append(reader)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
provider = MeterProvider(resource=resource, metric_readers=readers)
|
||||
metrics.set_meter_provider(provider)
|
||||
_provider_ready = True
|
||||
|
||||
|
||||
def shutdown_meter_provider() -> None:
|
||||
"""Flush and shut down the meter provider."""
|
||||
global _provider_ready
|
||||
if not _provider_ready:
|
||||
return
|
||||
from opentelemetry import metrics
|
||||
|
||||
provider = metrics.get_meter_provider()
|
||||
if hasattr(provider, "shutdown"):
|
||||
provider.shutdown()
|
||||
_provider_ready = False
|
||||
|
||||
|
||||
def create_meter(name: str) -> Any:
|
||||
"""Return a real OTel meter or a MockMeter."""
|
||||
if not _provider_ready:
|
||||
from perf import _enabled
|
||||
|
||||
if not _enabled():
|
||||
return MockMeter()
|
||||
return MockMeter()
|
||||
|
||||
from opentelemetry import metrics
|
||||
|
||||
return metrics.get_meter(name)
|
||||
77
src/perf/_resource.py
Normal file
77
src/perf/_resource.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Build the OTel Resource shared by all providers.
|
||||
|
||||
Includes service name, version, git SHA, and active context.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
|
||||
def build_resource():
|
||||
"""Create an OTel Resource with stack-specific attributes."""
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
|
||||
attrs = {
|
||||
"service.name": _service_name(),
|
||||
"service.version": _version(),
|
||||
}
|
||||
|
||||
sha = _git_sha()
|
||||
if sha:
|
||||
attrs["vcs.revision"] = sha
|
||||
|
||||
ctx = _context_name()
|
||||
if ctx:
|
||||
attrs["deployment.environment"] = ctx
|
||||
|
||||
return Resource.create(attrs)
|
||||
|
||||
|
||||
def _service_name() -> str:
|
||||
"""Read service name from env or stack.toml."""
|
||||
import os
|
||||
|
||||
env = os.environ.get("OTEL_SERVICE_NAME")
|
||||
if env:
|
||||
return env
|
||||
try:
|
||||
from conf import cfg
|
||||
|
||||
return str(cfg.telemetry.service_name)
|
||||
except (ImportError, AttributeError, KeyError):
|
||||
return "stack"
|
||||
|
||||
|
||||
def _version() -> str:
|
||||
try:
|
||||
from importlib.metadata import version
|
||||
|
||||
return version("stack")
|
||||
except Exception:
|
||||
return "0.0.0"
|
||||
|
||||
|
||||
def _git_sha() -> str | None:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--short", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _context_name() -> str | None:
|
||||
try:
|
||||
from conf import context
|
||||
|
||||
ctx = context()
|
||||
return str(ctx.db_backend)
|
||||
except Exception:
|
||||
return None
|
||||
48
src/perf/_system.py
Normal file
48
src/perf/_system.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""System resource snapshots via psutil.
|
||||
|
||||
Captures CPU%, RSS, and disk I/O at a point in time. Used by
|
||||
``StepCollector`` to measure resource deltas per pipeline step.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SystemSnapshot:
|
||||
"""Point-in-time system resource reading."""
|
||||
|
||||
rss_mb: float
|
||||
cpu_percent: float
|
||||
disk_read_mb: float
|
||||
disk_write_mb: float
|
||||
|
||||
|
||||
def take_snapshot() -> SystemSnapshot:
|
||||
"""Capture current process resource usage."""
|
||||
import psutil
|
||||
|
||||
proc = psutil.Process()
|
||||
mem = proc.memory_info()
|
||||
io = proc.io_counters()
|
||||
# cpu_percent with interval=None returns since last call
|
||||
cpu = proc.cpu_percent(interval=None)
|
||||
|
||||
return SystemSnapshot(
|
||||
rss_mb=mem.rss / (1024 * 1024),
|
||||
cpu_percent=cpu,
|
||||
disk_read_mb=io.read_bytes / (1024 * 1024),
|
||||
disk_write_mb=io.write_bytes / (1024 * 1024),
|
||||
)
|
||||
|
||||
|
||||
def delta(before: SystemSnapshot, after: SystemSnapshot) -> dict[str, float]:
|
||||
"""Compute resource deltas between two snapshots."""
|
||||
return {
|
||||
"memory.delta_mb": round(after.rss_mb - before.rss_mb, 2),
|
||||
"memory.rss_mb": round(after.rss_mb, 2),
|
||||
"cpu_percent": round(after.cpu_percent, 2),
|
||||
"disk.read_delta_mb": round(after.disk_read_mb - before.disk_read_mb, 2),
|
||||
"disk.write_delta_mb": round(after.disk_write_mb - before.disk_write_mb, 2),
|
||||
}
|
||||
133
src/perf/_tracer.py
Normal file
133
src/perf/_tracer.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Tracer provider setup with MockTracer/MockSpan no-ops.
|
||||
|
||||
Follows the marimo ``_tracer.py`` pattern: when telemetry is disabled,
|
||||
``create_tracer()`` returns a ``MockTracer`` whose spans are zero-cost
|
||||
no-ops. Call sites never need conditional checks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Iterator
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mock objects — zero overhead when telemetry is off
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MockSpan:
|
||||
"""No-op span returned when telemetry is disabled."""
|
||||
|
||||
def set_attribute(self, key: str, value: Any) -> None:
|
||||
pass
|
||||
|
||||
def set_status(self, status: Any, description: str | None = None) -> None:
|
||||
pass
|
||||
|
||||
def record_exception(self, exception: BaseException) -> None:
|
||||
pass
|
||||
|
||||
def add_event(self, name: str, attributes: dict | None = None) -> None:
|
||||
pass
|
||||
|
||||
def __enter__(self) -> MockSpan:
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: Any) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class MockTracer:
|
||||
"""No-op tracer returned when telemetry is disabled."""
|
||||
|
||||
@contextmanager
|
||||
def start_as_current_span(self, name: str, **kwargs: Any) -> Iterator[MockSpan]:
|
||||
yield MockSpan()
|
||||
|
||||
def start_span(self, name: str, **kwargs: Any) -> MockSpan:
|
||||
return MockSpan()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_provider_ready: bool = False
|
||||
|
||||
|
||||
def setup_tracer_provider() -> None:
|
||||
"""Configure the global OTel TracerProvider with OTLP exporter."""
|
||||
global _provider_ready
|
||||
|
||||
from opentelemetry import trace
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||||
|
||||
from perf._resource import build_resource
|
||||
|
||||
resource = build_resource()
|
||||
provider = TracerProvider(resource=resource)
|
||||
|
||||
try:
|
||||
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
|
||||
OTLPSpanExporter,
|
||||
)
|
||||
|
||||
endpoint = _get_endpoint()
|
||||
exporter = OTLPSpanExporter(endpoint=endpoint, insecure=True)
|
||||
provider.add_span_processor(BatchSpanProcessor(exporter))
|
||||
except Exception:
|
||||
# Fall back to file exporter if OTLP is unreachable
|
||||
from perf.export import FileSpanExporter
|
||||
|
||||
provider.add_span_processor(BatchSpanProcessor(FileSpanExporter()))
|
||||
|
||||
trace.set_tracer_provider(provider)
|
||||
_provider_ready = True
|
||||
|
||||
|
||||
def shutdown_tracer_provider() -> None:
|
||||
"""Flush and shut down the tracer provider."""
|
||||
global _provider_ready
|
||||
if not _provider_ready:
|
||||
return
|
||||
from opentelemetry import trace
|
||||
|
||||
provider = trace.get_tracer_provider()
|
||||
if hasattr(provider, "shutdown"):
|
||||
provider.shutdown()
|
||||
_provider_ready = False
|
||||
|
||||
|
||||
def create_tracer(name: str) -> Any:
|
||||
"""Return a real OTel tracer or a MockTracer.
|
||||
|
||||
If telemetry is enabled and the provider is ready, returns a real
|
||||
tracer from the global provider. Otherwise returns a MockTracer.
|
||||
"""
|
||||
if not _provider_ready:
|
||||
from perf import _enabled
|
||||
|
||||
if not _enabled():
|
||||
return MockTracer()
|
||||
# Provider not yet set up — init() hasn't been called
|
||||
return MockTracer()
|
||||
|
||||
from opentelemetry import trace
|
||||
|
||||
return trace.get_tracer(name)
|
||||
|
||||
|
||||
def _get_endpoint() -> str:
|
||||
"""Read the OTLP endpoint from env or stack.toml."""
|
||||
import os
|
||||
|
||||
env = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
|
||||
if env:
|
||||
return env
|
||||
try:
|
||||
from conf import cfg
|
||||
|
||||
return str(cfg.telemetry.endpoint)
|
||||
except (ImportError, AttributeError, KeyError):
|
||||
return "http://otel-collector:4317"
|
||||
215
src/perf/collector.py
Normal file
215
src/perf/collector.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""StepCollector — context manager that wraps pipeline step execution.
|
||||
|
||||
Creates OTel spans and records metrics for each pipeline step::
|
||||
|
||||
from perf.collector import PipelineCollector
|
||||
|
||||
with PipelineCollector("readmissions") as pc:
|
||||
for name, fn, output_cls in steps:
|
||||
with pc.step(name) as s:
|
||||
result = fn(**args)
|
||||
s.record(rows=len(result), columns=list(result.columns))
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Iterator
|
||||
|
||||
from perf import meter as get_meter
|
||||
from perf import tracer as get_tracer
|
||||
|
||||
|
||||
@dataclass
|
||||
class StepResult:
|
||||
"""Metrics recorded for a single step execution."""
|
||||
|
||||
name: str
|
||||
rows_out: int = 0
|
||||
columns: list[str] = field(default_factory=list)
|
||||
cache_hit: bool = False
|
||||
duration_s: float = 0.0
|
||||
system_delta: dict[str, float] = field(default_factory=dict)
|
||||
|
||||
|
||||
class StepContext:
|
||||
"""Collects metrics during step execution."""
|
||||
|
||||
def __init__(self, name: str, span: Any) -> None:
|
||||
self._name = name
|
||||
self._span = span
|
||||
self._result = StepResult(name=name)
|
||||
|
||||
def record(
|
||||
self,
|
||||
rows: int = 0,
|
||||
columns: list[str] | None = None,
|
||||
cache_hit: bool = False,
|
||||
) -> None:
|
||||
"""Record step output metrics."""
|
||||
self._result.rows_out = rows
|
||||
self._result.columns = columns or []
|
||||
self._result.cache_hit = cache_hit
|
||||
|
||||
self._span.set_attribute("rows.out", rows)
|
||||
if columns:
|
||||
self._span.set_attribute("columns.count", len(columns))
|
||||
self._span.set_attribute("cache.hit", cache_hit)
|
||||
|
||||
@property
|
||||
def result(self) -> StepResult:
|
||||
return self._result
|
||||
|
||||
|
||||
class PipelineCollector:
|
||||
"""Wraps an entire pipeline run with tracing and metrics."""
|
||||
|
||||
def __init__(self, pipeline_name: str) -> None:
|
||||
self.pipeline_name = pipeline_name
|
||||
self._tracer = get_tracer("stack.pipeline")
|
||||
self._meter = get_meter("stack.pipeline")
|
||||
self._steps: list[StepResult] = []
|
||||
self._root_span: Any = None
|
||||
self._root_ctx: Any = None
|
||||
|
||||
# Metrics instruments
|
||||
self._step_duration = self._meter.create_histogram(
|
||||
"stack_step_duration_seconds",
|
||||
unit="s",
|
||||
description="Duration of a pipeline step",
|
||||
)
|
||||
self._pipeline_duration = self._meter.create_histogram(
|
||||
"stack_pipeline_duration_seconds",
|
||||
unit="s",
|
||||
description="Duration of an entire pipeline run",
|
||||
)
|
||||
self._rows_out = self._meter.create_histogram(
|
||||
"stack_step_rows_out",
|
||||
description="Number of rows output by a step",
|
||||
)
|
||||
self._cache_hits = self._meter.create_counter(
|
||||
"stack_cache_hits_total",
|
||||
description="Number of cache hits",
|
||||
)
|
||||
self._cache_misses = self._meter.create_counter(
|
||||
"stack_cache_misses_total",
|
||||
description="Number of cache misses",
|
||||
)
|
||||
|
||||
def __enter__(self) -> PipelineCollector:
|
||||
self._root_ctx = self._tracer.start_as_current_span(
|
||||
"pipeline.run",
|
||||
attributes={"pipeline": self.pipeline_name},
|
||||
)
|
||||
self._root_span = self._root_ctx.__enter__()
|
||||
self._start_time = time.monotonic()
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: Any) -> None:
|
||||
elapsed = time.monotonic() - self._start_time
|
||||
self._pipeline_duration.record(elapsed, {"pipeline": self.pipeline_name})
|
||||
self._root_span.set_attribute("steps.total", len(self._steps))
|
||||
self._root_span.set_attribute(
|
||||
"steps.cached", sum(1 for s in self._steps if s.cache_hit)
|
||||
)
|
||||
self._root_ctx.__exit__(*args)
|
||||
|
||||
@contextmanager
|
||||
def step(self, name: str) -> Iterator[StepContext]:
|
||||
"""Wrap a single pipeline step."""
|
||||
attrs = {"pipeline": self.pipeline_name, "step": name}
|
||||
|
||||
with self._tracer.start_as_current_span(
|
||||
f"step.{name}", attributes=attrs
|
||||
) as span:
|
||||
try:
|
||||
from perf._system import delta, take_snapshot
|
||||
|
||||
snap_before = take_snapshot()
|
||||
except Exception:
|
||||
snap_before = None
|
||||
|
||||
ctx = StepContext(name, span)
|
||||
t0 = time.monotonic()
|
||||
|
||||
try:
|
||||
yield ctx
|
||||
finally:
|
||||
elapsed = time.monotonic() - t0
|
||||
ctx._result.duration_s = elapsed
|
||||
|
||||
# Record system delta
|
||||
if snap_before is not None:
|
||||
try:
|
||||
snap_after = take_snapshot()
|
||||
sys_delta = delta(snap_before, snap_after)
|
||||
ctx._result.system_delta = sys_delta
|
||||
for k, v in sys_delta.items():
|
||||
span.set_attribute(k, v)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Record timing
|
||||
span.set_attribute("duration_s", elapsed)
|
||||
self._step_duration.record(elapsed, attrs)
|
||||
|
||||
# Record row count
|
||||
if ctx._result.rows_out:
|
||||
self._rows_out.record(ctx._result.rows_out, attrs)
|
||||
|
||||
# Record cache hit/miss
|
||||
if ctx._result.cache_hit:
|
||||
self._cache_hits.add(1, attrs)
|
||||
else:
|
||||
self._cache_misses.add(1, attrs)
|
||||
|
||||
self._steps.append(ctx._result)
|
||||
|
||||
@contextmanager
|
||||
def load(self, table_ref: str) -> Iterator[LoadContext]:
|
||||
"""Wrap a data load operation as a child span."""
|
||||
with self._tracer.start_as_current_span(
|
||||
f"load.{table_ref}",
|
||||
attributes={"table": table_ref},
|
||||
) as span:
|
||||
ctx = LoadContext(table_ref, span)
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
yield ctx
|
||||
finally:
|
||||
elapsed = time.monotonic() - t0
|
||||
span.set_attribute("duration_s", elapsed)
|
||||
if ctx.rows:
|
||||
span.set_attribute("rows", ctx.rows)
|
||||
|
||||
@contextmanager
|
||||
def save(self, table_ref: str) -> Iterator[LoadContext]:
|
||||
"""Wrap a data save operation as a child span."""
|
||||
with self._tracer.start_as_current_span(
|
||||
f"save.{table_ref}",
|
||||
attributes={"table": table_ref},
|
||||
) as span:
|
||||
ctx = LoadContext(table_ref, span)
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
yield ctx
|
||||
finally:
|
||||
elapsed = time.monotonic() - t0
|
||||
span.set_attribute("duration_s", elapsed)
|
||||
if ctx.rows:
|
||||
span.set_attribute("rows", ctx.rows)
|
||||
|
||||
|
||||
class LoadContext:
|
||||
"""Collects metrics during a load or save operation."""
|
||||
|
||||
def __init__(self, table_ref: str, span: Any) -> None:
|
||||
self.table_ref = table_ref
|
||||
self.rows: int = 0
|
||||
self._span = span
|
||||
|
||||
def record(self, rows: int = 0) -> None:
|
||||
self.rows = rows
|
||||
self._span.set_attribute("rows", rows)
|
||||
58
src/perf/export.py
Normal file
58
src/perf/export.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""File-based span exporter for local dev without an OTel Collector.
|
||||
|
||||
Writes spans as JSONL to a configurable path (default: ``traces/spans.jsonl``).
|
||||
Inspired by marimo's ``FileExporter`` pattern.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
|
||||
|
||||
def _fallback_path() -> Path:
|
||||
"""Resolve the fallback JSONL path from stack.toml or default."""
|
||||
try:
|
||||
from conf import path as conf_path
|
||||
|
||||
return conf_path("telemetry.fallback")
|
||||
except Exception:
|
||||
pass
|
||||
# Default relative to cwd
|
||||
return Path("traces/spans.jsonl")
|
||||
|
||||
|
||||
class FileSpanExporter:
|
||||
"""Span exporter that appends JSONL to a local file."""
|
||||
|
||||
def __init__(self, path: Path | str | None = None) -> None:
|
||||
self._path = Path(path) if path else _fallback_path()
|
||||
|
||||
def export(self, spans: Sequence) -> int:
|
||||
"""Write spans as one JSON line each. Returns SUCCESS (0)."""
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self._path.open("a", encoding="utf-8") as f:
|
||||
for span in spans:
|
||||
ctx = span.get_span_context()
|
||||
record = {
|
||||
"trace_id": format(ctx.trace_id, "032x"),
|
||||
"span_id": format(ctx.span_id, "016x"),
|
||||
"name": span.name,
|
||||
"start_time": span.start_time,
|
||||
"end_time": span.end_time,
|
||||
"status": {
|
||||
"status_code": span.status.status_code.name
|
||||
if span.status
|
||||
else "UNSET",
|
||||
},
|
||||
"attributes": dict(span.attributes) if span.attributes else {},
|
||||
}
|
||||
f.write(json.dumps(record) + "\n")
|
||||
return 0 # SpanExportResult.SUCCESS
|
||||
|
||||
def shutdown(self) -> None:
|
||||
pass
|
||||
|
||||
def force_flush(self, timeout_millis: int = 30000) -> bool:
|
||||
return True
|
||||
190
src/perf/hooks.py
Normal file
190
src/perf/hooks.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""Event hooks — auto-file Gitea issues on pipeline and test failures.
|
||||
|
||||
Integrates with the existing ``api.diag`` crash diagnostics system
|
||||
to file issues whenever a pipeline step fails, a step is skipped,
|
||||
or a test fails or is skipped.
|
||||
|
||||
Pipeline integration::
|
||||
|
||||
from perf.hooks import on_step_failure, on_step_skip
|
||||
|
||||
try:
|
||||
result = fn(**kwargs)
|
||||
except Exception as exc:
|
||||
on_step_failure("readmissions._int_encounter", exc)
|
||||
raise
|
||||
|
||||
Pytest integration::
|
||||
|
||||
# In conftest.py
|
||||
from perf.hooks import pytest_runtest_logreport # noqa: F401
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import traceback
|
||||
from typing import Any
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_OWNER = "homelab"
|
||||
_REPO = "stack"
|
||||
|
||||
|
||||
def _get_token() -> str:
|
||||
"""Resolve Gitea token from env or config."""
|
||||
token = os.environ.get("GITEA_TOKEN", "")
|
||||
if token:
|
||||
return token
|
||||
try:
|
||||
from conf import secret
|
||||
|
||||
return secret("gitea.token", "GITEA_TOKEN")
|
||||
except (ImportError, KeyError):
|
||||
return ""
|
||||
|
||||
|
||||
def _file_issue(title: str, body: str, labels: list[str] | None = None) -> dict | None:
|
||||
"""Post an issue to Gitea. Returns issue dict or None."""
|
||||
token = _get_token()
|
||||
if not token:
|
||||
log.debug("No GITEA_TOKEN — skipping issue filing")
|
||||
return None
|
||||
|
||||
try:
|
||||
from api.clients.gitea import GiteaClient
|
||||
|
||||
client = GiteaClient(token)
|
||||
issue_body: dict[str, Any] = {"title": title, "body": body}
|
||||
if labels:
|
||||
label_ids = client.resolve_labels(_OWNER, _REPO, labels)
|
||||
if label_ids:
|
||||
issue_body["labels"] = label_ids
|
||||
result = client.create_issue(_OWNER, _REPO, issue_body)
|
||||
log.info("Filed issue #%s: %s", result.get("number"), title)
|
||||
client.close()
|
||||
return result
|
||||
except Exception:
|
||||
log.exception("Failed to file Gitea issue")
|
||||
return None
|
||||
|
||||
|
||||
def _git_sha_short() -> str:
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["git", "rev-parse", "--short", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
return r.stdout.strip() if r.returncode == 0 else "unknown"
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
|
||||
# ── Pipeline hooks ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def on_step_failure(
|
||||
step_name: str,
|
||||
exc: BaseException,
|
||||
*,
|
||||
pipeline: str = "",
|
||||
) -> dict | None:
|
||||
"""File a Gitea issue when a pipeline step fails.
|
||||
|
||||
Called from the pipeline runner when a step raises an exception.
|
||||
"""
|
||||
sha = _git_sha_short()
|
||||
exc_str = f"{type(exc).__name__}: {exc}"
|
||||
if len(exc_str) > 80:
|
||||
exc_str = exc_str[:77] + "..."
|
||||
|
||||
title = f"pipeline/{pipeline or 'unknown'}: step `{step_name}` failed"
|
||||
|
||||
tb_lines = traceback.format_exception(type(exc), exc, exc.__traceback__)
|
||||
tb_text = "".join(tb_lines)
|
||||
|
||||
body = (
|
||||
f"**Pipeline:** `{pipeline}`\n"
|
||||
f"**Step:** `{step_name}`\n"
|
||||
f"**Commit:** `{sha}`\n"
|
||||
f"**Exception:** `{exc_str}`\n\n"
|
||||
f"## Traceback\n\n"
|
||||
f"```python\n{tb_text}```\n"
|
||||
)
|
||||
|
||||
return _file_issue(title, body, labels=["bug", "pipeline"])
|
||||
|
||||
|
||||
def on_step_skip(
|
||||
step_name: str,
|
||||
*,
|
||||
pipeline: str = "",
|
||||
reason: str = "fingerprint cache hit",
|
||||
) -> dict | None:
|
||||
"""File a Gitea issue when a pipeline step is unexpectedly skipped."""
|
||||
sha = _git_sha_short()
|
||||
|
||||
title = f"pipeline/{pipeline or 'unknown'}: step `{step_name}` skipped"
|
||||
body = (
|
||||
f"**Pipeline:** `{pipeline}`\n"
|
||||
f"**Step:** `{step_name}`\n"
|
||||
f"**Commit:** `{sha}`\n"
|
||||
f"**Reason:** {reason}\n\n"
|
||||
f"This step was skipped during pipeline execution. "
|
||||
f"If this is unexpected, check input data and fingerprint cache.\n"
|
||||
)
|
||||
|
||||
return _file_issue(title, body, labels=["pipeline"])
|
||||
|
||||
|
||||
# ── Pytest plugin ─────────────────────────────────────────────────
|
||||
# This module is registered as a pytest plugin via conftest or
|
||||
# pyproject.toml's [tool.pytest.ini_options] markers.
|
||||
|
||||
|
||||
def pytest_runtest_logreport(report):
|
||||
"""Pytest hook — file a Gitea issue on test failure or unexpected skip.
|
||||
|
||||
Only fires when STACK_FILE_TEST_ISSUES=true is set (CI environments).
|
||||
"""
|
||||
# Only act on test call phase
|
||||
if report.when != "call":
|
||||
return
|
||||
|
||||
# Only in CI — don't spam during local dev
|
||||
if not os.environ.get("STACK_FILE_TEST_ISSUES", ""):
|
||||
return
|
||||
|
||||
if report.failed:
|
||||
_file_test_issue(report, "failed")
|
||||
elif report.skipped:
|
||||
_file_test_issue(report, "skipped")
|
||||
|
||||
|
||||
def _file_test_issue(report, status: str) -> dict | None:
|
||||
"""Build and file a Gitea issue for a test failure or skip."""
|
||||
sha = _git_sha_short()
|
||||
test_id = report.nodeid
|
||||
|
||||
title = f"test {status}: `{test_id}`"
|
||||
|
||||
body_parts = [
|
||||
f"**Test:** `{test_id}`",
|
||||
f"**Status:** `{status}`",
|
||||
f"**Commit:** `{sha}`",
|
||||
"",
|
||||
]
|
||||
|
||||
if report.longrepr:
|
||||
body_parts.append("## Output\n")
|
||||
body_parts.append(f"```\n{report.longreprtext}\n```\n")
|
||||
|
||||
body = "\n".join(body_parts)
|
||||
labels = ["bug", "test"] if status == "failed" else ["test"]
|
||||
return _file_issue(title, body, labels=labels)
|
||||
31
src/perf/middleware.py
Normal file
31
src/perf/middleware.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""FastAPI OpenTelemetry middleware.
|
||||
|
||||
One-liner instrumentation for the stack API::
|
||||
|
||||
from perf.middleware import instrument
|
||||
instrument(app)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def instrument(app: Any) -> None:
|
||||
"""Add OTel instrumentation to a FastAPI application.
|
||||
|
||||
- Auto-creates spans for all HTTP requests
|
||||
- Propagates trace context into background pipeline runs
|
||||
- No-op if telemetry is disabled or OTel packages unavailable
|
||||
"""
|
||||
from perf import _enabled
|
||||
|
||||
if not _enabled():
|
||||
return
|
||||
|
||||
try:
|
||||
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
||||
|
||||
FastAPIInstrumentor.instrument_app(app)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -188,6 +188,13 @@ registry_secret = "REGISTRY_TOKEN"
|
||||
uv_version = "latest"
|
||||
ci_registry = "gitea:3000" # internal HTTP endpoint (avoids self-signed TLS)
|
||||
|
||||
[telemetry]
|
||||
enabled = false
|
||||
endpoint = "http://otel-collector:4317"
|
||||
fallback = "traces/spans.jsonl"
|
||||
sample_rate = 1.0
|
||||
service_name = "stack"
|
||||
|
||||
[lint]
|
||||
line_length = 88
|
||||
|
||||
@@ -206,7 +213,7 @@ trivy_exit_code = 0
|
||||
[images.notebooks]
|
||||
dockerfile = "infra/images/notebooks.Dockerfile"
|
||||
context = "notebooks/"
|
||||
path_filter = ["notebooks/**", "infra/images/notebooks.Dockerfile"]
|
||||
path_filter = ["notebooks/**", "infra/marimo/**", "infra/images/notebooks.Dockerfile"]
|
||||
|
||||
[images.zotero]
|
||||
dockerfile = "infra/images/zotero.Dockerfile"
|
||||
|
||||
@@ -16,6 +16,14 @@ from pathlib import Path
|
||||
import polars as pl
|
||||
import pytest
|
||||
|
||||
# ── Perf hooks — auto-file Gitea issues on test failure/skip in CI ───────────
|
||||
# Only active when STACK_FILE_TEST_ISSUES=true (set in CI workflows).
|
||||
|
||||
try:
|
||||
from perf.hooks import pytest_runtest_logreport # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# ── input_layer fixtures ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
0
tests/perf/__init__.py
Normal file
0
tests/perf/__init__.py
Normal file
187
tests/perf/test_collector.py
Normal file
187
tests/perf/test_collector.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""Tests for StepCollector, PipelineCollector, and system snapshots."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from opentelemetry import trace
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import (
|
||||
SimpleSpanProcessor,
|
||||
SpanExporter,
|
||||
SpanExportResult,
|
||||
)
|
||||
|
||||
|
||||
class _MemoryExporter(SpanExporter):
|
||||
def __init__(self):
|
||||
self.spans = []
|
||||
|
||||
def export(self, spans):
|
||||
self.spans.extend(spans)
|
||||
return SpanExportResult.SUCCESS
|
||||
|
||||
def shutdown(self):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def trace_setup(monkeypatch):
|
||||
"""Wire up real tracer + mock telemetry enabled."""
|
||||
monkeypatch.setenv("STACK_TELEMETRY", "false")
|
||||
original = trace.get_tracer_provider()
|
||||
exporter = _MemoryExporter()
|
||||
provider = TracerProvider()
|
||||
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
||||
trace.set_tracer_provider(provider)
|
||||
|
||||
# Make perf.tracer() return real tracers from this provider
|
||||
import perf._tracer as tmod
|
||||
|
||||
tmod._provider_ready = True
|
||||
yield exporter
|
||||
tmod._provider_ready = False
|
||||
provider.shutdown()
|
||||
trace._TRACER_PROVIDER = original
|
||||
trace._TRACER_PROVIDER_SET_ONCE._done = False
|
||||
|
||||
|
||||
class TestSystemSnapshot:
|
||||
"""psutil-based system snapshots."""
|
||||
|
||||
def test_take_snapshot(self):
|
||||
from perf._system import take_snapshot
|
||||
|
||||
snap = take_snapshot()
|
||||
assert snap.rss_mb > 0
|
||||
assert snap.disk_read_mb >= 0
|
||||
assert snap.disk_write_mb >= 0
|
||||
|
||||
def test_delta(self):
|
||||
from perf._system import SystemSnapshot, delta
|
||||
|
||||
before = SystemSnapshot(
|
||||
rss_mb=100.0,
|
||||
cpu_percent=10.0,
|
||||
disk_read_mb=50.0,
|
||||
disk_write_mb=20.0,
|
||||
)
|
||||
after = SystemSnapshot(
|
||||
rss_mb=120.0,
|
||||
cpu_percent=25.0,
|
||||
disk_read_mb=55.0,
|
||||
disk_write_mb=22.0,
|
||||
)
|
||||
d = delta(before, after)
|
||||
assert d["memory.delta_mb"] == 20.0
|
||||
assert d["memory.rss_mb"] == 120.0
|
||||
assert d["cpu_percent"] == 25.0
|
||||
assert d["disk.read_delta_mb"] == 5.0
|
||||
assert d["disk.write_delta_mb"] == 2.0
|
||||
|
||||
|
||||
class TestPipelineCollector:
|
||||
"""PipelineCollector creates proper span hierarchy."""
|
||||
|
||||
def test_step_creates_span(self, trace_setup):
|
||||
exporter = trace_setup
|
||||
from perf.collector import PipelineCollector
|
||||
|
||||
with PipelineCollector("test_pipeline") as pc:
|
||||
with pc.step("step_a") as s:
|
||||
s.record(rows=42, columns=["a", "b"])
|
||||
|
||||
names = [s.name for s in exporter.spans]
|
||||
assert "step.step_a" in names
|
||||
assert "pipeline.run" in names
|
||||
|
||||
def test_step_records_attributes(self, trace_setup):
|
||||
exporter = trace_setup
|
||||
from perf.collector import PipelineCollector
|
||||
|
||||
with PipelineCollector("test_pipeline") as pc:
|
||||
with pc.step("step_b") as s:
|
||||
s.record(rows=100, columns=["x", "y", "z"], cache_hit=True)
|
||||
|
||||
step_span = next(s for s in exporter.spans if s.name == "step.step_b")
|
||||
assert step_span.attributes["rows.out"] == 100
|
||||
assert step_span.attributes["columns.count"] == 3
|
||||
assert step_span.attributes["cache.hit"] is True
|
||||
|
||||
def test_multiple_steps(self, trace_setup):
|
||||
exporter = trace_setup
|
||||
from perf.collector import PipelineCollector
|
||||
|
||||
with PipelineCollector("multi") as pc:
|
||||
with pc.step("first") as s:
|
||||
s.record(rows=10)
|
||||
with pc.step("second") as s:
|
||||
s.record(rows=20)
|
||||
with pc.step("third") as s:
|
||||
s.record(rows=30)
|
||||
|
||||
root = next(s for s in exporter.spans if s.name == "pipeline.run")
|
||||
assert root.attributes["steps.total"] == 3
|
||||
|
||||
def test_step_timing(self, trace_setup):
|
||||
import time
|
||||
|
||||
from perf.collector import PipelineCollector
|
||||
|
||||
with PipelineCollector("timed") as pc:
|
||||
with pc.step("slow") as s:
|
||||
time.sleep(0.01)
|
||||
s.record(rows=1)
|
||||
|
||||
assert s.result.duration_s >= 0.01
|
||||
|
||||
def test_step_system_delta(self, trace_setup):
|
||||
from perf.collector import PipelineCollector
|
||||
|
||||
with PipelineCollector("sys") as pc:
|
||||
with pc.step("mem_test") as s:
|
||||
# Allocate some memory to create a delta
|
||||
_data = bytearray(1024 * 1024) # 1MB
|
||||
s.record(rows=1)
|
||||
|
||||
assert "memory.rss_mb" in s.result.system_delta
|
||||
|
||||
def test_cache_hit_flag(self, trace_setup):
|
||||
from perf.collector import PipelineCollector
|
||||
|
||||
with PipelineCollector("cache_test") as pc:
|
||||
with pc.step("cached") as s:
|
||||
s.record(rows=50, cache_hit=True)
|
||||
with pc.step("uncached") as s2:
|
||||
s2.record(rows=50, cache_hit=False)
|
||||
|
||||
root = next(s for s in trace_setup.spans if s.name == "pipeline.run")
|
||||
assert root.attributes["steps.cached"] == 1
|
||||
|
||||
def test_load_span(self, trace_setup):
|
||||
exporter = trace_setup
|
||||
from perf.collector import PipelineCollector
|
||||
|
||||
with PipelineCollector("load_test") as pc:
|
||||
with pc.step("step_load") as s:
|
||||
with pc.load("core.encounter") as lc:
|
||||
lc.record(rows=500)
|
||||
s.record(rows=500)
|
||||
|
||||
load_span = next(s for s in exporter.spans if s.name == "load.core.encounter")
|
||||
assert load_span.attributes["rows"] == 500
|
||||
assert load_span.attributes["table"] == "core.encounter"
|
||||
|
||||
def test_save_span(self, trace_setup):
|
||||
exporter = trace_setup
|
||||
from perf.collector import PipelineCollector
|
||||
|
||||
with PipelineCollector("save_test") as pc:
|
||||
with pc.step("step_save") as s:
|
||||
s.record(rows=200)
|
||||
with pc.save("readmissions.output") as sc:
|
||||
sc.record(rows=200)
|
||||
|
||||
save_span = next(
|
||||
s for s in exporter.spans if s.name == "save.readmissions.output"
|
||||
)
|
||||
assert save_span.attributes["rows"] == 200
|
||||
69
tests/perf/test_dashboard.py
Normal file
69
tests/perf/test_dashboard.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Tests for the Grafana pipeline performance dashboard JSON."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
DASHBOARD_PATH = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "infra"
|
||||
/ "grafana"
|
||||
/ "dashboards"
|
||||
/ "pipeline-performance.json"
|
||||
)
|
||||
|
||||
|
||||
class TestDashboard:
|
||||
def test_file_exists(self):
|
||||
assert DASHBOARD_PATH.exists()
|
||||
|
||||
def test_valid_json(self):
|
||||
data = json.loads(DASHBOARD_PATH.read_text())
|
||||
assert isinstance(data, dict)
|
||||
|
||||
def test_has_required_keys(self):
|
||||
data = json.loads(DASHBOARD_PATH.read_text())
|
||||
assert "panels" in data
|
||||
assert "title" in data
|
||||
assert "uid" in data
|
||||
assert "templating" in data
|
||||
|
||||
def test_uid(self):
|
||||
data = json.loads(DASHBOARD_PATH.read_text())
|
||||
assert data["uid"] == "stack-pipeline-perf"
|
||||
|
||||
def test_has_panels(self):
|
||||
data = json.loads(DASHBOARD_PATH.read_text())
|
||||
panels = data["panels"]
|
||||
assert len(panels) >= 8
|
||||
|
||||
titles = {p["title"] for p in panels}
|
||||
assert "Pipeline Run Duration" in titles
|
||||
assert "Cache Hit Rate" in titles
|
||||
assert "Memory RSS" in titles
|
||||
assert "CPU Utilization" in titles
|
||||
assert "Row Throughput per Step" in titles
|
||||
assert "Recent Pipeline Traces" in titles
|
||||
assert "Pipeline Logs" in titles
|
||||
|
||||
def test_has_pipeline_variable(self):
|
||||
data = json.loads(DASHBOARD_PATH.read_text())
|
||||
variables = data["templating"]["list"]
|
||||
names = [v["name"] for v in variables]
|
||||
assert "pipeline" in names
|
||||
|
||||
def test_datasources(self):
|
||||
data = json.loads(DASHBOARD_PATH.read_text())
|
||||
ds_types = set()
|
||||
for panel in data["panels"]:
|
||||
if "datasource" in panel:
|
||||
ds_types.add(panel["datasource"]["type"])
|
||||
assert "prometheus" in ds_types
|
||||
assert "jaeger" in ds_types
|
||||
assert "loki" in ds_types
|
||||
|
||||
def test_tags(self):
|
||||
data = json.loads(DASHBOARD_PATH.read_text())
|
||||
assert "pipeline" in data.get("tags", [])
|
||||
assert "otel" in data.get("tags", [])
|
||||
99
tests/perf/test_export.py
Normal file
99
tests/perf/test_export.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""Tests for FileSpanExporter fallback and CLI show command."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from perf.export import FileSpanExporter
|
||||
|
||||
|
||||
def _mock_span(name="test", trace_id=0xABCD, span_id=0x1234, attrs=None):
|
||||
ctx = MagicMock()
|
||||
ctx.trace_id = trace_id
|
||||
ctx.span_id = span_id
|
||||
status = MagicMock()
|
||||
status.status_code.name = "OK"
|
||||
span = MagicMock()
|
||||
span.get_span_context.return_value = ctx
|
||||
span.name = name
|
||||
span.start_time = 1_000_000_000
|
||||
span.end_time = 2_000_000_000
|
||||
span.status = status
|
||||
span.attributes = attrs or {}
|
||||
return span
|
||||
|
||||
|
||||
class TestFileExporter:
|
||||
def test_creates_directory(self, tmp_path):
|
||||
out = tmp_path / "deep" / "dir" / "spans.jsonl"
|
||||
exporter = FileSpanExporter(path=out)
|
||||
exporter.export([_mock_span()])
|
||||
assert out.exists()
|
||||
|
||||
def test_appends_multiple_exports(self, tmp_path):
|
||||
out = tmp_path / "spans.jsonl"
|
||||
exporter = FileSpanExporter(path=out)
|
||||
exporter.export([_mock_span("a")])
|
||||
exporter.export([_mock_span("b"), _mock_span("c")])
|
||||
|
||||
lines = out.read_text().strip().split("\n")
|
||||
assert len(lines) == 3
|
||||
names = [json.loads(l)["name"] for l in lines]
|
||||
assert names == ["a", "b", "c"]
|
||||
|
||||
def test_attributes_preserved(self, tmp_path):
|
||||
out = tmp_path / "spans.jsonl"
|
||||
exporter = FileSpanExporter(path=out)
|
||||
exporter.export([_mock_span(attrs={"rows.out": 42, "cache.hit": True})])
|
||||
|
||||
record = json.loads(out.read_text().strip())
|
||||
assert record["attributes"]["rows.out"] == 42
|
||||
assert record["attributes"]["cache.hit"] is True
|
||||
|
||||
def test_shutdown_noop(self, tmp_path):
|
||||
exporter = FileSpanExporter(path=tmp_path / "spans.jsonl")
|
||||
exporter.shutdown() # should not raise
|
||||
|
||||
def test_force_flush(self, tmp_path):
|
||||
exporter = FileSpanExporter(path=tmp_path / "spans.jsonl")
|
||||
assert exporter.force_flush() is True
|
||||
|
||||
|
||||
class TestCLIShow:
|
||||
def test_show_with_data(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from cli.perf import app
|
||||
|
||||
runner = CliRunner()
|
||||
trace_file = tmp_path / "spans.jsonl"
|
||||
exporter = FileSpanExporter(path=trace_file)
|
||||
exporter.export(
|
||||
[
|
||||
_mock_span("pipeline.run", attrs={"rows.out": 100}),
|
||||
_mock_span(
|
||||
"step.core.encounter",
|
||||
attrs={
|
||||
"rows.out": 50,
|
||||
"cache.hit": False,
|
||||
"memory.delta_mb": 2.5,
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["--path", str(trace_file)])
|
||||
assert result.exit_code == 0
|
||||
assert "pipeline.run" in result.output
|
||||
assert "step.core.encounter" in result.output
|
||||
assert "rows=50" in result.output
|
||||
|
||||
def test_show_missing_file(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from cli.perf import app
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["--path", str(tmp_path / "nope.jsonl")])
|
||||
assert result.exit_code == 1
|
||||
174
tests/perf/test_hooks.py
Normal file
174
tests/perf/test_hooks.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""Tests for perf.hooks — Gitea issue filing on failure/skip."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
class TestOnStepFailure:
|
||||
"""on_step_failure builds correct issue title and body."""
|
||||
|
||||
@patch("perf.hooks._file_issue")
|
||||
def test_files_issue_on_exception(self, mock_file):
|
||||
mock_file.return_value = {"number": 999}
|
||||
from perf.hooks import on_step_failure
|
||||
|
||||
exc = ValueError("missing column: person_id")
|
||||
on_step_failure("core.encounter", exc, pipeline="readmissions")
|
||||
|
||||
mock_file.assert_called_once()
|
||||
title, body = mock_file.call_args[0][:2]
|
||||
assert "core.encounter" in title
|
||||
assert "readmissions" in title
|
||||
assert "failed" in title
|
||||
assert "ValueError" in body
|
||||
assert "missing column: person_id" in body
|
||||
assert "Traceback" in body
|
||||
|
||||
@patch("perf.hooks._file_issue")
|
||||
def test_labels_include_bug_and_pipeline(self, mock_file):
|
||||
mock_file.return_value = {"number": 1}
|
||||
from perf.hooks import on_step_failure
|
||||
|
||||
on_step_failure("x.y", RuntimeError("boom"), pipeline="core")
|
||||
labels = mock_file.call_args[1].get("labels") or mock_file.call_args[0][2]
|
||||
assert "bug" in labels
|
||||
assert "pipeline" in labels
|
||||
|
||||
|
||||
class TestOnStepSkip:
|
||||
"""on_step_skip builds correct issue for skipped steps."""
|
||||
|
||||
@patch("perf.hooks._file_issue")
|
||||
def test_files_issue_on_skip(self, mock_file):
|
||||
mock_file.return_value = {"number": 100}
|
||||
from perf.hooks import on_step_skip
|
||||
|
||||
on_step_skip("readmissions._int_enc", pipeline="readmissions")
|
||||
|
||||
mock_file.assert_called_once()
|
||||
title = mock_file.call_args[0][0]
|
||||
assert "skipped" in title
|
||||
assert "readmissions._int_enc" in title
|
||||
|
||||
|
||||
class TestPytestHook:
|
||||
"""pytest_runtest_logreport files issues in CI."""
|
||||
|
||||
@patch("perf.hooks._file_test_issue")
|
||||
def test_ignores_setup_phase(self, mock_file):
|
||||
from perf.hooks import pytest_runtest_logreport
|
||||
|
||||
report = MagicMock()
|
||||
report.when = "setup"
|
||||
pytest_runtest_logreport(report)
|
||||
mock_file.assert_not_called()
|
||||
|
||||
@patch("perf.hooks._file_test_issue")
|
||||
def test_ignores_without_env_var(self, mock_file, monkeypatch):
|
||||
monkeypatch.delenv("STACK_FILE_TEST_ISSUES", raising=False)
|
||||
from perf.hooks import pytest_runtest_logreport
|
||||
|
||||
report = MagicMock()
|
||||
report.when = "call"
|
||||
report.failed = True
|
||||
pytest_runtest_logreport(report)
|
||||
mock_file.assert_not_called()
|
||||
|
||||
@patch("perf.hooks._file_test_issue")
|
||||
def test_files_on_failure_in_ci(self, mock_file, monkeypatch):
|
||||
monkeypatch.setenv("STACK_FILE_TEST_ISSUES", "true")
|
||||
from perf.hooks import pytest_runtest_logreport
|
||||
|
||||
report = MagicMock()
|
||||
report.when = "call"
|
||||
report.failed = True
|
||||
report.skipped = False
|
||||
pytest_runtest_logreport(report)
|
||||
mock_file.assert_called_once_with(report, "failed")
|
||||
|
||||
@patch("perf.hooks._file_test_issue")
|
||||
def test_files_on_skip_in_ci(self, mock_file, monkeypatch):
|
||||
monkeypatch.setenv("STACK_FILE_TEST_ISSUES", "true")
|
||||
from perf.hooks import pytest_runtest_logreport
|
||||
|
||||
report = MagicMock()
|
||||
report.when = "call"
|
||||
report.failed = False
|
||||
report.skipped = True
|
||||
pytest_runtest_logreport(report)
|
||||
mock_file.assert_called_once_with(report, "skipped")
|
||||
|
||||
|
||||
class TestFileTestIssue:
|
||||
"""_file_test_issue builds correct markdown body."""
|
||||
|
||||
@patch("perf.hooks._file_issue")
|
||||
def test_body_includes_nodeid(self, mock_file):
|
||||
mock_file.return_value = {"number": 50}
|
||||
from perf.hooks import _file_test_issue
|
||||
|
||||
report = MagicMock()
|
||||
report.nodeid = "tests/aco/test_core.py::test_encounter"
|
||||
report.longrepr = "AssertionError: ..."
|
||||
report.longreprtext = "AssertionError: expected 10 got 0"
|
||||
|
||||
_file_test_issue(report, "failed")
|
||||
|
||||
title, body = mock_file.call_args[0][:2]
|
||||
assert "test_core.py::test_encounter" in title
|
||||
assert "failed" in title
|
||||
assert "AssertionError" in body
|
||||
|
||||
@patch("perf.hooks._file_issue")
|
||||
def test_failed_gets_bug_label(self, mock_file):
|
||||
mock_file.return_value = {"number": 51}
|
||||
from perf.hooks import _file_test_issue
|
||||
|
||||
report = MagicMock()
|
||||
report.nodeid = "tests/foo.py::test_bar"
|
||||
report.longrepr = None
|
||||
|
||||
_file_test_issue(report, "failed")
|
||||
labels = mock_file.call_args[1].get("labels") or mock_file.call_args[0][2]
|
||||
assert "bug" in labels
|
||||
assert "test" in labels
|
||||
|
||||
@patch("perf.hooks._file_issue")
|
||||
def test_skipped_no_bug_label(self, mock_file):
|
||||
mock_file.return_value = {"number": 52}
|
||||
from perf.hooks import _file_test_issue
|
||||
|
||||
report = MagicMock()
|
||||
report.nodeid = "tests/foo.py::test_baz"
|
||||
report.longrepr = None
|
||||
|
||||
_file_test_issue(report, "skipped")
|
||||
labels = mock_file.call_args[1].get("labels") or mock_file.call_args[0][2]
|
||||
assert "bug" not in labels
|
||||
assert "test" in labels
|
||||
|
||||
|
||||
class TestRunnerIntegration:
|
||||
"""Pipeline runner calls on_step_failure when a step raises."""
|
||||
|
||||
@patch("perf.hooks.on_step_failure")
|
||||
def test_runner_fires_hook_on_failure(self, mock_hook):
|
||||
import polars as pl
|
||||
|
||||
from aco.pipe.runner import run_pipeline
|
||||
|
||||
def bad_step(input_layer__data):
|
||||
raise RuntimeError("data corrupted")
|
||||
|
||||
data = pl.DataFrame({"id": [1]})
|
||||
|
||||
try:
|
||||
run_pipeline([("ns.bad", bad_step)], lambda ref: data)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
mock_hook.assert_called_once()
|
||||
args = mock_hook.call_args
|
||||
assert args[0][0] == "ns.bad"
|
||||
assert isinstance(args[0][1], RuntimeError)
|
||||
201
tests/perf/test_init.py
Normal file
201
tests/perf/test_init.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""Tests for the perf package scaffold — public API, mock objects, config."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class TestEnabled:
|
||||
"""perf._enabled() resolves from env var and stack.toml."""
|
||||
|
||||
def test_env_true(self, monkeypatch):
|
||||
monkeypatch.setenv("STACK_TELEMETRY", "true")
|
||||
from perf import _enabled
|
||||
|
||||
assert _enabled() is True
|
||||
|
||||
def test_env_false(self, monkeypatch):
|
||||
monkeypatch.setenv("STACK_TELEMETRY", "false")
|
||||
from perf import _enabled
|
||||
|
||||
assert _enabled() is False
|
||||
|
||||
def test_env_1(self, monkeypatch):
|
||||
monkeypatch.setenv("STACK_TELEMETRY", "1")
|
||||
from perf import _enabled
|
||||
|
||||
assert _enabled() is True
|
||||
|
||||
def test_env_0(self, monkeypatch):
|
||||
monkeypatch.setenv("STACK_TELEMETRY", "0")
|
||||
from perf import _enabled
|
||||
|
||||
assert _enabled() is False
|
||||
|
||||
def test_fallback_to_config(self, monkeypatch):
|
||||
monkeypatch.delenv("STACK_TELEMETRY", raising=False)
|
||||
from perf import _enabled
|
||||
|
||||
# stack.toml has enabled = false by default
|
||||
assert _enabled() is False
|
||||
|
||||
|
||||
class TestMockTracer:
|
||||
"""MockTracer and MockSpan are zero-cost no-ops."""
|
||||
|
||||
def test_mock_span_context_manager(self):
|
||||
from perf._tracer import MockSpan
|
||||
|
||||
span = MockSpan()
|
||||
with span as s:
|
||||
s.set_attribute("key", "value")
|
||||
s.set_status("OK")
|
||||
s.record_exception(ValueError("test"))
|
||||
s.add_event("test_event", {"k": "v"})
|
||||
|
||||
def test_mock_tracer_start_as_current_span(self):
|
||||
from perf._tracer import MockTracer
|
||||
|
||||
tracer = MockTracer()
|
||||
with tracer.start_as_current_span("test_span") as span:
|
||||
span.set_attribute("rows.out", 100)
|
||||
|
||||
def test_mock_tracer_start_span(self):
|
||||
from perf._tracer import MockTracer
|
||||
|
||||
tracer = MockTracer()
|
||||
span = tracer.start_span("test_span")
|
||||
assert span is not None
|
||||
|
||||
|
||||
class TestMockMeter:
|
||||
"""MockMeter instruments are zero-cost no-ops."""
|
||||
|
||||
def test_mock_counter(self):
|
||||
from perf._meter import MockMeter
|
||||
|
||||
meter = MockMeter()
|
||||
counter = meter.create_counter("test_counter")
|
||||
counter.add(1, {"pipeline": "test"})
|
||||
|
||||
def test_mock_histogram(self):
|
||||
from perf._meter import MockMeter
|
||||
|
||||
meter = MockMeter()
|
||||
hist = meter.create_histogram("test_histogram")
|
||||
hist.record(0.5, {"step": "test"})
|
||||
|
||||
def test_mock_up_down_counter(self):
|
||||
from perf._meter import MockMeter
|
||||
|
||||
meter = MockMeter()
|
||||
udc = meter.create_up_down_counter("test_udc")
|
||||
udc.add(-1)
|
||||
|
||||
def test_mock_observable_gauge(self):
|
||||
from perf._meter import MockMeter
|
||||
|
||||
meter = MockMeter()
|
||||
gauge = meter.create_observable_gauge("test_gauge")
|
||||
gauge.set(42.0)
|
||||
|
||||
|
||||
class TestPublicAPI:
|
||||
"""perf.tracer() and perf.meter() return mocks when disabled."""
|
||||
|
||||
def test_tracer_returns_mock_when_disabled(self, monkeypatch):
|
||||
monkeypatch.setenv("STACK_TELEMETRY", "false")
|
||||
from perf import tracer
|
||||
from perf._tracer import MockTracer
|
||||
|
||||
t = tracer("test.scope")
|
||||
assert isinstance(t, MockTracer)
|
||||
|
||||
def test_meter_returns_mock_when_disabled(self, monkeypatch):
|
||||
monkeypatch.setenv("STACK_TELEMETRY", "false")
|
||||
from perf import meter
|
||||
from perf._meter import MockMeter
|
||||
|
||||
m = meter("test.scope")
|
||||
assert isinstance(m, MockMeter)
|
||||
|
||||
def test_init_noop_when_disabled(self, monkeypatch):
|
||||
monkeypatch.setenv("STACK_TELEMETRY", "false")
|
||||
import perf
|
||||
|
||||
perf._initialised = False # reset state
|
||||
perf.init()
|
||||
# Should not raise — just a no-op
|
||||
perf.shutdown()
|
||||
|
||||
|
||||
class TestFileSpanExporter:
|
||||
"""FileSpanExporter writes JSONL to disk."""
|
||||
|
||||
def test_export_writes_jsonl(self, tmp_path):
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from perf.export import FileSpanExporter
|
||||
|
||||
out = tmp_path / "spans.jsonl"
|
||||
exporter = FileSpanExporter(path=out)
|
||||
|
||||
# Build a mock span
|
||||
mock_ctx = MagicMock()
|
||||
mock_ctx.trace_id = 0x1234567890ABCDEF1234567890ABCDEF
|
||||
mock_ctx.span_id = 0xABCDEF1234567890
|
||||
|
||||
mock_status = MagicMock()
|
||||
mock_status.status_code.name = "OK"
|
||||
|
||||
mock_span = MagicMock()
|
||||
mock_span.get_span_context.return_value = mock_ctx
|
||||
mock_span.name = "test.step"
|
||||
mock_span.start_time = 1000000000
|
||||
mock_span.end_time = 2000000000
|
||||
mock_span.status = mock_status
|
||||
mock_span.attributes = {"rows.out": 42}
|
||||
|
||||
result = exporter.export([mock_span])
|
||||
assert result == 0
|
||||
|
||||
lines = out.read_text().strip().split("\n")
|
||||
assert len(lines) == 1
|
||||
|
||||
record = json.loads(lines[0])
|
||||
assert record["name"] == "test.step"
|
||||
assert record["attributes"]["rows.out"] == 42
|
||||
assert len(record["trace_id"]) == 32
|
||||
assert len(record["span_id"]) == 16
|
||||
|
||||
|
||||
class TestResource:
|
||||
"""_resource.py builds OTel-compatible resource attributes."""
|
||||
|
||||
def test_service_name_from_env(self, monkeypatch):
|
||||
monkeypatch.setenv("OTEL_SERVICE_NAME", "my-service")
|
||||
from perf._resource import _service_name
|
||||
|
||||
assert _service_name() == "my-service"
|
||||
|
||||
def test_service_name_default(self, monkeypatch):
|
||||
monkeypatch.delenv("OTEL_SERVICE_NAME", raising=False)
|
||||
from perf._resource import _service_name
|
||||
|
||||
# Falls back to stack.toml or "stack"
|
||||
name = _service_name()
|
||||
assert isinstance(name, str)
|
||||
assert len(name) > 0
|
||||
|
||||
def test_git_sha(self):
|
||||
from perf._resource import _git_sha
|
||||
|
||||
sha = _git_sha()
|
||||
# We're in a git repo, so this should return a short SHA
|
||||
assert sha is not None
|
||||
assert len(sha) >= 7
|
||||
|
||||
def test_version(self):
|
||||
from perf._resource import _version
|
||||
|
||||
v = _version()
|
||||
assert isinstance(v, str)
|
||||
149
tests/perf/test_integration.py
Normal file
149
tests/perf/test_integration.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""Integration tests — runner with perf collector, middleware wiring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from opentelemetry import trace
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import (
|
||||
SimpleSpanProcessor,
|
||||
SpanExporter,
|
||||
SpanExportResult,
|
||||
)
|
||||
|
||||
|
||||
class _MemoryExporter(SpanExporter):
|
||||
def __init__(self):
|
||||
self.spans = []
|
||||
|
||||
def export(self, spans):
|
||||
self.spans.extend(spans)
|
||||
return SpanExportResult.SUCCESS
|
||||
|
||||
def shutdown(self):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def trace_setup(monkeypatch):
|
||||
"""Wire up real tracer for integration tests."""
|
||||
monkeypatch.setenv("STACK_TELEMETRY", "false")
|
||||
original = trace.get_tracer_provider()
|
||||
exporter = _MemoryExporter()
|
||||
provider = TracerProvider()
|
||||
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
||||
trace.set_tracer_provider(provider)
|
||||
|
||||
import perf._tracer as tmod
|
||||
|
||||
tmod._provider_ready = True
|
||||
yield exporter
|
||||
tmod._provider_ready = False
|
||||
provider.shutdown()
|
||||
trace._TRACER_PROVIDER = original
|
||||
trace._TRACER_PROVIDER_SET_ONCE._done = False
|
||||
|
||||
|
||||
class TestRunnerIntegration:
|
||||
"""run_pipeline emits spans via PipelineCollector."""
|
||||
|
||||
def test_pipeline_creates_spans(self, trace_setup):
|
||||
import polars as pl
|
||||
|
||||
from aco.pipe.runner import run_pipeline
|
||||
|
||||
# Simple two-step pipeline
|
||||
def step_a(input_layer__eligibility):
|
||||
return input_layer__eligibility.select("person_id")
|
||||
|
||||
def step_b(core__step_a):
|
||||
return core__step_a
|
||||
|
||||
eligibility = pl.DataFrame({"person_id": ["P001", "P002"]})
|
||||
|
||||
def load(ref):
|
||||
if ref == "input_layer.eligibility":
|
||||
return eligibility
|
||||
raise KeyError(ref)
|
||||
|
||||
cache = run_pipeline(
|
||||
[
|
||||
("core.step_a", step_a),
|
||||
("core.step_b", step_b),
|
||||
],
|
||||
load,
|
||||
)
|
||||
|
||||
assert "core.step_a" in cache
|
||||
assert "core.step_b" in cache
|
||||
assert len(cache["core.step_a"]) == 2
|
||||
|
||||
# Verify spans were created
|
||||
names = [s.name for s in trace_setup.spans]
|
||||
assert "pipeline.run" in names
|
||||
assert "step.core.step_a" in names
|
||||
assert "step.core.step_b" in names
|
||||
|
||||
def test_pipeline_records_row_counts(self, trace_setup):
|
||||
import polars as pl
|
||||
|
||||
from aco.pipe.runner import run_pipeline
|
||||
|
||||
def step_one(input_layer__data):
|
||||
return input_layer__data
|
||||
|
||||
data = pl.DataFrame({"id": list(range(50))})
|
||||
|
||||
run_pipeline(
|
||||
[("core.step_one", step_one)],
|
||||
lambda ref: data,
|
||||
)
|
||||
|
||||
step_span = next(s for s in trace_setup.spans if s.name == "step.core.step_one")
|
||||
assert step_span.attributes["rows.out"] == 50
|
||||
|
||||
def test_pipeline_without_perf_still_works(self, monkeypatch):
|
||||
"""Verify graceful degradation if perf collector import fails."""
|
||||
import polars as pl
|
||||
|
||||
from aco.pipe.runner import run_pipeline
|
||||
|
||||
def step_x(input_layer__table):
|
||||
return input_layer__table
|
||||
|
||||
data = pl.DataFrame({"col": [1, 2, 3]})
|
||||
cache = run_pipeline(
|
||||
[("ns.step_x", step_x)],
|
||||
lambda ref: data,
|
||||
)
|
||||
assert len(cache["ns.step_x"]) == 3
|
||||
|
||||
def test_pipeline_name_inference(self):
|
||||
from aco.pipe.runner import _infer_pipeline_name
|
||||
|
||||
assert (
|
||||
_infer_pipeline_name([("readmissions._int_enc", lambda: None)])
|
||||
== "readmissions"
|
||||
)
|
||||
assert _infer_pipeline_name([("core.encounter", lambda: None)]) == "core"
|
||||
assert _infer_pipeline_name([]) == "unknown"
|
||||
|
||||
|
||||
class TestMiddleware:
|
||||
"""perf.middleware.instrument is a safe no-op when disabled."""
|
||||
|
||||
def test_instrument_noop_when_disabled(self, monkeypatch):
|
||||
monkeypatch.setenv("STACK_TELEMETRY", "false")
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from perf.middleware import instrument
|
||||
|
||||
app = MagicMock()
|
||||
instrument(app) # should not raise
|
||||
|
||||
def test_server_import(self):
|
||||
"""Verify server.py imports cleanly with perf wiring."""
|
||||
from api.server import app
|
||||
|
||||
assert app is not None
|
||||
assert app.title == "stack"
|
||||
110
tests/perf/test_logs.py
Normal file
110
tests/perf/test_logs.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Tests for the logging bridge — trace context injection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
from opentelemetry import trace
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import (
|
||||
SimpleSpanProcessor,
|
||||
SpanExporter,
|
||||
SpanExportResult,
|
||||
)
|
||||
|
||||
from perf._logs import (
|
||||
TraceContextFilter,
|
||||
TraceContextFormatter,
|
||||
attach_trace_filter,
|
||||
detach_trace_filter,
|
||||
)
|
||||
|
||||
|
||||
class _NoopExporter(SpanExporter):
|
||||
def export(self, spans):
|
||||
return SpanExportResult.SUCCESS
|
||||
|
||||
def shutdown(self):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def otel_provider():
|
||||
"""Set up a real tracer provider for span context tests."""
|
||||
original = trace.get_tracer_provider()
|
||||
provider = TracerProvider()
|
||||
provider.add_span_processor(SimpleSpanProcessor(_NoopExporter()))
|
||||
trace.set_tracer_provider(provider)
|
||||
yield provider
|
||||
provider.shutdown()
|
||||
trace._TRACER_PROVIDER = original
|
||||
trace._TRACER_PROVIDER_SET_ONCE._done = False
|
||||
|
||||
|
||||
class TestTraceContextFilter:
|
||||
"""TraceContextFilter injects trace_id and span_id."""
|
||||
|
||||
def test_zero_ids_outside_span(self):
|
||||
filt = TraceContextFilter()
|
||||
record = logging.LogRecord("test", logging.INFO, "", 0, "msg", (), None)
|
||||
result = filt.filter(record)
|
||||
assert result is True
|
||||
assert record.trace_id == "0" * 32 # type: ignore[attr-defined]
|
||||
assert record.span_id == "0" * 16 # type: ignore[attr-defined]
|
||||
|
||||
def test_real_ids_inside_span(self, otel_provider):
|
||||
filt = TraceContextFilter()
|
||||
tracer = trace.get_tracer("test")
|
||||
with tracer.start_as_current_span("log.test"):
|
||||
record = logging.LogRecord("test", logging.INFO, "", 0, "msg", (), None)
|
||||
filt.filter(record)
|
||||
assert record.trace_id != "0" * 32 # type: ignore[attr-defined]
|
||||
assert record.span_id != "0" * 16 # type: ignore[attr-defined]
|
||||
assert len(record.trace_id) == 32 # type: ignore[attr-defined]
|
||||
assert len(record.span_id) == 16 # type: ignore[attr-defined]
|
||||
|
||||
|
||||
class TestAttachDetach:
|
||||
"""attach_trace_filter and detach_trace_filter manage the root logger."""
|
||||
|
||||
def test_attach_adds_filter(self):
|
||||
logger = logging.getLogger("test.attach")
|
||||
attach_trace_filter(logger)
|
||||
assert any(isinstance(f, TraceContextFilter) for f in logger.filters)
|
||||
detach_trace_filter(logger)
|
||||
|
||||
def test_detach_removes_filter(self):
|
||||
logger = logging.getLogger("test.detach")
|
||||
attach_trace_filter(logger)
|
||||
detach_trace_filter(logger)
|
||||
assert not any(isinstance(f, TraceContextFilter) for f in logger.filters)
|
||||
|
||||
def test_double_attach_is_idempotent(self):
|
||||
logger = logging.getLogger("test.double")
|
||||
attach_trace_filter(logger)
|
||||
attach_trace_filter(logger)
|
||||
count = sum(1 for f in logger.filters if isinstance(f, TraceContextFilter))
|
||||
assert count == 1
|
||||
detach_trace_filter(logger)
|
||||
|
||||
|
||||
class TestTraceContextFormatter:
|
||||
"""Formatter includes trace context fields in output."""
|
||||
|
||||
def test_format_includes_trace_id(self, otel_provider):
|
||||
formatter = TraceContextFormatter()
|
||||
filt = TraceContextFilter()
|
||||
tracer = trace.get_tracer("test")
|
||||
|
||||
with tracer.start_as_current_span("fmt.test"):
|
||||
record = logging.LogRecord(
|
||||
"test.logger", logging.INFO, "", 0, "hello world", (), None
|
||||
)
|
||||
filt.filter(record)
|
||||
output = formatter.format(record)
|
||||
assert "trace_id=" in output
|
||||
assert "span_id=" in output
|
||||
assert "hello world" in output
|
||||
# Should NOT contain all-zero IDs
|
||||
assert "trace_id=" + "0" * 32 not in output
|
||||
103
tests/perf/test_meter.py
Normal file
103
tests/perf/test_meter.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""Tests for real OTel MeterProvider setup and metric instruments."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from opentelemetry.sdk.metrics import MeterProvider
|
||||
from opentelemetry.sdk.metrics.export import InMemoryMetricReader
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def meter_setup():
|
||||
"""Create a fresh MeterProvider + reader pair (no global mutation)."""
|
||||
reader = InMemoryMetricReader()
|
||||
provider = MeterProvider(metric_readers=[reader])
|
||||
yield provider, reader
|
||||
provider.shutdown()
|
||||
|
||||
|
||||
class TestRealMetrics:
|
||||
"""Verify metric instrument creation and recording."""
|
||||
|
||||
def test_counter(self, meter_setup):
|
||||
provider, reader = meter_setup
|
||||
meter = provider.get_meter("test.pipeline")
|
||||
counter = meter.create_counter(
|
||||
"stack_cache_hits_total",
|
||||
description="Cache hits",
|
||||
)
|
||||
counter.add(1, {"pipeline": "readmissions"})
|
||||
counter.add(3, {"pipeline": "readmissions"})
|
||||
|
||||
data = reader.get_metrics_data()
|
||||
rm = data.resource_metrics
|
||||
assert len(rm) > 0
|
||||
sm = rm[0].scope_metrics
|
||||
assert len(sm) > 0
|
||||
metric = sm[0].metrics[0]
|
||||
assert metric.name == "stack_cache_hits_total"
|
||||
dp = metric.data.data_points[0]
|
||||
assert dp.value == 4
|
||||
|
||||
def test_histogram(self, meter_setup):
|
||||
provider, reader = meter_setup
|
||||
meter = provider.get_meter("test.pipeline")
|
||||
hist = meter.create_histogram(
|
||||
"stack_step_duration_seconds",
|
||||
unit="s",
|
||||
description="Step duration",
|
||||
)
|
||||
hist.record(0.5, {"pipeline": "core", "step": "encounter"})
|
||||
hist.record(1.2, {"pipeline": "core", "step": "encounter"})
|
||||
|
||||
data = reader.get_metrics_data()
|
||||
metric = data.resource_metrics[0].scope_metrics[0].metrics[0]
|
||||
assert metric.name == "stack_step_duration_seconds"
|
||||
dp = metric.data.data_points[0]
|
||||
assert dp.count == 2
|
||||
assert dp.sum == pytest.approx(1.7)
|
||||
|
||||
def test_gauge_via_up_down_counter(self, meter_setup):
|
||||
provider, reader = meter_setup
|
||||
meter = provider.get_meter("test.pipeline")
|
||||
gauge = meter.create_up_down_counter(
|
||||
"stack_step_rows_out",
|
||||
description="Rows output by step",
|
||||
)
|
||||
gauge.add(100, {"step": "core.encounter"})
|
||||
|
||||
data = reader.get_metrics_data()
|
||||
metric = data.resource_metrics[0].scope_metrics[0].metrics[0]
|
||||
assert metric.name == "stack_step_rows_out"
|
||||
|
||||
def test_multiple_instruments(self, meter_setup):
|
||||
provider, reader = meter_setup
|
||||
meter = provider.get_meter("test.pipeline")
|
||||
counter = meter.create_counter("test_counter_a")
|
||||
hist = meter.create_histogram("test_hist_a")
|
||||
counter.add(5)
|
||||
hist.record(2.0)
|
||||
|
||||
data = reader.get_metrics_data()
|
||||
names = {
|
||||
m.name
|
||||
for rm in data.resource_metrics
|
||||
for sm in rm.scope_metrics
|
||||
for m in sm.metrics
|
||||
}
|
||||
assert "test_counter_a" in names
|
||||
assert "test_hist_a" in names
|
||||
|
||||
|
||||
class TestCreateMeter:
|
||||
"""Verify create_meter returns MockMeter or real meter."""
|
||||
|
||||
def test_returns_mock_when_disabled(self, monkeypatch):
|
||||
monkeypatch.setenv("STACK_TELEMETRY", "false")
|
||||
import perf._meter as mod
|
||||
|
||||
mod._provider_ready = False
|
||||
m = mod.create_meter("test")
|
||||
from perf._meter import MockMeter
|
||||
|
||||
assert isinstance(m, MockMeter)
|
||||
161
tests/perf/test_tracer.py
Normal file
161
tests/perf/test_tracer.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""Tests for real OTel TracerProvider setup and span lifecycle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from opentelemetry import trace
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import (
|
||||
SimpleSpanProcessor,
|
||||
SpanExporter,
|
||||
SpanExportResult,
|
||||
)
|
||||
|
||||
|
||||
class _MemoryExporter(SpanExporter):
|
||||
"""Lightweight in-memory span collector for tests."""
|
||||
|
||||
def __init__(self):
|
||||
self.spans = []
|
||||
|
||||
def export(self, spans):
|
||||
self.spans.extend(spans)
|
||||
return SpanExportResult.SUCCESS
|
||||
|
||||
def shutdown(self):
|
||||
pass
|
||||
|
||||
def get_finished_spans(self):
|
||||
return list(self.spans)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def memory_exporter():
|
||||
"""Set up an in-memory exporter and restore the global provider after."""
|
||||
original = trace.get_tracer_provider()
|
||||
exporter = _MemoryExporter()
|
||||
provider = TracerProvider()
|
||||
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
||||
trace.set_tracer_provider(provider)
|
||||
yield exporter
|
||||
provider.shutdown()
|
||||
# Reset to original (or no-op proxy)
|
||||
trace._TRACER_PROVIDER = original
|
||||
trace._TRACER_PROVIDER_SET_ONCE._done = False
|
||||
|
||||
|
||||
class TestRealTracerSpans:
|
||||
"""Verify span creation and attributes with a real provider."""
|
||||
|
||||
def test_span_created(self, memory_exporter):
|
||||
tracer = trace.get_tracer("test.pipeline")
|
||||
with tracer.start_as_current_span("test.step") as span:
|
||||
span.set_attribute("rows.out", 42)
|
||||
span.set_attribute("pipeline", "readmissions")
|
||||
|
||||
spans = memory_exporter.get_finished_spans()
|
||||
assert len(spans) == 1
|
||||
assert spans[0].name == "test.step"
|
||||
assert spans[0].attributes["rows.out"] == 42
|
||||
assert spans[0].attributes["pipeline"] == "readmissions"
|
||||
|
||||
def test_nested_spans(self, memory_exporter):
|
||||
tracer = trace.get_tracer("test.pipeline")
|
||||
with tracer.start_as_current_span("pipeline.run") as root:
|
||||
root.set_attribute("pipeline", "core")
|
||||
with tracer.start_as_current_span("step.encounter") as step:
|
||||
step.set_attribute("step", "core.encounter")
|
||||
with tracer.start_as_current_span("load.eligibility") as load:
|
||||
load.set_attribute("table", "input_layer.eligibility")
|
||||
|
||||
spans = memory_exporter.get_finished_spans()
|
||||
assert len(spans) == 3
|
||||
|
||||
# Verify parent-child relationships
|
||||
names = [s.name for s in spans]
|
||||
assert "load.eligibility" in names
|
||||
assert "step.encounter" in names
|
||||
assert "pipeline.run" in names
|
||||
|
||||
load_span = next(s for s in spans if s.name == "load.eligibility")
|
||||
step_span = next(s for s in spans if s.name == "step.encounter")
|
||||
root_span = next(s for s in spans if s.name == "pipeline.run")
|
||||
|
||||
assert load_span.parent.span_id == step_span.context.span_id
|
||||
assert step_span.parent.span_id == root_span.context.span_id
|
||||
assert root_span.parent is None
|
||||
|
||||
def test_span_records_exception(self, memory_exporter):
|
||||
tracer = trace.get_tracer("test.pipeline")
|
||||
try:
|
||||
with tracer.start_as_current_span("failing.step"):
|
||||
raise ValueError("schema mismatch")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
spans = memory_exporter.get_finished_spans()
|
||||
assert len(spans) == 1
|
||||
events = spans[0].events
|
||||
assert len(events) == 1
|
||||
assert events[0].name == "exception"
|
||||
|
||||
def test_span_status(self, memory_exporter):
|
||||
from opentelemetry.trace import StatusCode
|
||||
|
||||
tracer = trace.get_tracer("test.pipeline")
|
||||
with tracer.start_as_current_span("ok.step") as span:
|
||||
span.set_status(StatusCode.OK)
|
||||
|
||||
spans = memory_exporter.get_finished_spans()
|
||||
assert spans[0].status.status_code == StatusCode.OK
|
||||
|
||||
def test_span_timing(self, memory_exporter):
|
||||
tracer = trace.get_tracer("test.pipeline")
|
||||
with tracer.start_as_current_span("timed.step"):
|
||||
pass
|
||||
|
||||
spans = memory_exporter.get_finished_spans()
|
||||
s = spans[0]
|
||||
assert s.end_time > s.start_time
|
||||
|
||||
|
||||
class TestCreateTracer:
|
||||
"""Verify create_tracer returns MockTracer or real tracer."""
|
||||
|
||||
def test_returns_mock_when_disabled(self, monkeypatch):
|
||||
monkeypatch.setenv("STACK_TELEMETRY", "false")
|
||||
import perf._tracer as mod
|
||||
|
||||
mod._provider_ready = False
|
||||
t = mod.create_tracer("test")
|
||||
from perf._tracer import MockTracer
|
||||
|
||||
assert isinstance(t, MockTracer)
|
||||
|
||||
def test_returns_real_when_provider_ready(self, memory_exporter):
|
||||
import perf._tracer as mod
|
||||
|
||||
mod._provider_ready = True
|
||||
t = mod.create_tracer("test.scope")
|
||||
# Should be a real tracer (not MockTracer)
|
||||
from perf._tracer import MockTracer
|
||||
|
||||
assert not isinstance(t, MockTracer)
|
||||
mod._provider_ready = False
|
||||
|
||||
|
||||
class TestEndpointResolution:
|
||||
"""Verify OTLP endpoint reads from env and config."""
|
||||
|
||||
def test_env_override(self, monkeypatch):
|
||||
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://custom:4317")
|
||||
from perf._tracer import _get_endpoint
|
||||
|
||||
assert _get_endpoint() == "http://custom:4317"
|
||||
|
||||
def test_default_fallback(self, monkeypatch):
|
||||
monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False)
|
||||
from perf._tracer import _get_endpoint
|
||||
|
||||
endpoint = _get_endpoint()
|
||||
assert "4317" in endpoint
|
||||
301
uv.lock
generated
301
uv.lock
generated
@@ -228,6 +228,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asgiref"
|
||||
version = "3.11.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "attrs"
|
||||
version = "25.4.0"
|
||||
@@ -1953,6 +1962,158 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-api"
|
||||
version = "1.40.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "importlib-metadata" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2c/1d/4049a9e8698361cc1a1aa03a6c59e4fa4c71e0c0f94a30f988a6876a2ae6/opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f", size = 70851, upload-time = "2026-03-04T14:17:21.555Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9", size = 68676, upload-time = "2026-03-04T14:17:01.24Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-exporter-otlp-proto-common"
|
||||
version = "1.40.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "opentelemetry-proto" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/51/bc/1559d46557fe6eca0b46c88d4c2676285f1f3be2e8d06bb5d15fbffc814a/opentelemetry_exporter_otlp_proto_common-1.40.0.tar.gz", hash = "sha256:1cbee86a4064790b362a86601ee7934f368b81cd4cc2f2e163902a6e7818a0fa", size = 20416, upload-time = "2026-03-04T14:17:23.801Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/ca/8f122055c97a932311a3f640273f084e738008933503d0c2563cd5d591fc/opentelemetry_exporter_otlp_proto_common-1.40.0-py3-none-any.whl", hash = "sha256:7081ff453835a82417bf38dccf122c827c3cbc94f2079b03bba02a3165f25149", size = 18369, upload-time = "2026-03-04T14:17:04.796Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-exporter-otlp-proto-grpc"
|
||||
version = "1.40.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "googleapis-common-protos" },
|
||||
{ name = "grpcio" },
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-exporter-otlp-proto-common" },
|
||||
{ name = "opentelemetry-proto" },
|
||||
{ name = "opentelemetry-sdk" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8f/7f/b9e60435cfcc7590fa87436edad6822240dddbc184643a2a005301cc31f4/opentelemetry_exporter_otlp_proto_grpc-1.40.0.tar.gz", hash = "sha256:bd4015183e40b635b3dab8da528b27161ba83bf4ef545776b196f0fb4ec47740", size = 25759, upload-time = "2026-03-04T14:17:24.4Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/96/6f/7ee0980afcbdcd2d40362da16f7f9796bd083bf7f0b8e038abfbc0300f5d/opentelemetry_exporter_otlp_proto_grpc-1.40.0-py3-none-any.whl", hash = "sha256:2aa0ca53483fe0cf6405087a7491472b70335bc5c7944378a0a8e72e86995c52", size = 20304, upload-time = "2026-03-04T14:17:05.942Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-exporter-prometheus"
|
||||
version = "0.61b0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-sdk" },
|
||||
{ name = "prometheus-client" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4a/20/9e818fd364d12e8d0cfdce4a3b2d82e24d98c4ceebb315de6b6770b5f214/opentelemetry_exporter_prometheus-0.61b0.tar.gz", hash = "sha256:7c4919bd8e79abd62b610767e80f42c9c3a06c5183f4dd9141eedeb57aea284b", size = 15136, upload-time = "2026-03-04T14:17:26.275Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/02/4a/b65d40e94d1d930aee73a1a2857211ee6ab10ce3686cbdae5eea78cd9d34/opentelemetry_exporter_prometheus-0.61b0-py3-none-any.whl", hash = "sha256:3013b41f4370143d48d219a2351473761423e5882fa4c213811eaefacba39cb7", size = 13149, upload-time = "2026-03-04T14:17:08.983Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-instrumentation"
|
||||
version = "0.61b0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-semantic-conventions" },
|
||||
{ name = "packaging" },
|
||||
{ name = "wrapt" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/da/37/6bf8e66bfcee5d3c6515b79cb2ee9ad05fe573c20f7ceb288d0e7eeec28c/opentelemetry_instrumentation-0.61b0.tar.gz", hash = "sha256:cb21b48db738c9de196eba6b805b4ff9de3b7f187e4bbf9a466fa170514f1fc7", size = 32606, upload-time = "2026-03-04T14:20:16.825Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/3e/f6f10f178b6316de67f0dfdbbb699a24fbe8917cf1743c1595fb9dcdd461/opentelemetry_instrumentation-0.61b0-py3-none-any.whl", hash = "sha256:92a93a280e69788e8f88391247cc530fd81f16f2b011979d4d6398f805cfbc63", size = 33448, upload-time = "2026-03-04T14:19:02.447Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-instrumentation-asgi"
|
||||
version = "0.61b0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "asgiref" },
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-instrumentation" },
|
||||
{ name = "opentelemetry-semantic-conventions" },
|
||||
{ name = "opentelemetry-util-http" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/00/3e/143cf5c034e58037307e6a24f06e0dd64b2c49ae60a965fc580027581931/opentelemetry_instrumentation_asgi-0.61b0.tar.gz", hash = "sha256:9d08e127244361dc33976d39dd4ca8f128b5aa5a7ae425208400a80a095019b5", size = 26691, upload-time = "2026-03-04T14:20:21.038Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/19/78/154470cf9d741a7487fbb5067357b87386475bbb77948a6707cae982e158/opentelemetry_instrumentation_asgi-0.61b0-py3-none-any.whl", hash = "sha256:e4b3ce6b66074e525e717efff20745434e5efd5d9df6557710856fba356da7a4", size = 16980, upload-time = "2026-03-04T14:19:10.894Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-instrumentation-fastapi"
|
||||
version = "0.61b0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-instrumentation" },
|
||||
{ name = "opentelemetry-instrumentation-asgi" },
|
||||
{ name = "opentelemetry-semantic-conventions" },
|
||||
{ name = "opentelemetry-util-http" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/37/35/aa727bb6e6ef930dcdc96a617b83748fece57b43c47d83ba8d83fbeca657/opentelemetry_instrumentation_fastapi-0.61b0.tar.gz", hash = "sha256:3a24f35b07c557ae1bbc483bf8412221f25d79a405f8b047de8b670722e2fa9f", size = 24800, upload-time = "2026-03-04T14:20:32.759Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/91/05/acfeb2cccd434242a0a7d0ea29afaf077e04b42b35b485d89aee4e0d9340/opentelemetry_instrumentation_fastapi-0.61b0-py3-none-any.whl", hash = "sha256:a1a844d846540d687d377516b2ff698b51d87c781b59f47c214359c4a241047c", size = 13485, upload-time = "2026-03-04T14:19:30.351Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-proto"
|
||||
version = "1.40.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "protobuf" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4c/77/dd38991db037fdfce45849491cb61de5ab000f49824a00230afb112a4392/opentelemetry_proto-1.40.0.tar.gz", hash = "sha256:03f639ca129ba513f5819810f5b1f42bcb371391405d99c168fe6937c62febcd", size = 45667, upload-time = "2026-03-04T14:17:31.194Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/b2/189b2577dde745b15625b3214302605b1353436219d42b7912e77fa8dc24/opentelemetry_proto-1.40.0-py3-none-any.whl", hash = "sha256:266c4385d88923a23d63e353e9761af0f47a6ed0d486979777fe4de59dc9b25f", size = 72073, upload-time = "2026-03-04T14:17:16.673Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-sdk"
|
||||
version = "1.40.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-semantic-conventions" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/58/fd/3c3125b20ba18ce2155ba9ea74acb0ae5d25f8cd39cfd37455601b7955cc/opentelemetry_sdk-1.40.0.tar.gz", hash = "sha256:18e9f5ec20d859d268c7cb3c5198c8d105d073714db3de50b593b8c1345a48f2", size = 184252, upload-time = "2026-03-04T14:17:31.87Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/c5/6a852903d8bfac758c6dc6e9a68b015d3c33f2f1be5e9591e0f4b69c7e0a/opentelemetry_sdk-1.40.0-py3-none-any.whl", hash = "sha256:787d2154a71f4b3d81f20524a8ce061b7db667d24e46753f32a7bc48f1c1f3f1", size = 141951, upload-time = "2026-03-04T14:17:17.961Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-semantic-conventions"
|
||||
version = "0.61b0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6d/c0/4ae7973f3c2cfd2b6e321f1675626f0dab0a97027cc7a297474c9c8f3d04/opentelemetry_semantic_conventions-0.61b0.tar.gz", hash = "sha256:072f65473c5d7c6dc0355b27d6c9d1a679d63b6d4b4b16a9773062cb7e31192a", size = 145755, upload-time = "2026-03-04T14:17:32.664Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/37/cc6a55e448deaa9b27377d087da8615a3416d8ad523d5960b78dbeadd02a/opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl", hash = "sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2", size = 231621, upload-time = "2026-03-04T14:17:19.33Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-util-http"
|
||||
version = "0.61b0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/3c/f0196223efc5c4ca19f8fad3d5462b171ac6333013335ce540c01af419e9/opentelemetry_util_http-0.61b0.tar.gz", hash = "sha256:1039cb891334ad2731affdf034d8fb8b48c239af9b6dd295e5fabd07f1c95572", size = 11361, upload-time = "2026-03-04T14:20:57.01Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/e5/c08aaaf2f64288d2b6ef65741d2de5454e64af3e050f34285fb1907492fe/opentelemetry_util_http-0.61b0-py3-none-any.whl", hash = "sha256:8e715e848233e9527ea47e275659ea60a57a75edf5206a3b937e236a6da5fc33", size = 9281, upload-time = "2026-03-04T14:20:08.364Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "orderly-set"
|
||||
version = "5.5.0"
|
||||
@@ -2183,6 +2344,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/18/72c216f4ab0c82b907009668f79183ae029116ff0dd245d56ef58aac48e7/polars_runtime_32-1.38.1-cp310-abi3-win_arm64.whl", hash = "sha256:6d07d0cc832bfe4fb54b6e04218c2c27afcfa6b9498f9f6bbf262a00d58cc7c4", size = 41639413, upload-time = "2026-02-06T18:12:22.044Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prometheus-client"
|
||||
version = "0.24.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f0/58/a794d23feb6b00fc0c72787d7e87d872a6730dd9ed7c7b3e954637d8f280/prometheus_client-0.24.1.tar.gz", hash = "sha256:7e0ced7fbbd40f7b84962d5d2ab6f17ef88a72504dcf7c0b40737b43b2a461f9", size = 85616, upload-time = "2026-01-14T15:26:26.965Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/74/c3/24a2f845e3917201628ecaba4f18bab4d18a337834c1df2a159ee9d22a42/prometheus_client-0.24.1-py3-none-any.whl", hash = "sha256:150db128af71a5c2482b36e588fc8a6b95e498750da4b17065947c16070f4055", size = 64057, upload-time = "2026-01-14T15:26:24.42Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "propcache"
|
||||
version = "0.4.1"
|
||||
@@ -2938,6 +3108,13 @@ all = [
|
||||
{ name = "fsspec" },
|
||||
{ name = "httpx" },
|
||||
{ name = "narwhals" },
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-exporter-otlp-proto-grpc" },
|
||||
{ name = "opentelemetry-exporter-prometheus" },
|
||||
{ name = "opentelemetry-instrumentation-fastapi" },
|
||||
{ name = "opentelemetry-sdk" },
|
||||
{ name = "opentelemetry-semantic-conventions" },
|
||||
{ name = "psutil" },
|
||||
{ name = "pyarrow" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyjwt" },
|
||||
@@ -3014,6 +3191,18 @@ lake = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "sqlglot" },
|
||||
]
|
||||
perf = [
|
||||
{ name = "duckdb" },
|
||||
{ name = "httpx" },
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-exporter-otlp-proto-grpc" },
|
||||
{ name = "opentelemetry-exporter-prometheus" },
|
||||
{ name = "opentelemetry-instrumentation-fastapi" },
|
||||
{ name = "opentelemetry-sdk" },
|
||||
{ name = "opentelemetry-semantic-conventions" },
|
||||
{ name = "psutil" },
|
||||
{ name = "pydantic" },
|
||||
]
|
||||
pfs = [
|
||||
{ name = "duckdb" },
|
||||
{ name = "fsspec" },
|
||||
@@ -3074,6 +3263,13 @@ requires-dist = [
|
||||
{ name = "narwhals", marker = "extra == 'cms'", specifier = ">=2.17.0" },
|
||||
{ name = "narwhals", marker = "extra == 'pfs'", specifier = ">=2.17.0" },
|
||||
{ name = "narwhals", marker = "extra == 'rex'", specifier = ">=2.17.0" },
|
||||
{ name = "opentelemetry-api", marker = "extra == 'perf'", specifier = ">=1.25.0" },
|
||||
{ name = "opentelemetry-exporter-otlp-proto-grpc", marker = "extra == 'perf'", specifier = ">=1.25.0" },
|
||||
{ name = "opentelemetry-exporter-prometheus", marker = "extra == 'perf'", specifier = ">=0.46b0" },
|
||||
{ name = "opentelemetry-instrumentation-fastapi", marker = "extra == 'perf'", specifier = ">=0.46b0" },
|
||||
{ name = "opentelemetry-sdk", marker = "extra == 'perf'", specifier = ">=1.25.0" },
|
||||
{ name = "opentelemetry-semantic-conventions", marker = "extra == 'perf'", specifier = ">=0.46b0" },
|
||||
{ name = "psutil", marker = "extra == 'perf'", specifier = ">=5.9.0" },
|
||||
{ name = "pyarrow", marker = "extra == 'aco'", specifier = ">=23.0.0" },
|
||||
{ name = "pyarrow", marker = "extra == 'bcda'", specifier = ">=23.0.0" },
|
||||
{ name = "pyarrow", marker = "extra == 'rex'", specifier = ">=23.0.0" },
|
||||
@@ -3101,9 +3297,11 @@ requires-dist = [
|
||||
{ name = "stack", extras = ["conf"], marker = "extra == 'api'" },
|
||||
{ name = "stack", extras = ["conf"], marker = "extra == 'bcda'" },
|
||||
{ name = "stack", extras = ["conf"], marker = "extra == 'bib'" },
|
||||
{ name = "stack", extras = ["conf"], marker = "extra == 'perf'" },
|
||||
{ name = "stack", extras = ["conf"], marker = "extra == 'pfs'" },
|
||||
{ name = "stack", extras = ["conf"], marker = "extra == 'rex'" },
|
||||
{ name = "stack", extras = ["lake"], marker = "extra == 'all'" },
|
||||
{ name = "stack", extras = ["perf"], marker = "extra == 'all'" },
|
||||
{ name = "stack", extras = ["pfs"], marker = "extra == 'all'" },
|
||||
{ name = "stack", extras = ["rex"], marker = "extra == 'all'" },
|
||||
{ name = "stack", extras = ["rex"], marker = "extra == 'pfs'" },
|
||||
@@ -3111,7 +3309,7 @@ requires-dist = [
|
||||
{ name = "uvicorn", marker = "extra == 'api'", specifier = ">=0.41.0" },
|
||||
{ name = "uvicorn", marker = "extra == 'cli'", specifier = ">=0.41.0" },
|
||||
]
|
||||
provides-extras = ["conf", "aco", "api", "bcda", "bib", "bls", "ccw", "cli", "cms", "pfs", "rex", "lake", "aws", "gcp", "azure", "all"]
|
||||
provides-extras = ["conf", "aco", "api", "bcda", "bib", "bls", "ccw", "cli", "cms", "pfs", "rex", "perf", "lake", "aws", "gcp", "azure", "all"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
@@ -3286,66 +3484,51 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "wrapt"
|
||||
version = "2.1.2"
|
||||
version = "1.17.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/b6/1db817582c49c7fcbb7df6809d0f515af29d7c2fbf57eb44c36e98fb1492/wrapt-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ff2aad9c4cda28a8f0653fc2d487596458c2a3f475e56ba02909e950a9efa6a9", size = 61255, upload-time = "2026-03-06T02:52:45.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/16/9b02a6b99c09227c93cd4b73acc3678114154ec38da53043c0ddc1fba0dc/wrapt-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6433ea84e1cfacf32021d2a4ee909554ade7fd392caa6f7c13f1f4bf7b8e8748", size = 61848, upload-time = "2026-03-06T02:53:48.728Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/aa/ead46a88f9ec3a432a4832dfedb84092fc35af2d0ba40cd04aea3889f247/wrapt-2.1.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c20b757c268d30d6215916a5fa8461048d023865d888e437fab451139cad6c8e", size = 121433, upload-time = "2026-03-06T02:54:40.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/9f/742c7c7cdf58b59085a1ee4b6c37b013f66ac33673a7ef4aaed5e992bc33/wrapt-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79847b83eb38e70d93dc392c7c5b587efe65b3e7afcc167aa8abd5d60e8761c8", size = 123013, upload-time = "2026-03-06T02:53:26.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/44/2c3dd45d53236b7ed7c646fcf212251dc19e48e599debd3926b52310fafb/wrapt-2.1.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f8fba1bae256186a83d1875b2b1f4e2d1242e8fac0f58ec0d7e41b26967b965c", size = 117326, upload-time = "2026-03-06T02:53:11.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/e2/b17d66abc26bd96f89dec0ecd0ef03da4a1286e6ff793839ec431b9fae57/wrapt-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e3d3b35eedcf5f7d022291ecd7533321c4775f7b9cd0050a31a68499ba45757c", size = 121444, upload-time = "2026-03-06T02:54:09.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/62/e2977843fdf9f03daf1586a0ff49060b1b2fc7ff85a7ea82b6217c1ae36e/wrapt-2.1.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6f2c5390460de57fa9582bc8a1b7a6c86e1a41dfad74c5225fc07044c15cc8d1", size = 116237, upload-time = "2026-03-06T02:54:03.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/dd/27fc67914e68d740bce512f11734aec08696e6b17641fef8867c00c949fc/wrapt-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7dfa9f2cf65d027b951d05c662cc99ee3bd01f6e4691ed39848a7a5fffc902b2", size = 120563, upload-time = "2026-03-06T02:53:20.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/9f/b750b3692ed2ef4705cb305bd68858e73010492b80e43d2a4faa5573cbe7/wrapt-2.1.2-cp312-cp312-win32.whl", hash = "sha256:eba8155747eb2cae4a0b913d9ebd12a1db4d860fc4c829d7578c7b989bd3f2f0", size = 58198, upload-time = "2026-03-06T02:53:37.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/b2/feecfe29f28483d888d76a48f03c4c4d8afea944dbee2b0cd3380f9df032/wrapt-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1c51c738d7d9faa0b3601708e7e2eda9bf779e1b601dce6c77411f2a1b324a63", size = 60441, upload-time = "2026-03-06T02:52:47.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/e1/e328f605d6e208547ea9fd120804fcdec68536ac748987a68c47c606eea8/wrapt-2.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:c8e46ae8e4032792eb2f677dbd0d557170a8e5524d22acc55199f43efedd39bf", size = 58836, upload-time = "2026-03-06T02:53:22.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/7a/d936840735c828b38d26a854e85d5338894cda544cb7a85a9d5b8b9c4df7/wrapt-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787fd6f4d67befa6fe2abdffcbd3de2d82dfc6fb8a6d850407c53332709d030b", size = 61259, upload-time = "2026-03-06T02:53:41.922Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/88/9a9b9a90ac8ca11c2fdb6a286cb3a1fc7dd774c00ed70929a6434f6bc634/wrapt-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4bdf26e03e6d0da3f0e9422fd36bcebf7bc0eeb55fdf9c727a09abc6b9fe472e", size = 61851, upload-time = "2026-03-06T02:52:48.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/a9/5b7d6a16fd6533fed2756900fc8fc923f678179aea62ada6d65c92718c00/wrapt-2.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bbac24d879aa22998e87f6b3f481a5216311e7d53c7db87f189a7a0266dafffb", size = 121446, upload-time = "2026-03-06T02:54:14.013Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/bb/34c443690c847835cfe9f892be78c533d4f32366ad2888972c094a897e39/wrapt-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16997dfb9d67addc2e3f41b62a104341e80cac52f91110dece393923c0ebd5ca", size = 123056, upload-time = "2026-03-06T02:54:10.829Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/b9/ff205f391cb708f67f41ea148545f2b53ff543a7ac293b30d178af4d2271/wrapt-2.1.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:162e4e2ba7542da9027821cb6e7c5e068d64f9a10b5f15512ea28e954893a267", size = 117359, upload-time = "2026-03-06T02:53:03.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/3d/1ea04d7747825119c3c9a5e0874a40b33594ada92e5649347c457d982805/wrapt-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f29c827a8d9936ac320746747a016c4bc66ef639f5cd0d32df24f5eacbf9c69f", size = 121479, upload-time = "2026-03-06T02:53:45.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/cc/ee3a011920c7a023b25e8df26f306b2484a531ab84ca5c96260a73de76c0/wrapt-2.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9dd9813825f7ecb018c17fd147a01845eb330254dff86d3b5816f20f4d6aaf8", size = 116271, upload-time = "2026-03-06T02:54:46.356Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/fd/e5ff7ded41b76d802cf1191288473e850d24ba2e39a6ec540f21ae3b57cb/wrapt-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f8dbdd3719e534860d6a78526aafc220e0241f981367018c2875178cf83a413", size = 120573, upload-time = "2026-03-06T02:52:50.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/c5/242cae3b5b080cd09bacef0591691ba1879739050cc7c801ff35c8886b66/wrapt-2.1.2-cp313-cp313-win32.whl", hash = "sha256:5c35b5d82b16a3bc6e0a04349b606a0582bc29f573786aebe98e0c159bc48db6", size = 58205, upload-time = "2026-03-06T02:53:47.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/69/c358c61e7a50f290958809b3c61ebe8b3838ea3e070d7aac9814f95a0528/wrapt-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f8bc1c264d8d1cf5b3560a87bbdd31131573eb25f9f9447bb6252b8d4c44a3a1", size = 60452, upload-time = "2026-03-06T02:53:30.038Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/66/c8a6fcfe321295fd8c0ab1bd685b5a01462a9b3aa2f597254462fc2bc975/wrapt-2.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:3beb22f674550d5634642c645aba4c72a2c66fb185ae1aebe1e955fae5a13baf", size = 58842, upload-time = "2026-03-06T02:52:52.114Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/55/9c7052c349106e0b3f17ae8db4b23a691a963c334de7f9dbd60f8f74a831/wrapt-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fc04bc8664a8bc4c8e00b37b5355cffca2535209fba1abb09ae2b7c76ddf82b", size = 63075, upload-time = "2026-03-06T02:53:19.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/a8/ce7b4006f7218248dd71b7b2b732d0710845a0e49213b18faef64811ffef/wrapt-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a9b9d50c9af998875a1482a038eb05755dfd6fe303a313f6a940bb53a83c3f18", size = 63719, upload-time = "2026-03-06T02:54:33.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/e5/2ca472e80b9e2b7a17f106bb8f9df1db11e62101652ce210f66935c6af67/wrapt-2.1.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2d3ff4f0024dd224290c0eabf0240f1bfc1f26363431505fb1b0283d3b08f11d", size = 152643, upload-time = "2026-03-06T02:52:42.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/42/30f0f2cefca9d9cbf6835f544d825064570203c3e70aa873d8ae12e23791/wrapt-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3278c471f4468ad544a691b31bb856374fbdefb7fee1a152153e64019379f015", size = 158805, upload-time = "2026-03-06T02:54:25.441Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/67/d08672f801f604889dcf58f1a0b424fe3808860ede9e03affc1876b295af/wrapt-2.1.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8914c754d3134a3032601c6984db1c576e6abaf3fc68094bb8ab1379d75ff92", size = 145990, upload-time = "2026-03-06T02:53:57.456Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/a7/fd371b02e73babec1de6ade596e8cd9691051058cfdadbfd62a5898f3295/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ff95d4264e55839be37bafe1536db2ab2de19da6b65f9244f01f332b5286cfbf", size = 155670, upload-time = "2026-03-06T02:54:55.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/2d/9fe0095dfdb621009f40117dcebf41d7396c2c22dca6eac779f4c007b86c/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:76405518ca4e1b76fbb1b9f686cff93aebae03920cc55ceeec48ff9f719c5f67", size = 144357, upload-time = "2026-03-06T02:54:24.092Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/b6/ec7b4a254abbe4cde9fa15c5d2cca4518f6b07d0f1b77d4ee9655e30280e/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c0be8b5a74c5824e9359b53e7e58bef71a729bacc82e16587db1c4ebc91f7c5a", size = 150269, upload-time = "2026-03-06T02:53:31.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/6b/2fabe8ebf148f4ee3c782aae86a795cc68ffe7d432ef550f234025ce0cfa/wrapt-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:f01277d9a5fc1862f26f7626da9cf443bebc0abd2f303f41c5e995b15887dabd", size = 59894, upload-time = "2026-03-06T02:54:15.391Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/fb/9ba66fc2dedc936de5f8073c0217b5d4484e966d87723415cc8262c5d9c2/wrapt-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:84ce8f1c2104d2f6daa912b1b5b039f331febfeee74f8042ad4e04992bd95c8f", size = 63197, upload-time = "2026-03-06T02:54:41.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/1c/012d7423c95d0e337117723eb8ecf73c622ce15a97847e84cf3f8f26cd7e/wrapt-2.1.2-cp313-cp313t-win_arm64.whl", hash = "sha256:a93cd767e37faeddbe07d8fc4212d5cba660af59bdb0f6372c93faaa13e6e679", size = 60363, upload-time = "2026-03-06T02:54:48.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/25/e7ea0b417db02bb796182a5316398a75792cd9a22528783d868755e1f669/wrapt-2.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1370e516598854e5b4366e09ce81e08bfe94d42b0fd569b88ec46cc56d9164a9", size = 61418, upload-time = "2026-03-06T02:53:55.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/0f/fa539e2f6a770249907757eaeb9a5ff4deb41c026f8466c1c6d799088a9b/wrapt-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6de1a3851c27e0bd6a04ca993ea6f80fc53e6c742ee1601f486c08e9f9b900a9", size = 61914, upload-time = "2026-03-06T02:52:53.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/37/02af1867f5b1441aaeda9c82deed061b7cd1372572ddcd717f6df90b5e93/wrapt-2.1.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:de9f1a2bbc5ac7f6012ec24525bdd444765a2ff64b5985ac6e0692144838542e", size = 120417, upload-time = "2026-03-06T02:54:30.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/b7/0138a6238c8ba7476c77cf786a807f871672b37f37a422970342308276e7/wrapt-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:970d57ed83fa040d8b20c52fe74a6ae7e3775ae8cff5efd6a81e06b19078484c", size = 122797, upload-time = "2026-03-06T02:54:51.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/ad/819ae558036d6a15b7ed290d5b14e209ca795dd4da9c58e50c067d5927b0/wrapt-2.1.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3969c56e4563c375861c8df14fa55146e81ac11c8db49ea6fb7f2ba58bc1ff9a", size = 117350, upload-time = "2026-03-06T02:54:37.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/2d/afc18dc57a4600a6e594f77a9ae09db54f55ba455440a54886694a84c71b/wrapt-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:57d7c0c980abdc5f1d98b11a2aa3bb159790add80258c717fa49a99921456d90", size = 121223, upload-time = "2026-03-06T02:54:35.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/5b/5ec189b22205697bc56eb3b62aed87a1e0423e9c8285d0781c7a83170d15/wrapt-2.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:776867878e83130c7a04237010463372e877c1c994d449ca6aaafeab6aab2586", size = 116287, upload-time = "2026-03-06T02:54:19.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/2d/f84939a7c9b5e6cdd8a8d0f6a26cabf36a0f7e468b967720e8b0cd2bdf69/wrapt-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fab036efe5464ec3291411fabb80a7a39e2dd80bae9bcbeeca5087fdfa891e19", size = 119593, upload-time = "2026-03-06T02:54:16.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/fe/ccd22a1263159c4ac811ab9374c061bcb4a702773f6e06e38de5f81a1bdc/wrapt-2.1.2-cp314-cp314-win32.whl", hash = "sha256:e6ed62c82ddf58d001096ae84ce7f833db97ae2263bff31c9b336ba8cfe3f508", size = 58631, upload-time = "2026-03-06T02:53:06.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/0a/6bd83be7bff2e7efaac7b4ac9748da9d75a34634bbbbc8ad077d527146df/wrapt-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:467e7c76315390331c67073073d00662015bb730c566820c9ca9b54e4d67fd04", size = 60875, upload-time = "2026-03-06T02:53:50.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/c0/0b3056397fe02ff80e5a5d72d627c11eb885d1ca78e71b1a5c1e8c7d45de/wrapt-2.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:da1f00a557c66225d53b095a97eace0fc5349e3bfda28fa34ffae238978ee575", size = 59164, upload-time = "2026-03-06T02:53:59.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/ed/5d89c798741993b2371396eb9d4634f009ff1ad8a6c78d366fe2883ea7a6/wrapt-2.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:62503ffbc2d3a69891cf29beeaccdb4d5e0a126e2b6a851688d4777e01428dbb", size = 63163, upload-time = "2026-03-06T02:52:54.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/8c/05d277d182bf36b0a13d6bd393ed1dec3468a25b59d01fba2dd70fe4d6ae/wrapt-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7e6cd120ef837d5b6f860a6ea3745f8763805c418bb2f12eeb1fa6e25f22d22", size = 63723, upload-time = "2026-03-06T02:52:56.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/27/6c51ec1eff4413c57e72d6106bb8dec6f0c7cdba6503d78f0fa98767bcc9/wrapt-2.1.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3769a77df8e756d65fbc050333f423c01ae012b4f6731aaf70cf2bef61b34596", size = 152652, upload-time = "2026-03-06T02:53:23.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/4c/d7dd662d6963fc7335bfe29d512b02b71cdfa23eeca7ab3ac74a67505deb/wrapt-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a76d61a2e851996150ba0f80582dd92a870643fa481f3b3846f229de88caf044", size = 158807, upload-time = "2026-03-06T02:53:35.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/4d/1e5eea1a78d539d346765727422976676615814029522c76b87a95f6bcdd/wrapt-2.1.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6f97edc9842cf215312b75fe737ee7c8adda75a89979f8e11558dfff6343cc4b", size = 146061, upload-time = "2026-03-06T02:52:57.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/bc/62cabea7695cd12a288023251eeefdcb8465056ddaab6227cb78a2de005b/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4006c351de6d5007aa33a551f600404ba44228a89e833d2fadc5caa5de8edfbf", size = 155667, upload-time = "2026-03-06T02:53:39.422Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/99/6f2888cd68588f24df3a76572c69c2de28287acb9e1972bf0c83ce97dbc1/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a9372fc3639a878c8e7d87e1556fa209091b0a66e912c611e3f833e2c4202be2", size = 144392, upload-time = "2026-03-06T02:54:22.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/51/1dfc783a6c57971614c48e361a82ca3b6da9055879952587bc99fe1a7171/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3144b027ff30cbd2fca07c0a87e67011adb717eb5f5bd8496325c17e454257a3", size = 150296, upload-time = "2026-03-06T02:54:07.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/38/cbb8b933a0201076c1f64fc42883b0023002bdc14a4964219154e6ff3350/wrapt-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:3b8d15e52e195813efe5db8cec156eebe339aaf84222f4f4f051a6c01f237ed7", size = 60539, upload-time = "2026-03-06T02:54:00.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/dd/e5176e4b241c9f528402cebb238a36785a628179d7d8b71091154b3e4c9e/wrapt-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:08ffa54146a7559f5b8df4b289b46d963a8e74ed16ba3687f99896101a3990c5", size = 63969, upload-time = "2026-03-06T02:54:39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/99/79f17046cf67e4a95b9987ea129632ba8bcec0bc81f3fb3d19bdb0bd60cd/wrapt-2.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:72aaa9d0d8e4ed0e2e98019cea47a21f823c9dd4b43c7b77bba6679ffcca6a00", size = 60554, upload-time = "2026-03-06T02:53:14.132Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user