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.
Files changed (95) hide show
  1. contractforge_snowflake/__init__.py +68 -0
  2. contractforge_snowflake/access/__init__.py +5 -0
  3. contractforge_snowflake/access/runtime.py +264 -0
  4. contractforge_snowflake/adapter.py +78 -0
  5. contractforge_snowflake/annotations/__init__.py +9 -0
  6. contractforge_snowflake/annotations/runtime.py +258 -0
  7. contractforge_snowflake/api.py +51 -0
  8. contractforge_snowflake/capabilities/__init__.py +17 -0
  9. contractforge_snowflake/capabilities/sql_warehouse.py +46 -0
  10. contractforge_snowflake/cli/__init__.py +79 -0
  11. contractforge_snowflake/cli/_helpers.py +132 -0
  12. contractforge_snowflake/cli/cost.py +52 -0
  13. contractforge_snowflake/cli/dashboard.py +34 -0
  14. contractforge_snowflake/cli/lineage.py +47 -0
  15. contractforge_snowflake/cli/maintenance.py +47 -0
  16. contractforge_snowflake/cli/plan.py +22 -0
  17. contractforge_snowflake/cli/project.py +84 -0
  18. contractforge_snowflake/cli/publish.py +54 -0
  19. contractforge_snowflake/cli/run.py +24 -0
  20. contractforge_snowflake/cli/smoke.py +112 -0
  21. contractforge_snowflake/connection_options.py +70 -0
  22. contractforge_snowflake/contract_extensions.py +52 -0
  23. contractforge_snowflake/cost/__init__.py +5 -0
  24. contractforge_snowflake/cost/reconciliation.py +210 -0
  25. contractforge_snowflake/dashboards/__init__.py +15 -0
  26. contractforge_snowflake/dashboards/control_tables.py +145 -0
  27. contractforge_snowflake/deployment/__init__.py +15 -0
  28. contractforge_snowflake/deployment/procedure.py +193 -0
  29. contractforge_snowflake/deployment/task_graph.py +211 -0
  30. contractforge_snowflake/diagnostics/__init__.py +13 -0
  31. contractforge_snowflake/diagnostics/portability.py +212 -0
  32. contractforge_snowflake/environment.py +61 -0
  33. contractforge_snowflake/evidence/__init__.py +34 -0
  34. contractforge_snowflake/evidence/ddl.py +84 -0
  35. contractforge_snowflake/evidence/writer.py +877 -0
  36. contractforge_snowflake/lineage/__init__.py +8 -0
  37. contractforge_snowflake/lineage/reconciliation.py +96 -0
  38. contractforge_snowflake/maintenance/__init__.py +15 -0
  39. contractforge_snowflake/maintenance/retention.py +84 -0
  40. contractforge_snowflake/naming/__init__.py +9 -0
  41. contractforge_snowflake/naming/identifiers.py +29 -0
  42. contractforge_snowflake/operations/__init__.py +5 -0
  43. contractforge_snowflake/operations/runtime.py +63 -0
  44. contractforge_snowflake/polling.py +14 -0
  45. contractforge_snowflake/preparation/__init__.py +11 -0
  46. contractforge_snowflake/preparation/registry.py +34 -0
  47. contractforge_snowflake/preparation/sql.py +360 -0
  48. contractforge_snowflake/publish/__init__.py +5 -0
  49. contractforge_snowflake/publish/bundle.py +112 -0
  50. contractforge_snowflake/rendering/__init__.py +5 -0
  51. contractforge_snowflake/rendering/review.py +132 -0
  52. contractforge_snowflake/runtime/__init__.py +54 -0
  53. contractforge_snowflake/runtime/artifacts.py +85 -0
  54. contractforge_snowflake/runtime/execution.py +520 -0
  55. contractforge_snowflake/runtime/project.py +627 -0
  56. contractforge_snowflake/runtime/publish.py +196 -0
  57. contractforge_snowflake/runtime/quality.py +242 -0
  58. contractforge_snowflake/runtime/runner.py +186 -0
  59. contractforge_snowflake/runtime/schema_policy.py +350 -0
  60. contractforge_snowflake/runtime/session.py +177 -0
  61. contractforge_snowflake/runtime/snowpark_handler.py +20 -0
  62. contractforge_snowflake/session_ops.py +60 -0
  63. contractforge_snowflake/smoke/__init__.py +26 -0
  64. contractforge_snowflake/smoke/failure_paths.py +75 -0
  65. contractforge_snowflake/smoke/minimal.py +75 -0
  66. contractforge_snowflake/smoke/models.py +362 -0
  67. contractforge_snowflake/smoke/procedure.py +273 -0
  68. contractforge_snowflake/smoke/runner.py +224 -0
  69. contractforge_snowflake/smoke/stage_publish.py +194 -0
  70. contractforge_snowflake/smoke/task_graph.py +298 -0
  71. contractforge_snowflake/sources/__init__.py +6 -0
  72. contractforge_snowflake/sources/models.py +15 -0
  73. contractforge_snowflake/sources/registry.py +36 -0
  74. contractforge_snowflake/sources/review.py +23 -0
  75. contractforge_snowflake/sources/sql.py +23 -0
  76. contractforge_snowflake/sources/stage_files.py +106 -0
  77. contractforge_snowflake/sources/table.py +25 -0
  78. contractforge_snowflake/sources/table_refs.py +81 -0
  79. contractforge_snowflake/sql.py +11 -0
  80. contractforge_snowflake/state/__init__.py +23 -0
  81. contractforge_snowflake/state/runtime.py +368 -0
  82. contractforge_snowflake/subtargets.py +27 -0
  83. contractforge_snowflake/values.py +55 -0
  84. contractforge_snowflake/write_modes/__init__.py +17 -0
  85. contractforge_snowflake/write_modes/append.py +12 -0
  86. contractforge_snowflake/write_modes/hash_diff.py +48 -0
  87. contractforge_snowflake/write_modes/models.py +41 -0
  88. contractforge_snowflake/write_modes/overwrite.py +12 -0
  89. contractforge_snowflake/write_modes/registry.py +77 -0
  90. contractforge_snowflake/write_modes/upsert.py +32 -0
  91. contractforge_snowflake/write_modes/validation.py +60 -0
  92. contractforge_snowflake-0.1.0.dist-info/METADATA +210 -0
  93. contractforge_snowflake-0.1.0.dist-info/RECORD +95 -0
  94. contractforge_snowflake-0.1.0.dist-info/WHEEL +4 -0
  95. 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,5 @@
1
+ """Snowflake access planning and runtime application."""
2
+
3
+ from contractforge_snowflake.access.runtime import SnowflakeAccessResult, access_steps, apply_snowflake_access
4
+
5
+ __all__ = ["SnowflakeAccessResult", "access_steps", "apply_snowflake_access"]
@@ -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"]