querycleaner 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.
- qc/__init__.py +4 -0
- qc/application/__init__.py +1 -0
- qc/application/cancellation.py +54 -0
- qc/application/exports.py +128 -0
- qc/application/generation.py +439 -0
- qc/application/job_runner.py +163 -0
- qc/application/jobs.py +221 -0
- qc/application/runtime.py +105 -0
- qc/application/sanitize.py +318 -0
- qc/application/sources.py +79 -0
- qc/artifacts/__init__.py +1 -0
- qc/artifacts/profiles.py +104 -0
- qc/cli/__init__.py +1 -0
- qc/cli/app.py +1222 -0
- qc/cli/present.py +102 -0
- qc/config/__init__.py +29 -0
- qc/config/loader.py +199 -0
- qc/config/policies.py +158 -0
- qc/connectors/__init__.py +1 -0
- qc/connectors/base.py +178 -0
- qc/connectors/fixture.py +364 -0
- qc/connectors/google_sheets.py +503 -0
- qc/connectors/lark_sheets.py +441 -0
- qc/connectors/maxcompute.py +421 -0
- qc/connectors/mysql.py +572 -0
- qc/connectors/pool.py +113 -0
- qc/connectors/postgres.py +596 -0
- qc/connectors/registry.py +76 -0
- qc/connectors/resilience.py +128 -0
- qc/connectors/sheet_normalize.py +315 -0
- qc/connectors/sql_common.py +235 -0
- qc/connectors/sqlite_conn.py +733 -0
- qc/domain/__init__.py +139 -0
- qc/domain/cancellation.py +50 -0
- qc/domain/enums.py +262 -0
- qc/domain/errors.py +116 -0
- qc/domain/models.py +551 -0
- qc/domain/predicate.py +174 -0
- qc/exports/__init__.py +1 -0
- qc/exports/csv_writer.py +65 -0
- qc/exports/db_writer.py +232 -0
- qc/exports/jsonl.py +70 -0
- qc/generation/__init__.py +1 -0
- qc/generation/strategies.py +410 -0
- qc/generation/validation.py +247 -0
- qc/jdbc_bridge/__init__.py +22 -0
- qc/jdbc_bridge/client.py +370 -0
- qc/jdbc_bridge/protocol.py +178 -0
- qc/jdbc_bridge/sidecar/BUILD.md +62 -0
- qc/jdbc_bridge/sidecar/driver-lock.json +11 -0
- qc/jdbc_bridge/sidecar/src/qc/jdbc/Main.java +535 -0
- qc/mcp/__init__.py +1 -0
- qc/mcp/__main__.py +40 -0
- qc/mcp/metadata.py +155 -0
- qc/mcp/schemas.py +257 -0
- qc/mcp/server.py +222 -0
- qc/mcp/service.py +666 -0
- qc/observability/__init__.py +5 -0
- qc/observability/logging.py +138 -0
- qc/observability/metrics.py +132 -0
- qc/profiling/__init__.py +1 -0
- qc/profiling/engine.py +902 -0
- qc/profiling/falsy.py +166 -0
- qc/profiling/relationships.py +98 -0
- qc/profiling/type_inference.py +371 -0
- qc/sanitization/__init__.py +1 -0
- qc/sanitization/actions.py +140 -0
- qc/security/__init__.py +45 -0
- qc/security/audit.py +106 -0
- qc/security/policy.py +264 -0
- qc/security/redaction.py +80 -0
- qc/security/secrets.py +158 -0
- qc/security/sensitivity.py +291 -0
- querycleaner-0.1.0.dist-info/METADATA +645 -0
- querycleaner-0.1.0.dist-info/RECORD +78 -0
- querycleaner-0.1.0.dist-info/WHEEL +4 -0
- querycleaner-0.1.0.dist-info/entry_points.txt +2 -0
- querycleaner-0.1.0.dist-info/licenses/LICENSE +202 -0
qc/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""QC application package."""
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Process-wide registry of cancellable jobs (SPEC 12.6, 15).
|
|
2
|
+
|
|
3
|
+
Maps job ids to :class:`~qc.domain.cancellation.CancellationToken` so
|
|
4
|
+
MCP ``cancel_job`` and CLI signal handlers can stop a running operation
|
|
5
|
+
without holding a reference to its coroutine.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import threading
|
|
11
|
+
|
|
12
|
+
from qc.domain.cancellation import CancellationToken
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CancellationRegistry:
|
|
16
|
+
"""Thread-safe registry; a cancellation from any thread is observed."""
|
|
17
|
+
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self._lock = threading.Lock()
|
|
20
|
+
self._tokens: dict[str, CancellationToken] = {}
|
|
21
|
+
|
|
22
|
+
def register(self, job_id: str) -> CancellationToken:
|
|
23
|
+
token = CancellationToken()
|
|
24
|
+
with self._lock:
|
|
25
|
+
self._tokens[job_id] = token
|
|
26
|
+
return token
|
|
27
|
+
|
|
28
|
+
def get(self, job_id: str) -> CancellationToken | None:
|
|
29
|
+
with self._lock:
|
|
30
|
+
return self._tokens.get(job_id)
|
|
31
|
+
|
|
32
|
+
def cancel(self, job_id: str, reason: str | None = None) -> bool:
|
|
33
|
+
"""Cancel a registered job; returns False if it is not running."""
|
|
34
|
+
with self._lock:
|
|
35
|
+
token = self._tokens.get(job_id)
|
|
36
|
+
if token is None:
|
|
37
|
+
return False
|
|
38
|
+
token.cancel(reason)
|
|
39
|
+
return True
|
|
40
|
+
|
|
41
|
+
def unregister(self, job_id: str) -> None:
|
|
42
|
+
with self._lock:
|
|
43
|
+
self._tokens.pop(job_id, None)
|
|
44
|
+
|
|
45
|
+
def running(self) -> list[str]:
|
|
46
|
+
with self._lock:
|
|
47
|
+
return [jid for jid, tok in self._tokens.items() if not tok.cancelled]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
#: Process-wide registry shared by the CLI and MCP server.
|
|
51
|
+
REGISTRY = CancellationRegistry()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
__all__ = ["REGISTRY", "CancellationRegistry"]
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Database destination export (SPEC 10.3).
|
|
2
|
+
|
|
3
|
+
Separate source/destination configs; approved destinations only;
|
|
4
|
+
create-new-table by default; append/replace require explicit mode.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from qc.domain.enums import OperationKind
|
|
14
|
+
from qc.domain.errors import PolicyDeniedError, ValidationError, WriteError
|
|
15
|
+
from qc.security.policy import DecisionContext, Operation
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from qc.application.runtime import Runtime
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def export_to_database(
|
|
22
|
+
rt: Runtime,
|
|
23
|
+
*,
|
|
24
|
+
output: Path,
|
|
25
|
+
destination: str,
|
|
26
|
+
table: str,
|
|
27
|
+
policy_name: str | None = None,
|
|
28
|
+
mode: str = "create",
|
|
29
|
+
) -> dict[str, Any]:
|
|
30
|
+
"""Write a JSONL/CSV file to an explicitly approved destination."""
|
|
31
|
+
# argument validation first (cheap, no I/O), then policy gates
|
|
32
|
+
if mode not in ("create", "append", "replace"):
|
|
33
|
+
raise ValidationError(f"invalid mode '{mode}'; use create|append|replace")
|
|
34
|
+
if not table.isidentifier() and not all(p.isidentifier() for p in table.split(".")):
|
|
35
|
+
raise ValidationError(f"invalid table name: {table}")
|
|
36
|
+
|
|
37
|
+
engine = rt.engine(policy_name)
|
|
38
|
+
policy = engine.policy
|
|
39
|
+
ctx = DecisionContext(source=destination, dataset=table)
|
|
40
|
+
engine.require(Operation.WRITE, ctx)
|
|
41
|
+
engine.require(Operation.EXPORT, ctx)
|
|
42
|
+
|
|
43
|
+
if not policy.write_allowed:
|
|
44
|
+
raise PolicyDeniedError("policy does not allow writes (write_allowed=false)")
|
|
45
|
+
|
|
46
|
+
dest_cfg = rt.source(destination)
|
|
47
|
+
rows = _read_rows(output)
|
|
48
|
+
if not rows:
|
|
49
|
+
raise ValidationError("output file contains no rows")
|
|
50
|
+
|
|
51
|
+
# destination role is a *separate* writer (SPEC 6, 10.3): source
|
|
52
|
+
# connectors never gain write_rows by configuration
|
|
53
|
+
writer = _destination_writer(dest_cfg)
|
|
54
|
+
try:
|
|
55
|
+
columns = list(rows[0].keys())
|
|
56
|
+
written = await writer.write_rows(dest_cfg, table, columns, rows, mode=mode)
|
|
57
|
+
finally:
|
|
58
|
+
await writer.close()
|
|
59
|
+
|
|
60
|
+
rt.audit.record(
|
|
61
|
+
OperationKind.EXPORT_DATABASE,
|
|
62
|
+
source_id=dest_cfg.id,
|
|
63
|
+
dataset_id=table,
|
|
64
|
+
policy_version=policy.version,
|
|
65
|
+
decision="allow",
|
|
66
|
+
rows_affected=written,
|
|
67
|
+
result_status="ok",
|
|
68
|
+
detail={"mode": mode},
|
|
69
|
+
)
|
|
70
|
+
return {
|
|
71
|
+
"destination": destination,
|
|
72
|
+
"table": table,
|
|
73
|
+
"rows": written,
|
|
74
|
+
"mode": mode,
|
|
75
|
+
"columns": len(rows[0]),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _destination_writer(config: Any) -> Any:
|
|
80
|
+
"""Return the writable destination role for a source config."""
|
|
81
|
+
kind = config.connector
|
|
82
|
+
if kind == "postgres":
|
|
83
|
+
from qc.exports.db_writer import PostgresDestination
|
|
84
|
+
|
|
85
|
+
return PostgresDestination()
|
|
86
|
+
if kind == "mysql":
|
|
87
|
+
from qc.exports.db_writer import MySQLDestination
|
|
88
|
+
|
|
89
|
+
return MySQLDestination()
|
|
90
|
+
raise WriteError(
|
|
91
|
+
f"connector '{kind}' does not support database destination writes",
|
|
92
|
+
detail={"destination": config.name},
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _read_rows(path: Path) -> list[dict[str, Any]]:
|
|
97
|
+
if not path.is_file():
|
|
98
|
+
raise WriteError(f"output file not found: {path.name}")
|
|
99
|
+
rows: list[dict[str, Any]] = []
|
|
100
|
+
if path.suffix == ".csv":
|
|
101
|
+
import csv
|
|
102
|
+
|
|
103
|
+
with path.open(encoding="utf-8", newline="") as fh:
|
|
104
|
+
for raw in csv.DictReader(fh):
|
|
105
|
+
rows.append({k: _coerce(v) for k, v in raw.items()})
|
|
106
|
+
else:
|
|
107
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
108
|
+
line = line.strip()
|
|
109
|
+
if line:
|
|
110
|
+
obj = json.loads(line)
|
|
111
|
+
if isinstance(obj, dict):
|
|
112
|
+
rows.append(obj)
|
|
113
|
+
return rows
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _coerce(v: str) -> Any:
|
|
117
|
+
if v == "\\N":
|
|
118
|
+
return None
|
|
119
|
+
if v in ("true", "false"):
|
|
120
|
+
return v == "true"
|
|
121
|
+
try:
|
|
122
|
+
return int(v)
|
|
123
|
+
except ValueError:
|
|
124
|
+
pass
|
|
125
|
+
try:
|
|
126
|
+
return float(v)
|
|
127
|
+
except ValueError:
|
|
128
|
+
return v
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
"""Generation use cases: plan, run, validate (SPEC sections 9, 3.3)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import random
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
from qc.artifacts.profiles import find_profile, load_profile
|
|
12
|
+
from qc.domain.cancellation import CancellationToken
|
|
13
|
+
from qc.domain.enums import OperationKind
|
|
14
|
+
from qc.domain.errors import GenerationError, NotFoundError, ValidationError
|
|
15
|
+
from qc.domain.models import GenerationPlan, GenerationReport, StrategyOverride
|
|
16
|
+
from qc.generation.strategies import GenerationContext, execute, resolve_strategy
|
|
17
|
+
from qc.generation.validation import validate_generation
|
|
18
|
+
from qc.observability.metrics import OperationMetrics
|
|
19
|
+
from qc.security.policy import DecisionContext, Operation
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from qc.application.runtime import Runtime
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def create_plan(
|
|
26
|
+
rt: Runtime,
|
|
27
|
+
*,
|
|
28
|
+
profile_ref: str,
|
|
29
|
+
rows: int,
|
|
30
|
+
seed: int = 0,
|
|
31
|
+
locale: str = "en_US",
|
|
32
|
+
policy_name: str | None = None,
|
|
33
|
+
output: Path | None = None,
|
|
34
|
+
allow_fallbacks: bool = False,
|
|
35
|
+
strategy_overrides: dict[str, str] | None = None,
|
|
36
|
+
) -> GenerationPlan:
|
|
37
|
+
"""Build a GenerationPlan from a saved profile after policy checks.
|
|
38
|
+
|
|
39
|
+
``strategy_overrides`` maps column -> strategy name (SPEC 9.1 step 1:
|
|
40
|
+
explicit user-defined strategies win over the registry).
|
|
41
|
+
"""
|
|
42
|
+
engine = rt.engine(policy_name)
|
|
43
|
+
path = rt.state.find_profile_index(profile_ref) or find_profile(rt.paths.profiles, profile_ref)
|
|
44
|
+
artifact = load_profile(path)
|
|
45
|
+
# policy keys are source *names*; artifacts store opaque ids
|
|
46
|
+
src_cfg = rt.sources.get_by_id(artifact.source_id)
|
|
47
|
+
if src_cfg is None:
|
|
48
|
+
raise NotFoundError(
|
|
49
|
+
"profile's source is not configured in this project "
|
|
50
|
+
"(sources may have been re-created); re-run profiling"
|
|
51
|
+
)
|
|
52
|
+
ctx = DecisionContext(source=src_cfg.name, dataset=artifact.dataset.qualified_name)
|
|
53
|
+
engine.require(Operation.DATASET_READ, ctx)
|
|
54
|
+
|
|
55
|
+
if rows > rt.ceiling.max_rows:
|
|
56
|
+
raise ValidationError(
|
|
57
|
+
f"requested {rows} rows exceeds ceiling {rt.ceiling.max_rows}",
|
|
58
|
+
)
|
|
59
|
+
known_columns = {c.name for c in artifact.columns}
|
|
60
|
+
overrides = [
|
|
61
|
+
StrategyOverride(column=col, strategy=strat)
|
|
62
|
+
for col, strat in (strategy_overrides or {}).items()
|
|
63
|
+
]
|
|
64
|
+
for ov in overrides:
|
|
65
|
+
if ov.column not in known_columns:
|
|
66
|
+
raise ValidationError(
|
|
67
|
+
f"strategy override names unknown column '{ov.column}'",
|
|
68
|
+
detail={"column": ov.column},
|
|
69
|
+
)
|
|
70
|
+
plan = GenerationPlan(
|
|
71
|
+
profile_ids=[artifact.profile_id],
|
|
72
|
+
dataset=artifact.dataset,
|
|
73
|
+
rows=rows,
|
|
74
|
+
seed=seed,
|
|
75
|
+
locale=locale,
|
|
76
|
+
output_format=(output.suffix.lstrip(".") if output else "jsonl"),
|
|
77
|
+
allow_fallbacks=allow_fallbacks,
|
|
78
|
+
strategy_overrides=overrides,
|
|
79
|
+
budget=artifact.budget,
|
|
80
|
+
)
|
|
81
|
+
plan_path = rt.paths.reports / f"{plan.plan_id}.plan.json"
|
|
82
|
+
plan_path.parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
plan_path.write_text(json.dumps(plan.json_safe(), indent=2), encoding="utf-8")
|
|
84
|
+
rt.audit.record(
|
|
85
|
+
OperationKind.GENERATE,
|
|
86
|
+
source_id=artifact.source_id,
|
|
87
|
+
dataset_id=artifact.dataset.qualified_name,
|
|
88
|
+
policy_version=engine.policy.version,
|
|
89
|
+
decision="allow",
|
|
90
|
+
result_status="ok",
|
|
91
|
+
detail={"plan_id": plan.plan_id, "rows": str(rows)},
|
|
92
|
+
)
|
|
93
|
+
return plan
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def plan_path(rt: Runtime, plan: GenerationPlan) -> Path:
|
|
97
|
+
return rt.paths.reports / f"{plan.plan_id}.plan.json"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _ensure_unique(
|
|
101
|
+
value: Any,
|
|
102
|
+
column: str,
|
|
103
|
+
seen: set[str],
|
|
104
|
+
strat_plan: Any,
|
|
105
|
+
context: Any,
|
|
106
|
+
max_attempts: int = 100,
|
|
107
|
+
) -> Any:
|
|
108
|
+
"""Bounded retry for unique columns; raises when the strategy cannot
|
|
109
|
+
satisfy uniqueness (SPEC 9.6: never silently weaken a constraint)."""
|
|
110
|
+
key = f"{type(value).__name__}:{value}"
|
|
111
|
+
attempts = 0
|
|
112
|
+
while key in seen and attempts < max_attempts:
|
|
113
|
+
value = execute(strat_plan, context)
|
|
114
|
+
key = f"{type(value).__name__}:{value}"
|
|
115
|
+
attempts += 1
|
|
116
|
+
if key in seen:
|
|
117
|
+
raise GenerationError(
|
|
118
|
+
f"strategy '{strat_plan.strategy}' could not produce a unique "
|
|
119
|
+
f"value for column '{column}' within {max_attempts} attempts; "
|
|
120
|
+
"define a strategy override or reduce row count",
|
|
121
|
+
detail={"column": column, "strategy": strat_plan.strategy},
|
|
122
|
+
)
|
|
123
|
+
seen.add(key)
|
|
124
|
+
return value
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _check_unique_feasibility(resolved: dict[str, Any], unique_cols: set[str], rows: int) -> None:
|
|
128
|
+
"""Reject impossible uniqueness before generating a single row.
|
|
129
|
+
|
|
130
|
+
A bounded numeric range that cannot yield ``rows`` distinct values
|
|
131
|
+
fails immediately with an actionable error instead of exhausting
|
|
132
|
+
per-row retries (SPEC 9.6).
|
|
133
|
+
"""
|
|
134
|
+
for name in sorted(unique_cols):
|
|
135
|
+
plan = resolved[name]
|
|
136
|
+
col = plan.profile
|
|
137
|
+
if col is None or col.numeric is None:
|
|
138
|
+
continue
|
|
139
|
+
if plan.strategy != "numeric_distribution":
|
|
140
|
+
continue
|
|
141
|
+
if col.numeric.minimum is None or col.numeric.maximum is None:
|
|
142
|
+
continue
|
|
143
|
+
try:
|
|
144
|
+
lo = int(float(col.numeric.minimum))
|
|
145
|
+
hi = int(float(col.numeric.maximum))
|
|
146
|
+
except (TypeError, ValueError):
|
|
147
|
+
continue
|
|
148
|
+
space = hi - lo + 1
|
|
149
|
+
if space < rows:
|
|
150
|
+
raise GenerationError(
|
|
151
|
+
f"column '{name}' requires {rows} unique values but its "
|
|
152
|
+
f"observed range [{col.numeric.minimum}, {col.numeric.maximum}] "
|
|
153
|
+
f"holds only {space}; reduce --rows, override the strategy, "
|
|
154
|
+
"or drop the uniqueness requirement",
|
|
155
|
+
detail={
|
|
156
|
+
"column": name,
|
|
157
|
+
"value_space": str(space),
|
|
158
|
+
"rows_requested": str(rows),
|
|
159
|
+
},
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
async def run_generation(
|
|
164
|
+
rt: Runtime,
|
|
165
|
+
*,
|
|
166
|
+
plan_file: Path,
|
|
167
|
+
output: Path | None = None,
|
|
168
|
+
report_path: Path | None = None,
|
|
169
|
+
seed: int | None = None,
|
|
170
|
+
strict: bool = False,
|
|
171
|
+
allow_fallbacks: bool = False,
|
|
172
|
+
cancel: CancellationToken | None = None,
|
|
173
|
+
) -> dict[str, Any]:
|
|
174
|
+
"""Execute a generation plan: synthesize, validate, report.
|
|
175
|
+
|
|
176
|
+
``cancel`` is polled between generated rows and before writing output
|
|
177
|
+
so a cancelled job never leaves a partial artifact marked complete.
|
|
178
|
+
"""
|
|
179
|
+
from faker import Faker
|
|
180
|
+
|
|
181
|
+
from qc.exports.csv_writer import write_csv_rows
|
|
182
|
+
|
|
183
|
+
def check_cancel() -> None:
|
|
184
|
+
if cancel is not None:
|
|
185
|
+
cancel.raise_if_cancelled()
|
|
186
|
+
|
|
187
|
+
if not plan_file.is_file():
|
|
188
|
+
raise NotFoundError(f"plan not found: {plan_file.name}")
|
|
189
|
+
plan = GenerationPlan.model_validate(json.loads(plan_file.read_text(encoding="utf-8")))
|
|
190
|
+
if seed is not None:
|
|
191
|
+
plan.seed = seed
|
|
192
|
+
if allow_fallbacks:
|
|
193
|
+
plan.allow_fallbacks = True
|
|
194
|
+
|
|
195
|
+
# Load all referenced profiles
|
|
196
|
+
profiles: dict[str, Any] = {}
|
|
197
|
+
for pid in plan.profile_ids:
|
|
198
|
+
p = rt.state.find_profile_index(pid) or find_profile(rt.paths.profiles, pid)
|
|
199
|
+
artifact = load_profile(p)
|
|
200
|
+
profiles[artifact.dataset.qualified_name] = artifact
|
|
201
|
+
if not profiles:
|
|
202
|
+
raise NotFoundError("plan references no loadable profiles")
|
|
203
|
+
primary = next(iter(profiles.values()))
|
|
204
|
+
|
|
205
|
+
# Policy check: artifacts store opaque source ids, policy keys are names
|
|
206
|
+
src_cfg = rt.sources.get_by_id(primary.source_id)
|
|
207
|
+
engine = rt.engine()
|
|
208
|
+
engine.require(
|
|
209
|
+
Operation.DATASET_READ,
|
|
210
|
+
DecisionContext(
|
|
211
|
+
source=src_cfg.name if src_cfg else plan.dataset.source_id,
|
|
212
|
+
dataset=plan.dataset.qualified_name,
|
|
213
|
+
),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
started = time.monotonic()
|
|
217
|
+
metrics = OperationMetrics(operation="generate")
|
|
218
|
+
rng = random.Random(plan.seed)
|
|
219
|
+
fake = Faker(plan.locale)
|
|
220
|
+
Faker.seed(plan.seed)
|
|
221
|
+
context = GenerationContext(plan=plan, profiles=profiles, faker=fake, rng=rng)
|
|
222
|
+
|
|
223
|
+
# Resolve strategies per column (fail-closed before generating rows)
|
|
224
|
+
overrides = {o.column: o.strategy for o in plan.strategy_overrides}
|
|
225
|
+
resolved: dict[str, Any] = {}
|
|
226
|
+
fallbacks: list[str] = []
|
|
227
|
+
for col in primary.columns:
|
|
228
|
+
strat_plan = resolve_strategy(col, context, overrides)
|
|
229
|
+
strat_plan.column_profile = col
|
|
230
|
+
resolved[col.name] = strat_plan
|
|
231
|
+
if strat_plan.fallback:
|
|
232
|
+
fallbacks.append(col.name)
|
|
233
|
+
|
|
234
|
+
# Uniqueness requirements (SPEC 9.2 "declared and *approved* inferred
|
|
235
|
+
# uniqueness"): enforce exact uniqueness only where it is actually a
|
|
236
|
+
# key — a declared PK, an identifier-like type, or an id-ish name.
|
|
237
|
+
# Sample-uniqueness on measure columns (e.g. a qty column that
|
|
238
|
+
# happens to be distinct in a small table) is evidence, not a key
|
|
239
|
+
# constraint, and must not make generation fail (SPEC 9.2, 9.6).
|
|
240
|
+
declared_pk = set(primary.dataset_schema.primary_key)
|
|
241
|
+
unique_cols: set[str] = set()
|
|
242
|
+
unenforced_sample_unique: list[str] = []
|
|
243
|
+
for name, sp in resolved.items():
|
|
244
|
+
col = sp.profile
|
|
245
|
+
if col is None or col.unique is not True:
|
|
246
|
+
continue
|
|
247
|
+
if col.unique_measurement.value != "exact":
|
|
248
|
+
continue
|
|
249
|
+
lowered = name.lower()
|
|
250
|
+
keyish = (
|
|
251
|
+
name in declared_pk
|
|
252
|
+
or col.logical_type.value in ("identifier", "uuid")
|
|
253
|
+
or lowered in ("id", "uuid", "guid")
|
|
254
|
+
or lowered.endswith("_id")
|
|
255
|
+
or lowered.endswith("_uuid")
|
|
256
|
+
)
|
|
257
|
+
if keyish:
|
|
258
|
+
unique_cols.add(name)
|
|
259
|
+
else:
|
|
260
|
+
unenforced_sample_unique.append(name)
|
|
261
|
+
seen: dict[str, set[str]] = {c: set() for c in unique_cols}
|
|
262
|
+
pre_warnings: list[str] = []
|
|
263
|
+
if unenforced_sample_unique:
|
|
264
|
+
pre_warnings.append(
|
|
265
|
+
"sample-uniqueness not enforced for non-key columns: "
|
|
266
|
+
+ ", ".join(sorted(unenforced_sample_unique))
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# Fail fast when a unique key's value space cannot fit the request
|
|
270
|
+
# (SPEC 9.6: stop and report; never silently weaken the constraint).
|
|
271
|
+
_check_unique_feasibility(resolved, unique_cols, plan.rows)
|
|
272
|
+
|
|
273
|
+
# Observed null rates (SPEC 9.2): preserve within tolerance using the
|
|
274
|
+
# seeded RNG so output stays reproducible. Rate comes from the profile,
|
|
275
|
+
# not from re-reading the source. Unique columns never emit nulls here
|
|
276
|
+
# (their profile null_count is 0 by construction).
|
|
277
|
+
null_rates: dict[str, float] = {}
|
|
278
|
+
for col in primary.columns:
|
|
279
|
+
total = col.row_count_seen or 0
|
|
280
|
+
nulls = col.falsy.null_count
|
|
281
|
+
if total and nulls and col.name not in unique_cols:
|
|
282
|
+
null_rates[col.name] = nulls / total
|
|
283
|
+
|
|
284
|
+
rows: list[dict[str, Any]] = []
|
|
285
|
+
warnings: list[str] = list(pre_warnings)
|
|
286
|
+
for i in range(plan.rows):
|
|
287
|
+
check_cancel()
|
|
288
|
+
context.row_index = i
|
|
289
|
+
row: dict[str, Any] = {}
|
|
290
|
+
for col_name, strat_plan in resolved.items():
|
|
291
|
+
rate = null_rates.get(col_name, 0.0)
|
|
292
|
+
if rate > 0 and rng.random() < rate:
|
|
293
|
+
row[col_name] = None
|
|
294
|
+
continue
|
|
295
|
+
value = execute(strat_plan, context)
|
|
296
|
+
if col_name in unique_cols:
|
|
297
|
+
value = _ensure_unique(value, col_name, seen[col_name], strat_plan, context)
|
|
298
|
+
row[col_name] = value
|
|
299
|
+
rows.append(row)
|
|
300
|
+
|
|
301
|
+
# Validate against profile (SPEC 9.5)
|
|
302
|
+
fidelity = validate_generation(rows, primary, tolerances=None)
|
|
303
|
+
hard_failures = [m for m in fidelity if m.hard and not m.passed]
|
|
304
|
+
soft_failures = [m for m in fidelity if not m.hard and not m.passed]
|
|
305
|
+
metrics.rows_generated = len(rows)
|
|
306
|
+
metrics.validation_failures = len(hard_failures) + len(soft_failures)
|
|
307
|
+
|
|
308
|
+
if plan.preserve_relationships:
|
|
309
|
+
for rel in primary.relationships:
|
|
310
|
+
if rel.kind == "foreign_key" and rel.verified:
|
|
311
|
+
warnings.append(
|
|
312
|
+
f"relationship {rel.from_columns}->{rel.to_columns} "
|
|
313
|
+
"preservation is declarative in v1"
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
metrics.stop()
|
|
317
|
+
from qc.observability.metrics import RECORDER
|
|
318
|
+
|
|
319
|
+
RECORDER.record(metrics)
|
|
320
|
+
warnings.extend(
|
|
321
|
+
[
|
|
322
|
+
"synthetic data is NOT anonymous; empirical-distribution synthesis "
|
|
323
|
+
"can leak information (no differential privacy claimed)",
|
|
324
|
+
"phone-like values use fictional 555 ranges but are not guaranteed unassigned",
|
|
325
|
+
]
|
|
326
|
+
)
|
|
327
|
+
if fallbacks:
|
|
328
|
+
warnings.append(f"fallback strategies used for: {', '.join(fallbacks)}")
|
|
329
|
+
|
|
330
|
+
# Write output
|
|
331
|
+
check_cancel()
|
|
332
|
+
out_path = output or (rt.paths.output / f"synthetic.{plan.output_format}")
|
|
333
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
334
|
+
if plan.output_format == "csv":
|
|
335
|
+
write_csv_rows(out_path, rows)
|
|
336
|
+
else:
|
|
337
|
+
from qc.exports.jsonl import write_jsonl
|
|
338
|
+
|
|
339
|
+
write_jsonl(out_path, rows)
|
|
340
|
+
|
|
341
|
+
report = GenerationReport(
|
|
342
|
+
plan_id=plan.plan_id,
|
|
343
|
+
rows_requested=plan.rows,
|
|
344
|
+
rows_generated=len(rows),
|
|
345
|
+
seed=plan.seed,
|
|
346
|
+
metrics=fidelity,
|
|
347
|
+
constraint_violations=[
|
|
348
|
+
f"{m.name}: generated {m.generated} vs observed {m.observed}" for m in hard_failures
|
|
349
|
+
],
|
|
350
|
+
strategy_fallbacks=fallbacks,
|
|
351
|
+
warnings=warnings,
|
|
352
|
+
preserved_properties=[
|
|
353
|
+
m.name
|
|
354
|
+
for m in fidelity
|
|
355
|
+
if m.passed and m.name.endswith(("null_rate", "categorical_tvd"))
|
|
356
|
+
],
|
|
357
|
+
approximated_properties=[m.name for m in soft_failures],
|
|
358
|
+
runtime_seconds=time.monotonic() - started,
|
|
359
|
+
status="failed" if hard_failures or (strict and soft_failures) else "completed",
|
|
360
|
+
output_path=str(out_path),
|
|
361
|
+
)
|
|
362
|
+
rpath = report_path or (rt.paths.reports / f"{report.report_id}.report.json")
|
|
363
|
+
rpath.parent.mkdir(parents=True, exist_ok=True)
|
|
364
|
+
rpath.write_text(json.dumps(report.json_safe(), indent=2), encoding="utf-8")
|
|
365
|
+
|
|
366
|
+
rt.audit.record(
|
|
367
|
+
OperationKind.GENERATE,
|
|
368
|
+
source_id=primary.source_id,
|
|
369
|
+
dataset_id=plan.dataset.qualified_name,
|
|
370
|
+
policy_version=engine.policy.version,
|
|
371
|
+
decision="allow",
|
|
372
|
+
rows_affected=len(rows),
|
|
373
|
+
duration_seconds=report.runtime_seconds,
|
|
374
|
+
result_status=report.status,
|
|
375
|
+
detail={"report_id": report.report_id, "hard_failures": str(len(hard_failures))},
|
|
376
|
+
)
|
|
377
|
+
ok = not hard_failures and not (strict and soft_failures)
|
|
378
|
+
return {
|
|
379
|
+
"ok": ok,
|
|
380
|
+
"report": report,
|
|
381
|
+
"output": str(out_path),
|
|
382
|
+
"report_path": str(rpath),
|
|
383
|
+
"hard_failures": len(hard_failures),
|
|
384
|
+
"soft_failures": len(soft_failures),
|
|
385
|
+
"metrics": metrics.as_dict(),
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
async def validate_output(rt: Runtime, *, output: Path, profile_ref: str) -> GenerationReport:
|
|
390
|
+
"""Validate an existing generated file against a profile."""
|
|
391
|
+
if not output.is_file():
|
|
392
|
+
raise NotFoundError(f"output not found: {output.name}")
|
|
393
|
+
path = rt.state.find_profile_index(profile_ref) or find_profile(rt.paths.profiles, profile_ref)
|
|
394
|
+
artifact = load_profile(path)
|
|
395
|
+
|
|
396
|
+
rows: list[dict[str, Any]] = []
|
|
397
|
+
if output.suffix == ".csv":
|
|
398
|
+
import csv
|
|
399
|
+
|
|
400
|
+
with output.open(encoding="utf-8", newline="") as fh:
|
|
401
|
+
for raw in csv.DictReader(fh):
|
|
402
|
+
rows.append({k: _maybe_number(v) for k, v in raw.items()})
|
|
403
|
+
else:
|
|
404
|
+
for line in output.read_text(encoding="utf-8").splitlines():
|
|
405
|
+
if line.strip():
|
|
406
|
+
rows.append(json.loads(line))
|
|
407
|
+
|
|
408
|
+
metrics = validate_generation(rows, artifact)
|
|
409
|
+
hard = [m for m in metrics if m.hard and not m.passed]
|
|
410
|
+
soft = [m for m in metrics if not m.hard and not m.passed]
|
|
411
|
+
from qc import TOOL_VERSION
|
|
412
|
+
|
|
413
|
+
return GenerationReport(
|
|
414
|
+
plan_id="validation",
|
|
415
|
+
rows_requested=len(rows),
|
|
416
|
+
rows_generated=len(rows),
|
|
417
|
+
metrics=metrics,
|
|
418
|
+
constraint_violations=[m.name for m in hard],
|
|
419
|
+
approximated_properties=[m.name for m in soft],
|
|
420
|
+
status="failed" if hard else "completed",
|
|
421
|
+
tool_version=TOOL_VERSION,
|
|
422
|
+
output_path=str(output),
|
|
423
|
+
warnings=["validation run against saved profile; no source data accessed"],
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _maybe_number(v: str) -> Any:
|
|
428
|
+
if v == "\\N" or v == "":
|
|
429
|
+
return None
|
|
430
|
+
if v in ("true", "false"):
|
|
431
|
+
return v == "true"
|
|
432
|
+
try:
|
|
433
|
+
return int(v)
|
|
434
|
+
except ValueError:
|
|
435
|
+
pass
|
|
436
|
+
try:
|
|
437
|
+
return float(v)
|
|
438
|
+
except ValueError:
|
|
439
|
+
return v
|