test_rule_polars.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. from __future__ import annotations
  2. import copy
  3. import polars as pl
  4. import pytest
  5. from app.core.common.identifiers import new_governance_uid
  6. from app.runner.nodes import NodeExecutionError
  7. from tests.core.data_rules.test_polars_compiler import (
  8. _backend,
  9. _binding,
  10. _published_rule,
  11. _schema,
  12. )
  13. from tests.runner.test_artifacts import FakeMinio, _store
  14. class Resolver:
  15. def __init__(self, values):
  16. self.values = values
  17. self.calls = []
  18. def resolve(self, *, binding_id, correlation_id):
  19. self.calls.append((binding_id, correlation_id))
  20. return self.values[binding_id]
  21. def _compiled_plan(steps):
  22. from app.core.data_rules.compilers.polars import PolarsRuleCompiler
  23. fields = [
  24. ("customer_id", "integer", False),
  25. ("name", "string", True),
  26. ("mobile", "string", True),
  27. ]
  28. input_schema = _schema("bd:customer:raw", fields)
  29. output_schema = _schema("bd:customer:clean", fields)
  30. input_binding = _binding(input_schema, access_mode="read")
  31. output_binding = _binding(output_schema, access_mode="write")
  32. rule = _published_rule(input_schema, output_schema, steps)
  33. compiled = PolarsRuleCompiler().compile(
  34. rule_version=rule,
  35. input_schema=input_schema,
  36. output_schema=output_schema,
  37. input_binding=input_binding,
  38. output_binding=output_binding,
  39. backend=_backend(),
  40. )
  41. return compiled, input_binding
  42. def _node(compiled):
  43. return {
  44. "id": "task5_polars",
  45. "type": "rule.apply",
  46. "purpose": "write",
  47. "idempotency": {
  48. "strategy": "deduplication_key",
  49. "key": "customer_id",
  50. },
  51. "config": {
  52. "component_binding_id": new_governance_uid(),
  53. "rule_version_id": compiled["plan"]["rule_version_id"],
  54. "execution_plan_hash": compiled["plan_hash"],
  55. },
  56. }
  57. def test_polars_adapter_reconstructs_assert_and_deduplicate_and_writes_artifact():
  58. from app.runner.rule_polars import PolarsRulePlanAdapter
  59. compiled, input_binding = _compiled_plan(
  60. [
  61. {
  62. "id": "trim_name",
  63. "op": "normalize_text",
  64. "column": "name",
  65. "trim": True,
  66. },
  67. {
  68. "id": "mobile_format",
  69. "op": "assert",
  70. "expression": "matches(mobile, '^[0-9]{11}$')",
  71. "on_failure": "reject",
  72. "severity": "error",
  73. },
  74. {
  75. "id": "one_customer",
  76. "op": "deduplicate",
  77. "keys": ["customer_id"],
  78. "order_by": ["name"],
  79. "keep": "first",
  80. },
  81. ]
  82. )
  83. store = _store(FakeMinio())
  84. correlation_id = new_governance_uid()
  85. source = store.write(
  86. pl.DataFrame(
  87. {
  88. "customer_id": [1, 1, 2],
  89. "name": [" Alice ", "Alice B", " Bad "],
  90. "mobile": ["13800138000", "13800138000", "invalid"],
  91. }
  92. ).lazy(),
  93. correlation_id,
  94. 600,
  95. )
  96. resolver = Resolver(
  97. {
  98. input_binding["id"]: {
  99. **source,
  100. "binding_hash": compiled["plan"]["input_binding_hash"],
  101. }
  102. }
  103. )
  104. adapter = PolarsRulePlanAdapter(
  105. artifact_store=store,
  106. artifact_resolver=resolver,
  107. masking_policies={
  108. "customer_mobile_last4": "preserve_last_4"
  109. },
  110. artifact_ttl_seconds=300,
  111. )
  112. result = adapter.execute(
  113. plan=compiled["plan"],
  114. node=_node(compiled),
  115. parameters={},
  116. write_authorized=True,
  117. correlation_id=correlation_id,
  118. )
  119. assert result["rows_in"] == 3
  120. assert result["rows_out"] == 1
  121. assert result["rows_rejected"] == 2
  122. assert result["violation_count"] == 1
  123. assert result["violations"] == [
  124. {"step_id": "mobile_format", "count": 1}
  125. ]
  126. assert result["commit_outcome"] == "committed"
  127. assert store.read(
  128. result["artifact_ref"], result["digest"]
  129. ).collect().to_dicts() == [
  130. {
  131. "customer_id": 1,
  132. "name": "Alice",
  133. "mobile": "13800138000",
  134. }
  135. ]
  136. def test_polars_adapter_fails_closed_for_plan_hash_binding_and_authorization():
  137. from app.runner.rule_polars import PolarsRulePlanAdapter
  138. compiled, input_binding = _compiled_plan(
  139. [
  140. {
  141. "id": "trim_name",
  142. "op": "normalize_text",
  143. "column": "name",
  144. "trim": True,
  145. }
  146. ]
  147. )
  148. store = _store(FakeMinio())
  149. correlation_id = new_governance_uid()
  150. source = store.write(
  151. pl.DataFrame(
  152. {"customer_id": [1], "name": [" A "], "mobile": ["1"]}
  153. ).lazy(),
  154. correlation_id,
  155. 600,
  156. )
  157. resolver = Resolver(
  158. {
  159. input_binding["id"]: {
  160. **source,
  161. "binding_hash": "0" * 64,
  162. }
  163. }
  164. )
  165. adapter = PolarsRulePlanAdapter(
  166. artifact_store=store,
  167. artifact_resolver=resolver,
  168. )
  169. node = _node(compiled)
  170. with pytest.raises(NodeExecutionError, match="authorization"):
  171. adapter.execute(
  172. plan=compiled["plan"],
  173. node=node,
  174. parameters={},
  175. write_authorized=False,
  176. correlation_id=correlation_id,
  177. )
  178. with pytest.raises(NodeExecutionError, match="binding"):
  179. adapter.execute(
  180. plan=compiled["plan"],
  181. node=node,
  182. parameters={},
  183. write_authorized=True,
  184. correlation_id=correlation_id,
  185. )
  186. resolver.values[input_binding["id"]]["binding_hash"] = compiled["plan"][
  187. "input_binding_hash"
  188. ]
  189. node["config"]["execution_plan_hash"] = "0" * 64
  190. with pytest.raises(NodeExecutionError, match="hash"):
  191. adapter.execute(
  192. plan=compiled["plan"],
  193. node=node,
  194. parameters={},
  195. write_authorized=True,
  196. correlation_id=correlation_id,
  197. )
  198. tampered = copy.deepcopy(compiled["plan"])
  199. tampered["operations"][0]["callable"] = "unsafe"
  200. node["config"]["execution_plan_hash"] = compiled["plan_hash"]
  201. with pytest.raises(NodeExecutionError, match="invalid"):
  202. adapter.execute(
  203. plan=tampered,
  204. node=node,
  205. parameters={},
  206. write_authorized=True,
  207. correlation_id=correlation_id,
  208. )
  209. def test_rule_executor_attests_polars_canonical_hashes_and_forwards_correlation():
  210. from app.runner.rules import RulePlanExecutor
  211. compiled, _input_binding = _compiled_plan(
  212. [
  213. {
  214. "id": "trim_name",
  215. "op": "normalize_text",
  216. "column": "name",
  217. "trim": True,
  218. }
  219. ]
  220. )
  221. node = _node(compiled)
  222. correlation_id = new_governance_uid()
  223. plan = compiled["plan"]
  224. record = {
  225. "component_binding_id": node["config"]["component_binding_id"],
  226. "rule_version_id": plan["rule_version_id"],
  227. "backend": "polars_batch",
  228. "compiler_version": compiled["compiler_version"],
  229. "plan": plan,
  230. "plan_hash": compiled["plan_hash"],
  231. "schema_hashes": {
  232. "rule_spec_hash": plan["rule_spec_hash"],
  233. "input_schema_snapshot_id": plan["input_schema_snapshot_id"],
  234. "input_schema_hash": plan["input_schema_hash"],
  235. "output_schema_snapshot_id": plan["output_schema_snapshot_id"],
  236. "output_schema_hash": plan["output_schema_hash"],
  237. },
  238. "canonical_rule_spec_hash": plan["rule_spec_hash"],
  239. "canonical_input_schema_snapshot_id": plan[
  240. "input_schema_snapshot_id"
  241. ],
  242. "canonical_input_schema_hash": plan["input_schema_hash"],
  243. "canonical_output_schema_snapshot_id": plan[
  244. "output_schema_snapshot_id"
  245. ],
  246. "canonical_output_schema_hash": plan["output_schema_hash"],
  247. "plan_status": "published",
  248. "rule_status": "published",
  249. "component_kind": "rule.apply",
  250. "binding_idempotency": node["idempotency"],
  251. }
  252. class Repository:
  253. def load(self, **_kwargs):
  254. return record
  255. class Adapter:
  256. def __init__(self):
  257. self.kwargs = None
  258. def execute(self, **kwargs):
  259. self.kwargs = kwargs
  260. return {"rows_in": 1, "rows_out": 1, "rows_rejected": 0}
  261. adapter = Adapter()
  262. result = RulePlanExecutor(
  263. Repository(), adapters={"polars_batch": adapter}
  264. ).execute(
  265. node,
  266. {},
  267. write_authorized=True,
  268. correlation_id=correlation_id,
  269. )
  270. assert result["rows_out"] == 1
  271. assert adapter.kwargs["correlation_id"] == correlation_id
  272. record["canonical_input_schema_hash"] = "0" * 64
  273. with pytest.raises(NodeExecutionError, match="attestation"):
  274. RulePlanExecutor(
  275. Repository(), adapters={"polars_batch": adapter}
  276. ).execute(
  277. node,
  278. {},
  279. write_authorized=True,
  280. correlation_id=correlation_id,
  281. )
  282. def test_node_registry_forwards_trusted_correlation_context():
  283. from app.runner.nodes import NodeRegistry
  284. class Executor:
  285. def __init__(self):
  286. self.correlation_id = None
  287. def execute(self, _node, _parameters, **kwargs):
  288. self.correlation_id = kwargs["correlation_id"]
  289. return {"ok": True}
  290. executor = Executor()
  291. correlation_id = new_governance_uid()
  292. assert NodeRegistry({"rule.apply": executor}).execute(
  293. {"type": "rule.apply"},
  294. {},
  295. write_authorized=True,
  296. correlation_id=correlation_id,
  297. ) == {"ok": True}
  298. assert executor.correlation_id == correlation_id