dataforge-07 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dataforge/__init__.py +204 -0
- dataforge/__main__.py +5 -0
- dataforge/agent/__init__.py +16 -0
- dataforge/agent/providers.py +259 -0
- dataforge/agent/scratchpad.py +183 -0
- dataforge/agent/tool_actions.py +343 -0
- dataforge/bench/__init__.py +31 -0
- dataforge/bench/core.py +426 -0
- dataforge/bench/groq_client.py +386 -0
- dataforge/bench/methods.py +443 -0
- dataforge/bench/report.py +309 -0
- dataforge/bench/runner.py +247 -0
- dataforge/causal/__init__.py +21 -0
- dataforge/causal/dag.py +174 -0
- dataforge/causal/pc.py +232 -0
- dataforge/causal/root_cause.py +193 -0
- dataforge/cli/__init__.py +50 -0
- dataforge/cli/audit.py +70 -0
- dataforge/cli/bench.py +154 -0
- dataforge/cli/common.py +267 -0
- dataforge/cli/constraints.py +407 -0
- dataforge/cli/profile.py +147 -0
- dataforge/cli/release.py +166 -0
- dataforge/cli/repair.py +407 -0
- dataforge/cli/revert.py +139 -0
- dataforge/cli/watch.py +144 -0
- dataforge/datasets/__init__.py +25 -0
- dataforge/datasets/embedded/hospital/clean.csv +11 -0
- dataforge/datasets/embedded/hospital/dirty.csv +11 -0
- dataforge/datasets/real_world.py +290 -0
- dataforge/datasets/registry.py +103 -0
- dataforge/detectors/__init__.py +80 -0
- dataforge/detectors/base.py +145 -0
- dataforge/detectors/decimal_shift.py +166 -0
- dataforge/detectors/fd_violation.py +157 -0
- dataforge/detectors/type_mismatch.py +173 -0
- dataforge/engine/__init__.py +39 -0
- dataforge/engine/repair.py +905 -0
- dataforge/env/__init__.py +22 -0
- dataforge/env/environment.py +883 -0
- dataforge/env/observation.py +61 -0
- dataforge/env/openenv_core.py +161 -0
- dataforge/env/reward.py +128 -0
- dataforge/env/server.py +176 -0
- dataforge/evaluation_contract.py +76 -0
- dataforge/fixtures/hospital_10rows.csv +11 -0
- dataforge/fixtures/hospital_schema.yaml +17 -0
- dataforge/http/__init__.py +1 -0
- dataforge/http/problem.py +103 -0
- dataforge/integrations/__init__.py +1 -0
- dataforge/integrations/dbt.py +164 -0
- dataforge/observability.py +76 -0
- dataforge/py.typed +1 -0
- dataforge/release/__init__.py +1 -0
- dataforge/release/doctor.py +367 -0
- dataforge/release/full_vision.py +702 -0
- dataforge/release/gate.py +861 -0
- dataforge/release/playground_check.py +411 -0
- dataforge/repair_contract.py +468 -0
- dataforge/repairers/__init__.py +88 -0
- dataforge/repairers/base.py +77 -0
- dataforge/repairers/decimal_shift.py +43 -0
- dataforge/repairers/fd_violation.py +225 -0
- dataforge/repairers/type_mismatch.py +73 -0
- dataforge/safety/__init__.py +5 -0
- dataforge/safety/adversarial/attack_01_phone_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_02_phone_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_03_phone_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_04_phone_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_05_phone_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_06_phone_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_07_phone_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_08_phone_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_09_phone_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_10_phone_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_11_ssn_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_12_ssn_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_13_ssn_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_14_ssn_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_15_ssn_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_16_ssn_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_17_ssn_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_18_ssn_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_19_ssn_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_20_ssn_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_21_email_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_22_email_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_23_email_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_24_email_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_25_email_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_26_email_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_27_email_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_28_email_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_29_email_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_30_email_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_31_row_delete.yaml +7 -0
- dataforge/safety/adversarial/attack_32_row_delete.yaml +8 -0
- dataforge/safety/adversarial/attack_33_row_delete.yaml +7 -0
- dataforge/safety/adversarial/attack_34_row_delete.yaml +7 -0
- dataforge/safety/adversarial/attack_35_row_delete.yaml +7 -0
- dataforge/safety/adversarial/attack_36_row_delete.yaml +11 -0
- dataforge/safety/adversarial/attack_37_row_delete.yaml +7 -0
- dataforge/safety/adversarial/attack_38_row_delete.yaml +7 -0
- dataforge/safety/adversarial/attack_39_row_delete.yaml +8 -0
- dataforge/safety/adversarial/attack_40_row_delete.yaml +7 -0
- dataforge/safety/adversarial/attack_41_row_delete.yaml +7 -0
- dataforge/safety/adversarial/attack_42_row_delete.yaml +7 -0
- dataforge/safety/adversarial/attack_43_row_delete.yaml +7 -0
- dataforge/safety/adversarial/attack_44_row_delete.yaml +7 -0
- dataforge/safety/adversarial/attack_45_row_delete.yaml +8 -0
- dataforge/safety/adversarial/attack_46_row_delete.yaml +8 -0
- dataforge/safety/adversarial/attack_47_row_delete.yaml +7 -0
- dataforge/safety/adversarial/attack_48_row_delete.yaml +7 -0
- dataforge/safety/adversarial/attack_49_row_delete.yaml +8 -0
- dataforge/safety/adversarial/attack_50_row_delete.yaml +7 -0
- dataforge/safety/constitution.py +307 -0
- dataforge/safety/constitutions/default.yaml +40 -0
- dataforge/safety/filter.py +134 -0
- dataforge/schema_inference.py +620 -0
- dataforge/stores/__init__.py +46 -0
- dataforge/stores/base.py +73 -0
- dataforge/stores/cloud.py +78 -0
- dataforge/stores/csv.py +94 -0
- dataforge/stores/duckdb.py +313 -0
- dataforge/stores/patch_plan.py +178 -0
- dataforge/stores/registry.py +82 -0
- dataforge/stores/repair.py +121 -0
- dataforge/stores/revert.py +22 -0
- dataforge/stores/sql.py +27 -0
- dataforge/table.py +228 -0
- dataforge/transactions/__init__.py +34 -0
- dataforge/transactions/files.py +96 -0
- dataforge/transactions/log.py +613 -0
- dataforge/transactions/revert.py +102 -0
- dataforge/transactions/txn.py +104 -0
- dataforge/ui/__init__.py +1 -0
- dataforge/ui/profile_view.py +136 -0
- dataforge/ui/repair_diff.py +91 -0
- dataforge/verifier/__init__.py +55 -0
- dataforge/verifier/constraint_ir.py +155 -0
- dataforge/verifier/explain.py +47 -0
- dataforge/verifier/gate.py +5 -0
- dataforge/verifier/schema.py +111 -0
- dataforge/verifier/smt.py +433 -0
- dataforge_07-0.1.0.dist-info/METADATA +436 -0
- dataforge_07-0.1.0.dist-info/RECORD +150 -0
- dataforge_07-0.1.0.dist-info/WHEEL +5 -0
- dataforge_07-0.1.0.dist-info/entry_points.txt +3 -0
- dataforge_07-0.1.0.dist-info/licenses/LICENSE +176 -0
- dataforge_07-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""RFC 9457 problem details helpers for FastAPI surfaces."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from fastapi import HTTPException, Request
|
|
9
|
+
from fastapi.responses import JSONResponse
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ProblemDetail(BaseModel):
|
|
14
|
+
"""RFC 9457 problem detail response with extension members."""
|
|
15
|
+
|
|
16
|
+
type: str = Field(default="about:blank")
|
|
17
|
+
title: str
|
|
18
|
+
status: int
|
|
19
|
+
detail: str
|
|
20
|
+
instance: str | None = None
|
|
21
|
+
|
|
22
|
+
model_config = ConfigDict(strict=True, extra="allow")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def problem_body(
|
|
26
|
+
*,
|
|
27
|
+
status: int,
|
|
28
|
+
title: str,
|
|
29
|
+
detail: str,
|
|
30
|
+
type_: str = "about:blank",
|
|
31
|
+
instance: str | None = None,
|
|
32
|
+
**extensions: Any,
|
|
33
|
+
) -> dict[str, Any]:
|
|
34
|
+
"""Build a problem details JSON object."""
|
|
35
|
+
body = ProblemDetail(
|
|
36
|
+
type=type_,
|
|
37
|
+
title=title,
|
|
38
|
+
status=status,
|
|
39
|
+
detail=detail,
|
|
40
|
+
instance=instance,
|
|
41
|
+
**extensions,
|
|
42
|
+
)
|
|
43
|
+
return body.model_dump(mode="json", exclude_none=True)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def problem_response(
|
|
47
|
+
*,
|
|
48
|
+
status: int,
|
|
49
|
+
title: str,
|
|
50
|
+
detail: str,
|
|
51
|
+
type_: str = "about:blank",
|
|
52
|
+
instance: str | None = None,
|
|
53
|
+
headers: Mapping[str, str] | None = None,
|
|
54
|
+
**extensions: Any,
|
|
55
|
+
) -> JSONResponse:
|
|
56
|
+
"""Return an RFC 9457 JSON response."""
|
|
57
|
+
return JSONResponse(
|
|
58
|
+
status_code=status,
|
|
59
|
+
content=problem_body(
|
|
60
|
+
status=status,
|
|
61
|
+
title=title,
|
|
62
|
+
detail=detail,
|
|
63
|
+
type_=type_,
|
|
64
|
+
instance=instance,
|
|
65
|
+
**extensions,
|
|
66
|
+
),
|
|
67
|
+
headers=headers,
|
|
68
|
+
media_type="application/problem+json",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
|
|
73
|
+
"""Normalize FastAPI HTTPException values into problem details."""
|
|
74
|
+
raw_detail = exc.detail
|
|
75
|
+
extensions: dict[str, Any] = {}
|
|
76
|
+
if isinstance(raw_detail, dict):
|
|
77
|
+
error_code = str(raw_detail.get("error", "http_error"))
|
|
78
|
+
message = str(raw_detail.get("message") or raw_detail.get("detail") or error_code)
|
|
79
|
+
extensions.update(raw_detail)
|
|
80
|
+
else:
|
|
81
|
+
error_code = "http_error"
|
|
82
|
+
message = str(raw_detail)
|
|
83
|
+
|
|
84
|
+
request_id = getattr(request.state, "dataforge_request_id", None)
|
|
85
|
+
if isinstance(request_id, str) and request_id and "request_id" not in extensions:
|
|
86
|
+
extensions["request_id"] = request_id
|
|
87
|
+
|
|
88
|
+
return problem_response(
|
|
89
|
+
status=exc.status_code,
|
|
90
|
+
type_=f"https://dataforge.local/problems/{error_code}",
|
|
91
|
+
title=error_code.replace("_", " ").title(),
|
|
92
|
+
detail=message,
|
|
93
|
+
instance=str(request.url.path),
|
|
94
|
+
headers=exc.headers,
|
|
95
|
+
**extensions,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def problem_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
|
100
|
+
"""Adapter with the broad exception signature Starlette expects."""
|
|
101
|
+
if isinstance(exc, HTTPException):
|
|
102
|
+
return await http_exception_handler(request, exc)
|
|
103
|
+
raise exc
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Integration package scaffolding for DataForge."""
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""dbt artifact helpers for DataForge's local repair contract.
|
|
2
|
+
|
|
3
|
+
The external ``dataforge_07_dbt`` distribution can call these helpers without
|
|
4
|
+
duplicating dbt manifest parsing rules. Only dbt generic tests that DataForge
|
|
5
|
+
can represent as local constraints are mapped; everything else is ignored
|
|
6
|
+
conservatively.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from dataforge.verifier.schema import AcceptedValues, RelationshipConstraint, Schema
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def schema_from_dbt_artifacts(
|
|
20
|
+
manifest_path: Path,
|
|
21
|
+
*,
|
|
22
|
+
model_name: str | None = None,
|
|
23
|
+
model_unique_id: str | None = None,
|
|
24
|
+
) -> Schema:
|
|
25
|
+
"""Load a dbt manifest and map supported generic tests into a ``Schema``."""
|
|
26
|
+
payload = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
27
|
+
if not isinstance(payload, dict):
|
|
28
|
+
raise ValueError("dbt manifest must be a JSON object.")
|
|
29
|
+
return schema_from_dbt_manifest(
|
|
30
|
+
payload,
|
|
31
|
+
model_name=model_name,
|
|
32
|
+
model_unique_id=model_unique_id,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def schema_from_dbt_manifest(
|
|
37
|
+
manifest: Mapping[str, Any],
|
|
38
|
+
*,
|
|
39
|
+
model_name: str | None = None,
|
|
40
|
+
model_unique_id: str | None = None,
|
|
41
|
+
) -> Schema:
|
|
42
|
+
"""Map supported dbt generic tests from a manifest into DataForge constraints.
|
|
43
|
+
|
|
44
|
+
Supported dbt tests:
|
|
45
|
+
- ``not_null`` -> ``Schema.not_null_columns``
|
|
46
|
+
- ``unique`` -> ``Schema.unique_columns``
|
|
47
|
+
- ``accepted_values`` -> ``Schema.accepted_values``
|
|
48
|
+
- ``relationships`` -> ``Schema.relationships``
|
|
49
|
+
"""
|
|
50
|
+
target_model = _resolve_target_model(manifest, model_name, model_unique_id)
|
|
51
|
+
nodes = _mapping(manifest.get("nodes"))
|
|
52
|
+
columns = _model_columns(target_model)
|
|
53
|
+
not_null_columns: set[str] = set()
|
|
54
|
+
unique_columns: set[str] = set()
|
|
55
|
+
accepted_values: list[AcceptedValues] = []
|
|
56
|
+
relationships: list[RelationshipConstraint] = []
|
|
57
|
+
|
|
58
|
+
for raw_node in nodes.values():
|
|
59
|
+
node = _mapping(raw_node)
|
|
60
|
+
if node.get("resource_type") != "test":
|
|
61
|
+
continue
|
|
62
|
+
if target_model is not None and not _test_depends_on_model(node, target_model):
|
|
63
|
+
continue
|
|
64
|
+
metadata = _mapping(node.get("test_metadata"))
|
|
65
|
+
test_name = str(metadata.get("name", node.get("name", ""))).strip()
|
|
66
|
+
kwargs = _mapping(metadata.get("kwargs"))
|
|
67
|
+
column = _column_name(kwargs, node)
|
|
68
|
+
if not column:
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
if test_name == "not_null":
|
|
72
|
+
not_null_columns.add(column)
|
|
73
|
+
elif test_name == "unique":
|
|
74
|
+
unique_columns.add(column)
|
|
75
|
+
elif test_name == "accepted_values":
|
|
76
|
+
values = _accepted_values(kwargs)
|
|
77
|
+
if values:
|
|
78
|
+
accepted_values.append(AcceptedValues(column=column, values=values))
|
|
79
|
+
elif test_name == "relationships":
|
|
80
|
+
reference = str(kwargs.get("to", "")).strip()
|
|
81
|
+
reference_column = str(kwargs.get("field", "")).strip()
|
|
82
|
+
if reference and reference_column:
|
|
83
|
+
relationships.append(
|
|
84
|
+
RelationshipConstraint(
|
|
85
|
+
column=column,
|
|
86
|
+
reference=reference,
|
|
87
|
+
reference_column=reference_column,
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
return Schema(
|
|
92
|
+
columns=columns,
|
|
93
|
+
not_null_columns=frozenset(not_null_columns),
|
|
94
|
+
unique_columns=frozenset(unique_columns),
|
|
95
|
+
accepted_values=tuple(accepted_values),
|
|
96
|
+
relationships=tuple(relationships),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _mapping(value: object) -> Mapping[str, Any]:
|
|
101
|
+
"""Return a mapping view for JSON objects, or an empty mapping."""
|
|
102
|
+
return value if isinstance(value, Mapping) else {}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _resolve_target_model(
|
|
106
|
+
manifest: Mapping[str, Any],
|
|
107
|
+
model_name: str | None,
|
|
108
|
+
model_unique_id: str | None,
|
|
109
|
+
) -> Mapping[str, Any] | None:
|
|
110
|
+
"""Resolve the optional target model node from a dbt manifest."""
|
|
111
|
+
if model_name is None and model_unique_id is None:
|
|
112
|
+
return None
|
|
113
|
+
nodes = _mapping(manifest.get("nodes"))
|
|
114
|
+
for unique_id, raw_node in nodes.items():
|
|
115
|
+
node = _mapping(raw_node)
|
|
116
|
+
if node.get("resource_type") != "model":
|
|
117
|
+
continue
|
|
118
|
+
if model_unique_id is not None and str(unique_id) == model_unique_id:
|
|
119
|
+
return {**node, "unique_id": str(unique_id)}
|
|
120
|
+
if model_name is not None and str(node.get("name", "")) == model_name:
|
|
121
|
+
return {**node, "unique_id": str(unique_id)}
|
|
122
|
+
raise ValueError("Requested dbt model was not found in manifest.")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _model_columns(model_node: Mapping[str, Any] | None) -> dict[str, str]:
|
|
126
|
+
"""Extract declared column types from a dbt model node when available."""
|
|
127
|
+
if model_node is None:
|
|
128
|
+
return {}
|
|
129
|
+
columns: dict[str, str] = {}
|
|
130
|
+
for column_name, raw_column in _mapping(model_node.get("columns")).items():
|
|
131
|
+
column = _mapping(raw_column)
|
|
132
|
+
raw_type = column.get("data_type") or column.get("type")
|
|
133
|
+
if raw_type:
|
|
134
|
+
columns[str(column_name)] = str(raw_type)
|
|
135
|
+
return columns
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _test_depends_on_model(
|
|
139
|
+
test_node: Mapping[str, Any],
|
|
140
|
+
model_node: Mapping[str, Any],
|
|
141
|
+
) -> bool:
|
|
142
|
+
"""Return whether a dbt test node depends on the target model."""
|
|
143
|
+
target_unique_id = str(model_node.get("unique_id", ""))
|
|
144
|
+
if not target_unique_id:
|
|
145
|
+
target_unique_id = str(model_node.get("name", ""))
|
|
146
|
+
depends_on = _mapping(test_node.get("depends_on"))
|
|
147
|
+
raw_nodes = depends_on.get("nodes", [])
|
|
148
|
+
if not isinstance(raw_nodes, list):
|
|
149
|
+
return False
|
|
150
|
+
return target_unique_id in {str(node) for node in raw_nodes}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _column_name(kwargs: Mapping[str, Any], test_node: Mapping[str, Any]) -> str:
|
|
154
|
+
"""Extract the tested column name from dbt metadata variants."""
|
|
155
|
+
raw_column = kwargs.get("column_name") or kwargs.get("field") or test_node.get("column_name")
|
|
156
|
+
return str(raw_column or "").strip()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _accepted_values(kwargs: Mapping[str, Any]) -> tuple[str, ...]:
|
|
160
|
+
"""Extract dbt accepted_values values as a stable tuple of strings."""
|
|
161
|
+
raw_values = kwargs.get("values", [])
|
|
162
|
+
if not isinstance(raw_values, list):
|
|
163
|
+
return ()
|
|
164
|
+
return tuple(str(value) for value in raw_values)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Optional OpenTelemetry hooks for DataForge backend surfaces."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from contextlib import contextmanager, nullcontext
|
|
8
|
+
from importlib import import_module
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
_SENSITIVE_ATTR_FRAGMENTS = ("authorization", "cookie", "token", "key", "secret", "password")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _otel_enabled() -> bool:
|
|
15
|
+
"""Return whether optional OpenTelemetry instrumentation is enabled."""
|
|
16
|
+
return os.environ.get("DATAFORGE_OTEL_ENABLED", "").strip().lower() in {
|
|
17
|
+
"1",
|
|
18
|
+
"true",
|
|
19
|
+
"yes",
|
|
20
|
+
"on",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _safe_attrs(attributes: dict[str, Any]) -> dict[str, str | int | float | bool]:
|
|
25
|
+
"""Keep only scalar, non-sensitive telemetry attributes."""
|
|
26
|
+
safe: dict[str, str | int | float | bool] = {}
|
|
27
|
+
for key, value in attributes.items():
|
|
28
|
+
lowered = key.lower()
|
|
29
|
+
if any(fragment in lowered for fragment in _SENSITIVE_ATTR_FRAGMENTS):
|
|
30
|
+
continue
|
|
31
|
+
if lowered in {"row_values", "rows", "payload", "source_bytes", "csv"}:
|
|
32
|
+
continue
|
|
33
|
+
if isinstance(value, str | int | float | bool):
|
|
34
|
+
safe[key] = value
|
|
35
|
+
return safe
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def configure_fastapi_observability(app: Any, *, service_name: str) -> bool:
|
|
39
|
+
"""Instrument a FastAPI app when OpenTelemetry is explicitly enabled."""
|
|
40
|
+
if not _otel_enabled():
|
|
41
|
+
return False
|
|
42
|
+
try:
|
|
43
|
+
fastapi_instrumentation = import_module("opentelemetry.instrumentation.fastapi")
|
|
44
|
+
trace_module = import_module("opentelemetry.trace")
|
|
45
|
+
except ImportError:
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
app.state.dataforge_service_name = service_name
|
|
49
|
+
fastapi_instrumentation.FastAPIInstrumentor.instrument_app(
|
|
50
|
+
app,
|
|
51
|
+
tracer_provider=trace_module.get_tracer_provider(),
|
|
52
|
+
excluded_urls="/api/docs,/docs,/redoc,/openapi.json",
|
|
53
|
+
)
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@contextmanager
|
|
58
|
+
def repair_stage_span(stage: str, **attributes: Any) -> Iterator[None]:
|
|
59
|
+
"""Create a repair-stage span when OpenTelemetry is available."""
|
|
60
|
+
if not _otel_enabled():
|
|
61
|
+
with nullcontext():
|
|
62
|
+
yield
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
trace_module = import_module("opentelemetry.trace")
|
|
67
|
+
except ImportError:
|
|
68
|
+
with nullcontext():
|
|
69
|
+
yield
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
tracer = trace_module.get_tracer("dataforge.repair")
|
|
73
|
+
with tracer.start_as_current_span(stage) as span:
|
|
74
|
+
for key, value in _safe_attrs(attributes).items():
|
|
75
|
+
span.set_attribute(key, value)
|
|
76
|
+
yield
|
dataforge/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Release verification helpers for DataForge."""
|