contractforge-snowflake 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.
- contractforge_snowflake/__init__.py +68 -0
- contractforge_snowflake/access/__init__.py +5 -0
- contractforge_snowflake/access/runtime.py +264 -0
- contractforge_snowflake/adapter.py +78 -0
- contractforge_snowflake/annotations/__init__.py +9 -0
- contractforge_snowflake/annotations/runtime.py +258 -0
- contractforge_snowflake/api.py +51 -0
- contractforge_snowflake/capabilities/__init__.py +17 -0
- contractforge_snowflake/capabilities/sql_warehouse.py +46 -0
- contractforge_snowflake/cli/__init__.py +79 -0
- contractforge_snowflake/cli/_helpers.py +132 -0
- contractforge_snowflake/cli/cost.py +52 -0
- contractforge_snowflake/cli/dashboard.py +34 -0
- contractforge_snowflake/cli/lineage.py +47 -0
- contractforge_snowflake/cli/maintenance.py +47 -0
- contractforge_snowflake/cli/plan.py +22 -0
- contractforge_snowflake/cli/project.py +84 -0
- contractforge_snowflake/cli/publish.py +54 -0
- contractforge_snowflake/cli/run.py +24 -0
- contractforge_snowflake/cli/smoke.py +112 -0
- contractforge_snowflake/connection_options.py +70 -0
- contractforge_snowflake/contract_extensions.py +52 -0
- contractforge_snowflake/cost/__init__.py +5 -0
- contractforge_snowflake/cost/reconciliation.py +210 -0
- contractforge_snowflake/dashboards/__init__.py +15 -0
- contractforge_snowflake/dashboards/control_tables.py +145 -0
- contractforge_snowflake/deployment/__init__.py +15 -0
- contractforge_snowflake/deployment/procedure.py +193 -0
- contractforge_snowflake/deployment/task_graph.py +211 -0
- contractforge_snowflake/diagnostics/__init__.py +13 -0
- contractforge_snowflake/diagnostics/portability.py +212 -0
- contractforge_snowflake/environment.py +61 -0
- contractforge_snowflake/evidence/__init__.py +34 -0
- contractforge_snowflake/evidence/ddl.py +84 -0
- contractforge_snowflake/evidence/writer.py +877 -0
- contractforge_snowflake/lineage/__init__.py +8 -0
- contractforge_snowflake/lineage/reconciliation.py +96 -0
- contractforge_snowflake/maintenance/__init__.py +15 -0
- contractforge_snowflake/maintenance/retention.py +84 -0
- contractforge_snowflake/naming/__init__.py +9 -0
- contractforge_snowflake/naming/identifiers.py +29 -0
- contractforge_snowflake/operations/__init__.py +5 -0
- contractforge_snowflake/operations/runtime.py +63 -0
- contractforge_snowflake/polling.py +14 -0
- contractforge_snowflake/preparation/__init__.py +11 -0
- contractforge_snowflake/preparation/registry.py +34 -0
- contractforge_snowflake/preparation/sql.py +360 -0
- contractforge_snowflake/publish/__init__.py +5 -0
- contractforge_snowflake/publish/bundle.py +112 -0
- contractforge_snowflake/rendering/__init__.py +5 -0
- contractforge_snowflake/rendering/review.py +132 -0
- contractforge_snowflake/runtime/__init__.py +54 -0
- contractforge_snowflake/runtime/artifacts.py +85 -0
- contractforge_snowflake/runtime/execution.py +520 -0
- contractforge_snowflake/runtime/project.py +627 -0
- contractforge_snowflake/runtime/publish.py +196 -0
- contractforge_snowflake/runtime/quality.py +242 -0
- contractforge_snowflake/runtime/runner.py +186 -0
- contractforge_snowflake/runtime/schema_policy.py +350 -0
- contractforge_snowflake/runtime/session.py +177 -0
- contractforge_snowflake/runtime/snowpark_handler.py +20 -0
- contractforge_snowflake/session_ops.py +60 -0
- contractforge_snowflake/smoke/__init__.py +26 -0
- contractforge_snowflake/smoke/failure_paths.py +75 -0
- contractforge_snowflake/smoke/minimal.py +75 -0
- contractforge_snowflake/smoke/models.py +362 -0
- contractforge_snowflake/smoke/procedure.py +273 -0
- contractforge_snowflake/smoke/runner.py +224 -0
- contractforge_snowflake/smoke/stage_publish.py +194 -0
- contractforge_snowflake/smoke/task_graph.py +298 -0
- contractforge_snowflake/sources/__init__.py +6 -0
- contractforge_snowflake/sources/models.py +15 -0
- contractforge_snowflake/sources/registry.py +36 -0
- contractforge_snowflake/sources/review.py +23 -0
- contractforge_snowflake/sources/sql.py +23 -0
- contractforge_snowflake/sources/stage_files.py +106 -0
- contractforge_snowflake/sources/table.py +25 -0
- contractforge_snowflake/sources/table_refs.py +81 -0
- contractforge_snowflake/sql.py +11 -0
- contractforge_snowflake/state/__init__.py +23 -0
- contractforge_snowflake/state/runtime.py +368 -0
- contractforge_snowflake/subtargets.py +27 -0
- contractforge_snowflake/values.py +55 -0
- contractforge_snowflake/write_modes/__init__.py +17 -0
- contractforge_snowflake/write_modes/append.py +12 -0
- contractforge_snowflake/write_modes/hash_diff.py +48 -0
- contractforge_snowflake/write_modes/models.py +41 -0
- contractforge_snowflake/write_modes/overwrite.py +12 -0
- contractforge_snowflake/write_modes/registry.py +77 -0
- contractforge_snowflake/write_modes/upsert.py +32 -0
- contractforge_snowflake/write_modes/validation.py +60 -0
- contractforge_snowflake-0.1.0.dist-info/METADATA +210 -0
- contractforge_snowflake-0.1.0.dist-info/RECORD +95 -0
- contractforge_snowflake-0.1.0.dist-info/WHEEL +4 -0
- contractforge_snowflake-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Public API for the ContractForge Snowflake adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError
|
|
6
|
+
from importlib.metadata import version as _version
|
|
7
|
+
|
|
8
|
+
from contractforge_snowflake.adapter import SnowflakeAdapter
|
|
9
|
+
from contractforge_snowflake.api import (
|
|
10
|
+
build_snowflake_publish_bundle,
|
|
11
|
+
plan_snowflake_contract,
|
|
12
|
+
render_snowflake_contract,
|
|
13
|
+
)
|
|
14
|
+
from contractforge_snowflake.capabilities import (
|
|
15
|
+
SNOWFLAKE_SUBTARGET_SNOWPIPE,
|
|
16
|
+
SNOWFLAKE_SUBTARGET_SQL_WAREHOUSE,
|
|
17
|
+
SNOWFLAKE_SUBTARGET_STREAMS_TASKS,
|
|
18
|
+
SNOWFLAKE_SUBTARGET_TASK_GRAPH,
|
|
19
|
+
snowflake_sql_warehouse_capabilities,
|
|
20
|
+
)
|
|
21
|
+
from contractforge_snowflake.dashboards import render_control_dashboard_artifacts, render_control_dashboard_sql
|
|
22
|
+
from contractforge_snowflake.environment import SnowflakeEnvironment
|
|
23
|
+
from contractforge_snowflake.maintenance import build_control_retention_plan, execute_control_retention_plan
|
|
24
|
+
from contractforge_snowflake.runtime import (
|
|
25
|
+
SnowflakeAccessHistoryLineageResult,
|
|
26
|
+
build_snowflake_project_cleanup_plan,
|
|
27
|
+
deploy_snowflake_project,
|
|
28
|
+
publish_snowflake_contract,
|
|
29
|
+
reconcile_snowflake_access_history_lineage,
|
|
30
|
+
reconcile_snowflake_cost_evidence,
|
|
31
|
+
run_snowflake_contract,
|
|
32
|
+
run_snowflake_project,
|
|
33
|
+
wait_snowflake_project_tasks,
|
|
34
|
+
)
|
|
35
|
+
from contractforge_snowflake.subtargets import list_snowflake_subtargets
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
__version__ = _version("contractforge-snowflake")
|
|
39
|
+
except PackageNotFoundError: # pragma: no cover - editable/source tree without installed metadata
|
|
40
|
+
__version__ = "0.1.0"
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"SNOWFLAKE_SUBTARGET_SNOWPIPE",
|
|
44
|
+
"SNOWFLAKE_SUBTARGET_SQL_WAREHOUSE",
|
|
45
|
+
"SNOWFLAKE_SUBTARGET_STREAMS_TASKS",
|
|
46
|
+
"SNOWFLAKE_SUBTARGET_TASK_GRAPH",
|
|
47
|
+
"SnowflakeAdapter",
|
|
48
|
+
"SnowflakeAccessHistoryLineageResult",
|
|
49
|
+
"SnowflakeEnvironment",
|
|
50
|
+
"__version__",
|
|
51
|
+
"build_snowflake_publish_bundle",
|
|
52
|
+
"build_control_retention_plan",
|
|
53
|
+
"build_snowflake_project_cleanup_plan",
|
|
54
|
+
"deploy_snowflake_project",
|
|
55
|
+
"execute_control_retention_plan",
|
|
56
|
+
"list_snowflake_subtargets",
|
|
57
|
+
"plan_snowflake_contract",
|
|
58
|
+
"publish_snowflake_contract",
|
|
59
|
+
"reconcile_snowflake_access_history_lineage",
|
|
60
|
+
"reconcile_snowflake_cost_evidence",
|
|
61
|
+
"render_control_dashboard_artifacts",
|
|
62
|
+
"render_control_dashboard_sql",
|
|
63
|
+
"render_snowflake_contract",
|
|
64
|
+
"run_snowflake_contract",
|
|
65
|
+
"run_snowflake_project",
|
|
66
|
+
"snowflake_sql_warehouse_capabilities",
|
|
67
|
+
"wait_snowflake_project_tasks",
|
|
68
|
+
]
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Apply ContractForge access grants to Snowflake objects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from contractforge_core.security.redaction import redact_text
|
|
9
|
+
from contractforge_core.semantic import SemanticContract
|
|
10
|
+
from contractforge_snowflake.environment import SnowflakeEnvironment
|
|
11
|
+
from contractforge_snowflake.evidence import record_access_evidence
|
|
12
|
+
from contractforge_snowflake.naming import quote_identifier, quote_multipart_identifier, snowflake_target_name
|
|
13
|
+
from contractforge_snowflake.session_ops import execute
|
|
14
|
+
from contractforge_snowflake.values import dict_mapping as _mapping
|
|
15
|
+
from contractforge_snowflake.values import string_list as _as_list
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class SnowflakeAccessStep:
|
|
20
|
+
action: str
|
|
21
|
+
access_type: str
|
|
22
|
+
principal: str | None
|
|
23
|
+
privilege: str | None
|
|
24
|
+
column_name: str | None
|
|
25
|
+
function_name: str | None
|
|
26
|
+
object_name: str
|
|
27
|
+
mode: str
|
|
28
|
+
sql: str | None
|
|
29
|
+
drift_policy: str = "warn"
|
|
30
|
+
revoke_unmanaged: bool = False
|
|
31
|
+
previous_value: str | None = None
|
|
32
|
+
new_value: str | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class SnowflakeAccessResult:
|
|
37
|
+
status: str
|
|
38
|
+
applied: int
|
|
39
|
+
skipped: int
|
|
40
|
+
failed: int
|
|
41
|
+
commands: tuple[str, ...]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def apply_snowflake_access(
|
|
45
|
+
*,
|
|
46
|
+
session: Any,
|
|
47
|
+
environment: SnowflakeEnvironment,
|
|
48
|
+
contract: SemanticContract,
|
|
49
|
+
run_id: str,
|
|
50
|
+
) -> SnowflakeAccessResult:
|
|
51
|
+
steps = access_steps(contract)
|
|
52
|
+
if not steps:
|
|
53
|
+
return SnowflakeAccessResult(status="NOOP", applied=0, skipped=0, failed=0, commands=())
|
|
54
|
+
commands: list[str] = []
|
|
55
|
+
applied = skipped = failed = 0
|
|
56
|
+
for step in steps:
|
|
57
|
+
if step.mode != "apply":
|
|
58
|
+
skipped += 1
|
|
59
|
+
evidence = record_access_evidence(
|
|
60
|
+
session,
|
|
61
|
+
environment=environment,
|
|
62
|
+
contract=contract,
|
|
63
|
+
run_id=run_id,
|
|
64
|
+
step=step.__dict__,
|
|
65
|
+
status=_skipped_status(step.mode),
|
|
66
|
+
error_message=None,
|
|
67
|
+
)
|
|
68
|
+
commands.extend(evidence.commands)
|
|
69
|
+
continue
|
|
70
|
+
try:
|
|
71
|
+
if step.sql:
|
|
72
|
+
execute(session, step.sql)
|
|
73
|
+
except Exception as exc:
|
|
74
|
+
failed += 1
|
|
75
|
+
evidence = record_access_evidence(
|
|
76
|
+
session,
|
|
77
|
+
environment=environment,
|
|
78
|
+
contract=contract,
|
|
79
|
+
run_id=run_id,
|
|
80
|
+
step=step.__dict__,
|
|
81
|
+
status="FAILED",
|
|
82
|
+
error_message=redact_text(str(exc)),
|
|
83
|
+
)
|
|
84
|
+
commands.extend(_with_sql(step.sql, evidence.commands))
|
|
85
|
+
raise
|
|
86
|
+
else:
|
|
87
|
+
applied += 1
|
|
88
|
+
evidence = record_access_evidence(
|
|
89
|
+
session,
|
|
90
|
+
environment=environment,
|
|
91
|
+
contract=contract,
|
|
92
|
+
run_id=run_id,
|
|
93
|
+
step=step.__dict__,
|
|
94
|
+
status="APPLIED",
|
|
95
|
+
error_message=None,
|
|
96
|
+
)
|
|
97
|
+
commands.extend(_with_sql(step.sql, evidence.commands))
|
|
98
|
+
return SnowflakeAccessResult(status="SUCCESS" if failed == 0 else "FAILED", applied=applied, skipped=skipped, failed=failed, commands=tuple(commands))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def access_steps(contract: SemanticContract) -> tuple[SnowflakeAccessStep, ...]:
|
|
102
|
+
access = contract.governance.access if contract.governance else None
|
|
103
|
+
if not isinstance(access, dict):
|
|
104
|
+
return ()
|
|
105
|
+
target = snowflake_target_name(contract)
|
|
106
|
+
grants = _grant_steps(target, access)
|
|
107
|
+
policies = _policy_steps(target, access)
|
|
108
|
+
return (*grants, *policies)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _grant_steps(target: str, access: dict[str, Any]) -> tuple[SnowflakeAccessStep, ...]:
|
|
112
|
+
return tuple(
|
|
113
|
+
_grant_step(target, principal=str(grant["principal"]), privilege=str(privilege), access=access)
|
|
114
|
+
for grant in access.get("grants", ())
|
|
115
|
+
if isinstance(grant, dict)
|
|
116
|
+
for privilege in _as_list(grant.get("privileges"))
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _grant_step(target: str, *, principal: str, privilege: str, access: dict[str, Any]) -> SnowflakeAccessStep:
|
|
121
|
+
privilege_name = _privilege_name(privilege)
|
|
122
|
+
return SnowflakeAccessStep(
|
|
123
|
+
action="grant",
|
|
124
|
+
access_type="grant",
|
|
125
|
+
principal=principal,
|
|
126
|
+
privilege=privilege_name,
|
|
127
|
+
column_name=None,
|
|
128
|
+
function_name=None,
|
|
129
|
+
object_name=target,
|
|
130
|
+
mode=_access_mode(access),
|
|
131
|
+
sql=f"GRANT {privilege_name} ON TABLE {target} TO ROLE {quote_identifier(principal)}",
|
|
132
|
+
drift_policy=_access_drift_policy(access),
|
|
133
|
+
revoke_unmanaged=_revoke_unmanaged(access),
|
|
134
|
+
new_value="GRANTED",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _policy_steps(target: str, access: dict[str, Any]) -> tuple[SnowflakeAccessStep, ...]:
|
|
139
|
+
row_filters = tuple(
|
|
140
|
+
_row_access_policy_step(target, item, access)
|
|
141
|
+
for item in _iter_list_items(access.get("row_filters"))
|
|
142
|
+
)
|
|
143
|
+
masks = tuple(
|
|
144
|
+
_masking_policy_step(target, item, access)
|
|
145
|
+
for item in _iter_column_masks(access.get("column_masks"))
|
|
146
|
+
)
|
|
147
|
+
return (*row_filters, *masks)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _row_access_policy_step(target: str, row_filter: dict[str, Any], access: dict[str, Any]) -> SnowflakeAccessStep:
|
|
151
|
+
columns = tuple(_as_list(row_filter.get("columns")))
|
|
152
|
+
policy = str(row_filter.get("function") or "").strip()
|
|
153
|
+
if not columns:
|
|
154
|
+
raise ValueError("Snowflake row access policy requires access.row_filters.columns")
|
|
155
|
+
if not policy:
|
|
156
|
+
raise ValueError("Snowflake row access policy requires access.row_filters.function")
|
|
157
|
+
return SnowflakeAccessStep(
|
|
158
|
+
action="apply_row_access_policy",
|
|
159
|
+
access_type="row_filter",
|
|
160
|
+
principal="|".join(_as_list(_mapping(row_filter.get("applies_to")).get("principals"))),
|
|
161
|
+
privilege="ROW_ACCESS_POLICY",
|
|
162
|
+
column_name="|".join(columns),
|
|
163
|
+
function_name=policy,
|
|
164
|
+
object_name=target,
|
|
165
|
+
mode=_access_mode(access),
|
|
166
|
+
sql=(
|
|
167
|
+
f"ALTER TABLE {target} ADD ROW ACCESS POLICY {quote_multipart_identifier(policy)} "
|
|
168
|
+
f"ON ({', '.join(quote_identifier(column) for column in columns)})"
|
|
169
|
+
),
|
|
170
|
+
drift_policy=_access_drift_policy(access),
|
|
171
|
+
revoke_unmanaged=_revoke_unmanaged(access),
|
|
172
|
+
new_value=policy,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _masking_policy_step(target: str, column_mask: dict[str, Any], access: dict[str, Any]) -> SnowflakeAccessStep:
|
|
177
|
+
column = str(column_mask.get("column") or "").strip()
|
|
178
|
+
policy = str(column_mask.get("function") or "").strip()
|
|
179
|
+
using_columns = tuple(_as_list(column_mask.get("using_columns")))
|
|
180
|
+
if not column:
|
|
181
|
+
raise ValueError("Snowflake masking policy requires access.column_masks.column")
|
|
182
|
+
if not policy:
|
|
183
|
+
raise ValueError("Snowflake masking policy requires access.column_masks.function")
|
|
184
|
+
using_sql = ""
|
|
185
|
+
if using_columns:
|
|
186
|
+
using = (column, *tuple(item for item in using_columns if item != column))
|
|
187
|
+
using_sql = " USING (" + ", ".join(quote_identifier(item) for item in using) + ")"
|
|
188
|
+
return SnowflakeAccessStep(
|
|
189
|
+
action="apply_masking_policy",
|
|
190
|
+
access_type="column_mask",
|
|
191
|
+
principal="|".join(_as_list(_mapping(column_mask.get("applies_to")).get("principals"))),
|
|
192
|
+
privilege="MASKING_POLICY",
|
|
193
|
+
column_name=column,
|
|
194
|
+
function_name=policy,
|
|
195
|
+
object_name=target,
|
|
196
|
+
mode=_access_mode(access),
|
|
197
|
+
sql=(
|
|
198
|
+
f"ALTER TABLE {target} MODIFY COLUMN {quote_identifier(column)} "
|
|
199
|
+
f"SET MASKING POLICY {quote_multipart_identifier(policy)}{using_sql}"
|
|
200
|
+
),
|
|
201
|
+
drift_policy=_access_drift_policy(access),
|
|
202
|
+
revoke_unmanaged=_revoke_unmanaged(access),
|
|
203
|
+
new_value=policy,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _access_mode(access: dict[str, Any]) -> str:
|
|
208
|
+
policy = _mapping(access.get("access_policy"))
|
|
209
|
+
return str(policy.get("mode") or access.get("mode") or "apply")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _access_drift_policy(access: dict[str, Any]) -> str:
|
|
213
|
+
policy = _mapping(access.get("access_policy"))
|
|
214
|
+
return str(policy.get("on_drift") or access.get("on_drift") or "warn")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _revoke_unmanaged(access: dict[str, Any]) -> bool:
|
|
218
|
+
policy = _mapping(access.get("access_policy"))
|
|
219
|
+
return bool(policy.get("revoke_unmanaged", access.get("revoke_unmanaged", False)))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _privilege_name(value: str) -> str:
|
|
223
|
+
privilege = str(value).strip().replace("_", " ").upper()
|
|
224
|
+
allowed = {
|
|
225
|
+
"ALL PRIVILEGES",
|
|
226
|
+
"APPLYBUDGET",
|
|
227
|
+
"DELETE",
|
|
228
|
+
"EVOLVE SCHEMA",
|
|
229
|
+
"INSERT",
|
|
230
|
+
"REFERENCES",
|
|
231
|
+
"SELECT",
|
|
232
|
+
"TRUNCATE",
|
|
233
|
+
"UPDATE",
|
|
234
|
+
}
|
|
235
|
+
if privilege not in allowed:
|
|
236
|
+
raise ValueError(f"Unsupported Snowflake table privilege: {value!r}")
|
|
237
|
+
return privilege
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _skipped_status(mode: str) -> str:
|
|
241
|
+
return "IGNORED" if mode == "ignore" else "VALIDATED"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _iter_list_items(value: object) -> tuple[dict[str, Any], ...]:
|
|
245
|
+
if value is None:
|
|
246
|
+
return ()
|
|
247
|
+
if isinstance(value, dict):
|
|
248
|
+
return tuple(dict(item) for item in value.values() if isinstance(item, dict))
|
|
249
|
+
return tuple(dict(item) for item in value if isinstance(item, dict)) # type: ignore[union-attr]
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _iter_column_masks(value: object) -> tuple[dict[str, Any], ...]:
|
|
253
|
+
if value is None:
|
|
254
|
+
return ()
|
|
255
|
+
if isinstance(value, dict):
|
|
256
|
+
return tuple({**dict(config), "column": column} for column, config in value.items() if isinstance(config, dict))
|
|
257
|
+
return tuple(dict(item) for item in value if isinstance(item, dict)) # type: ignore[union-attr]
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _with_sql(sql: str | None, commands: tuple[str, ...]) -> tuple[str, ...]:
|
|
261
|
+
return ((sql,) if sql else ()) + commands
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
__all__ = ["SnowflakeAccessResult", "access_steps", "apply_snowflake_access"]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""ContractForge adapter implementation for Snowflake targets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from contractforge_core.adapters import RenderedArtifacts
|
|
9
|
+
from contractforge_core.capabilities import PlatformCapabilities
|
|
10
|
+
from contractforge_core.planner import ExecutionPlan, PlanningResult, plan_contract
|
|
11
|
+
from contractforge_core.semantic import SemanticContract
|
|
12
|
+
from contractforge_snowflake.capabilities import (
|
|
13
|
+
SNOWFLAKE_SUBTARGET_SQL_WAREHOUSE,
|
|
14
|
+
snowflake_sql_warehouse_capabilities,
|
|
15
|
+
)
|
|
16
|
+
from contractforge_snowflake.diagnostics import (
|
|
17
|
+
snowflake_planning_warnings,
|
|
18
|
+
snowflake_review_required_warnings,
|
|
19
|
+
unsupported_source_blockers,
|
|
20
|
+
)
|
|
21
|
+
from contractforge_snowflake.environment import SnowflakeEnvironment
|
|
22
|
+
from contractforge_snowflake.rendering import render_snowflake_review_artifacts
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class SnowflakeAdapter:
|
|
27
|
+
"""Snowflake adapter for planning and publish-bundle preparation."""
|
|
28
|
+
|
|
29
|
+
declared_capabilities: PlatformCapabilities
|
|
30
|
+
environment: SnowflakeEnvironment = SnowflakeEnvironment()
|
|
31
|
+
name: str = SNOWFLAKE_SUBTARGET_SQL_WAREHOUSE
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def sql_warehouse(cls, environment: dict[str, Any] | None = None) -> "SnowflakeAdapter":
|
|
35
|
+
return cls(
|
|
36
|
+
snowflake_sql_warehouse_capabilities(),
|
|
37
|
+
environment=SnowflakeEnvironment.from_contract(environment),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def capabilities(self) -> PlatformCapabilities:
|
|
41
|
+
return self.declared_capabilities
|
|
42
|
+
|
|
43
|
+
def plan(self, contract: SemanticContract) -> PlanningResult:
|
|
44
|
+
source_blockers = unsupported_source_blockers(contract)
|
|
45
|
+
if source_blockers:
|
|
46
|
+
return PlanningResult(status="UNSUPPORTED", plan=None, blockers=source_blockers)
|
|
47
|
+
|
|
48
|
+
result = plan_contract(contract, self.capabilities())
|
|
49
|
+
warnings = result.warnings + snowflake_planning_warnings(contract)
|
|
50
|
+
review_warnings = snowflake_review_required_warnings(contract)
|
|
51
|
+
if review_warnings and result.status in {"SUPPORTED", "SUPPORTED_WITH_WARNINGS"}:
|
|
52
|
+
return PlanningResult(
|
|
53
|
+
status="REVIEW_REQUIRED",
|
|
54
|
+
plan=result.plan,
|
|
55
|
+
blockers=result.blockers,
|
|
56
|
+
warnings=warnings + review_warnings,
|
|
57
|
+
)
|
|
58
|
+
if warnings and result.status == "SUPPORTED":
|
|
59
|
+
return PlanningResult(status="SUPPORTED_WITH_WARNINGS", plan=result.plan, warnings=warnings)
|
|
60
|
+
return PlanningResult(
|
|
61
|
+
status=result.status,
|
|
62
|
+
plan=result.plan,
|
|
63
|
+
blockers=result.blockers,
|
|
64
|
+
warnings=warnings + review_warnings,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def render(self, plan: ExecutionPlan) -> RenderedArtifacts:
|
|
68
|
+
return render_snowflake_review_artifacts(plan=plan, planning=None, environment=self.environment)
|
|
69
|
+
|
|
70
|
+
def render_contract(self, contract: SemanticContract, *, raw_contract: dict[str, Any] | None = None) -> RenderedArtifacts:
|
|
71
|
+
planning = self.plan(contract)
|
|
72
|
+
return render_snowflake_review_artifacts(
|
|
73
|
+
plan=planning.plan,
|
|
74
|
+
planning=planning,
|
|
75
|
+
contract=contract,
|
|
76
|
+
raw_contract=raw_contract,
|
|
77
|
+
environment=self.environment,
|
|
78
|
+
)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Snowflake annotation planning and runtime application."""
|
|
2
|
+
|
|
3
|
+
from contractforge_snowflake.annotations.runtime import (
|
|
4
|
+
SnowflakeAnnotationResult,
|
|
5
|
+
annotation_steps,
|
|
6
|
+
apply_snowflake_annotations,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
__all__ = ["SnowflakeAnnotationResult", "annotation_steps", "apply_snowflake_annotations"]
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Apply ContractForge annotations to Snowflake objects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
from contractforge_core.security.redaction import redact_text
|
|
10
|
+
from contractforge_core.semantic import SemanticContract
|
|
11
|
+
from contractforge_snowflake.contract_extensions import snowflake_extensions
|
|
12
|
+
from contractforge_snowflake.environment import SnowflakeEnvironment
|
|
13
|
+
from contractforge_snowflake.evidence import record_annotation_evidence
|
|
14
|
+
from contractforge_snowflake.naming import quote_identifier, quote_multipart_identifier, snowflake_target_name
|
|
15
|
+
from contractforge_snowflake.session_ops import execute
|
|
16
|
+
from contractforge_snowflake.sql import sql_string
|
|
17
|
+
from contractforge_snowflake.values import dict_mapping as _mapping
|
|
18
|
+
from contractforge_snowflake.values import pipe_string_list as _as_list
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class SnowflakeAnnotationStep:
|
|
23
|
+
scope: str
|
|
24
|
+
annotation_type: str
|
|
25
|
+
column_name: str | None
|
|
26
|
+
key: str
|
|
27
|
+
value: str
|
|
28
|
+
sql: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class SnowflakeAnnotationResult:
|
|
33
|
+
status: str
|
|
34
|
+
applied: int
|
|
35
|
+
failed: int
|
|
36
|
+
commands: tuple[str, ...]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class _TagExtractor:
|
|
41
|
+
prefix: str
|
|
42
|
+
extract: Callable[[dict[str, Any]], dict[str, str]]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def apply_snowflake_annotations(
|
|
46
|
+
*,
|
|
47
|
+
session: Any,
|
|
48
|
+
environment: SnowflakeEnvironment,
|
|
49
|
+
contract: SemanticContract,
|
|
50
|
+
run_id: str,
|
|
51
|
+
) -> SnowflakeAnnotationResult:
|
|
52
|
+
"""Apply annotations and record canonical annotation evidence."""
|
|
53
|
+
|
|
54
|
+
steps = annotation_steps(contract)
|
|
55
|
+
if not steps:
|
|
56
|
+
return SnowflakeAnnotationResult(status="NOOP", applied=0, failed=0, commands=())
|
|
57
|
+
policy = _annotation_policy(contract)
|
|
58
|
+
if policy == "ignore":
|
|
59
|
+
return SnowflakeAnnotationResult(status="IGNORED", applied=0, failed=0, commands=())
|
|
60
|
+
tag_mode = _tag_mode(contract)
|
|
61
|
+
commands: list[str] = []
|
|
62
|
+
applied = 0
|
|
63
|
+
failed = 0
|
|
64
|
+
for step in steps:
|
|
65
|
+
if step.annotation_type == "tag" and tag_mode == "validate_only":
|
|
66
|
+
evidence = record_annotation_evidence(
|
|
67
|
+
session,
|
|
68
|
+
environment=environment,
|
|
69
|
+
contract=contract,
|
|
70
|
+
run_id=run_id,
|
|
71
|
+
step=step.__dict__,
|
|
72
|
+
status="VALIDATED",
|
|
73
|
+
error_message=None,
|
|
74
|
+
)
|
|
75
|
+
applied += 1
|
|
76
|
+
commands.extend(evidence.commands)
|
|
77
|
+
continue
|
|
78
|
+
try:
|
|
79
|
+
execute(session, step.sql)
|
|
80
|
+
except Exception as exc:
|
|
81
|
+
failed += 1
|
|
82
|
+
evidence = record_annotation_evidence(
|
|
83
|
+
session,
|
|
84
|
+
environment=environment,
|
|
85
|
+
contract=contract,
|
|
86
|
+
run_id=run_id,
|
|
87
|
+
step=step.__dict__,
|
|
88
|
+
status="FAILED",
|
|
89
|
+
error_message=redact_text(str(exc)),
|
|
90
|
+
)
|
|
91
|
+
commands.extend((step.sql, *evidence.commands))
|
|
92
|
+
if policy == "fail":
|
|
93
|
+
raise
|
|
94
|
+
else:
|
|
95
|
+
applied += 1
|
|
96
|
+
evidence = record_annotation_evidence(
|
|
97
|
+
session,
|
|
98
|
+
environment=environment,
|
|
99
|
+
contract=contract,
|
|
100
|
+
run_id=run_id,
|
|
101
|
+
step=step.__dict__,
|
|
102
|
+
status="APPLIED",
|
|
103
|
+
error_message=None,
|
|
104
|
+
)
|
|
105
|
+
commands.extend((step.sql, *evidence.commands))
|
|
106
|
+
status = "SUCCESS" if failed == 0 else "WARNED"
|
|
107
|
+
return SnowflakeAnnotationResult(status=status, applied=applied, failed=failed, commands=tuple(commands))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def annotation_steps(contract: SemanticContract) -> tuple[SnowflakeAnnotationStep, ...]:
|
|
111
|
+
annotations = contract.governance.annotations if contract.governance else None
|
|
112
|
+
if not isinstance(annotations, dict):
|
|
113
|
+
return ()
|
|
114
|
+
target = snowflake_target_name(contract)
|
|
115
|
+
steps: list[SnowflakeAnnotationStep] = []
|
|
116
|
+
table = _mapping(annotations.get("table"))
|
|
117
|
+
description = table.get("description")
|
|
118
|
+
if description:
|
|
119
|
+
steps.append(_step("table", "description", None, "description", str(description), f"COMMENT ON TABLE {target} IS {sql_string(description)}"))
|
|
120
|
+
steps.extend(_tag_steps(target=target, column=None, tags=_table_tags(table)))
|
|
121
|
+
for column, config in _mapping(annotations.get("columns")).items():
|
|
122
|
+
steps.extend(_column_steps(target=target, column=str(column), config=_mapping(config)))
|
|
123
|
+
return tuple(steps)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _column_steps(*, target: str, column: str, config: dict[str, Any]) -> tuple[SnowflakeAnnotationStep, ...]:
|
|
127
|
+
steps: list[SnowflakeAnnotationStep] = []
|
|
128
|
+
description = config.get("description")
|
|
129
|
+
if description:
|
|
130
|
+
quoted_column = _qualified_column(target, column)
|
|
131
|
+
steps.append(
|
|
132
|
+
_step(
|
|
133
|
+
"column",
|
|
134
|
+
"description",
|
|
135
|
+
column,
|
|
136
|
+
"description",
|
|
137
|
+
str(description),
|
|
138
|
+
f"COMMENT ON COLUMN {quoted_column} IS {sql_string(description)}",
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
steps.extend(_tag_steps(target=target, column=column, tags=_column_tags(config)))
|
|
142
|
+
return tuple(steps)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _tag_steps(*, target: str, column: str | None, tags: dict[str, str]) -> tuple[SnowflakeAnnotationStep, ...]:
|
|
146
|
+
builder = _column_tag_sql if column else _table_tag_sql
|
|
147
|
+
scope = "column" if column else "table"
|
|
148
|
+
return tuple(
|
|
149
|
+
_step(scope, "tag", column, key, value, builder(target, column, key, value))
|
|
150
|
+
for key, value in tags.items()
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _table_tag_sql(target: str, _column: str | None, key: str, value: str) -> str:
|
|
155
|
+
return f"ALTER TABLE {target} SET TAG {_tag_name(key)} = {sql_string(value)}"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _column_tag_sql(target: str, column: str | None, key: str, value: str) -> str:
|
|
159
|
+
if column is None:
|
|
160
|
+
raise ValueError("column tag SQL requires a column")
|
|
161
|
+
return f"ALTER TABLE {target} ALTER COLUMN {quote_identifier(column)} SET TAG {_tag_name(key)} = {sql_string(value)}"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _step(scope: str, annotation_type: str, column: str | None, key: str, value: str, sql: str) -> SnowflakeAnnotationStep:
|
|
165
|
+
return SnowflakeAnnotationStep(
|
|
166
|
+
scope=scope,
|
|
167
|
+
annotation_type=annotation_type,
|
|
168
|
+
column_name=column,
|
|
169
|
+
key=key,
|
|
170
|
+
value=value,
|
|
171
|
+
sql=sql,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _qualified_column(target: str, column: str) -> str:
|
|
176
|
+
return f"{target}.{quote_identifier(column)}"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _annotation_policy(contract: SemanticContract) -> str:
|
|
180
|
+
annotations = contract.governance.annotations if contract.governance else None
|
|
181
|
+
value = annotations.get("policy") if isinstance(annotations, dict) else None
|
|
182
|
+
return str(value or "warn").lower()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _tag_mode(contract: SemanticContract) -> str:
|
|
186
|
+
annotations = contract.governance.annotations if contract.governance else None
|
|
187
|
+
snowflake = snowflake_extensions(contract)
|
|
188
|
+
value = snowflake.get("annotation_tag_mode") or snowflake.get("tag_mode")
|
|
189
|
+
if value is not None:
|
|
190
|
+
return str(value).lower()
|
|
191
|
+
if not isinstance(annotations, dict):
|
|
192
|
+
return "apply"
|
|
193
|
+
return str(annotations.get("tags_mode") or "apply").lower()
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _tag_name(key: str) -> str:
|
|
197
|
+
return quote_multipart_identifier(key) if "." in key else quote_identifier(key)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _table_tags(table: dict[str, Any]) -> dict[str, str]:
|
|
201
|
+
return _tags(table, _TABLE_TAG_EXTRACTORS)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _column_tags(config: dict[str, Any]) -> dict[str, str]:
|
|
205
|
+
return _tags(config, _COLUMN_TAG_EXTRACTORS)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _tags(data: dict[str, Any], extractors: tuple[_TagExtractor, ...]) -> dict[str, str]:
|
|
209
|
+
tags: dict[str, str] = {}
|
|
210
|
+
for extractor in extractors:
|
|
211
|
+
tags.update({f"{extractor.prefix}{key}": value for key, value in extractor.extract(data).items()})
|
|
212
|
+
return tags
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _str_map(value: object) -> dict[str, str]:
|
|
216
|
+
return {str(key): _tag_value(item) for key, item in _mapping(value).items()}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _indexed_tags(value: object) -> dict[str, str]:
|
|
220
|
+
return {str(idx): item for idx, item in enumerate(_as_list(value), start=1)}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _deprecated_tags(value: object) -> dict[str, str]:
|
|
224
|
+
deprecated = _mapping(value)
|
|
225
|
+
if not deprecated:
|
|
226
|
+
return {}
|
|
227
|
+
payload = {"enabled": "true"}
|
|
228
|
+
payload.update({key: str(deprecated[key]) for key in ("since", "replacement", "removal_date") if deprecated.get(key)})
|
|
229
|
+
return payload
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _pii_tags(value: object) -> dict[str, str]:
|
|
233
|
+
pii = _mapping(value)
|
|
234
|
+
if not pii:
|
|
235
|
+
return {}
|
|
236
|
+
return {
|
|
237
|
+
"enabled": _tag_value(pii.get("enabled", True)),
|
|
238
|
+
"type": str(pii.get("type", "unknown")),
|
|
239
|
+
"sensitivity": str(pii.get("sensitivity", "internal")),
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _tag_value(value: object) -> str:
|
|
244
|
+
return str(value).lower() if isinstance(value, bool) else str(value)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
_TABLE_TAG_EXTRACTORS: tuple[_TagExtractor, ...] = (
|
|
248
|
+
_TagExtractor("", lambda data: _str_map(data.get("tags"))),
|
|
249
|
+
_TagExtractor("alias_", lambda data: _indexed_tags(data.get("aliases"))),
|
|
250
|
+
_TagExtractor("deprecated_", lambda data: _deprecated_tags(data.get("deprecated"))),
|
|
251
|
+
)
|
|
252
|
+
_COLUMN_TAG_EXTRACTORS: tuple[_TagExtractor, ...] = (
|
|
253
|
+
*_TABLE_TAG_EXTRACTORS,
|
|
254
|
+
_TagExtractor("pii_", lambda data: _pii_tags(data.get("pii"))),
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
__all__ = ["SnowflakeAnnotationResult", "annotation_steps", "apply_snowflake_annotations"]
|