memframe 0.2.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 (114) hide show
  1. memframe/__init__.py +8 -0
  2. memframe/cache/__init__.py +10 -0
  3. memframe/cache/cache_manager.py +321 -0
  4. memframe/core/__init__.py +1 -0
  5. memframe/core/analytix/_response.py +89 -0
  6. memframe/core/analytix/arithmetic.py +847 -0
  7. memframe/core/analytix/cleaning.py +2948 -0
  8. memframe/core/analytix/inspection.py +1969 -0
  9. memframe/core/analytix/selection.py +952 -0
  10. memframe/core/analytix/stats.py +1766 -0
  11. memframe/core/ingestion/datatype_detector.py +315 -0
  12. memframe/core/ingestion/upload/__init__.py +21 -0
  13. memframe/core/ingestion/upload/base.py +643 -0
  14. memframe/core/ingestion/upload/clickhouse.py +140 -0
  15. memframe/core/ingestion/upload/duckdb.py +83 -0
  16. memframe/core/ingestion/upload/postgres.py +104 -0
  17. memframe/core/ingestion/upload/strategies/__init__.py +13 -0
  18. memframe/core/ingestion/upload/strategies/base.py +60 -0
  19. memframe/core/ingestion/upload/strategies/csv.py +485 -0
  20. memframe/core/ingestion/upload/strategies/df.py +122 -0
  21. memframe/core/ingestion/upload/strategies/parquet.py +106 -0
  22. memframe/core/orchestrator/analytix/arithmetic.py +213 -0
  23. memframe/core/orchestrator/analytix/cleaning.py +489 -0
  24. memframe/core/orchestrator/analytix/inspection.py +242 -0
  25. memframe/core/orchestrator/analytix/selection.py +303 -0
  26. memframe/core/orchestrator/analytix/stats.py +329 -0
  27. memframe/core/orchestrator/plots/bar.py +128 -0
  28. memframe/core/orchestrator/plots/bar_polar.py +109 -0
  29. memframe/core/orchestrator/plots/line.py +80 -0
  30. memframe/core/orchestrator/plots/pie.py +90 -0
  31. memframe/core/orchestrator/plots/scatter.py +77 -0
  32. memframe/core/orchestrator/plots/scatter_3d.py +126 -0
  33. memframe/core/plots/bar.py +252 -0
  34. memframe/core/plots/bar_polar.py +191 -0
  35. memframe/core/plots/line.py +213 -0
  36. memframe/core/plots/pie.py +177 -0
  37. memframe/core/plots/scatter.py +213 -0
  38. memframe/core/plots/scatter_3d.py +222 -0
  39. memframe/db_manager/__init__.py +1 -0
  40. memframe/db_manager/adapters/base.py +59 -0
  41. memframe/db_manager/adapters/clickhouse.py +347 -0
  42. memframe/db_manager/adapters/duckdb.py +92 -0
  43. memframe/db_manager/adapters/factory.py +27 -0
  44. memframe/db_manager/adapters/postgresql.py +105 -0
  45. memframe/db_manager/connection/__init__.py +13 -0
  46. memframe/db_manager/connection/connector.py +82 -0
  47. memframe/db_manager/connection/pool.py +268 -0
  48. memframe/db_manager/context.py +152 -0
  49. memframe/db_manager/context.pyi +56 -0
  50. memframe/db_manager/ops.py +189 -0
  51. memframe/db_manager/setup/__init__.py +26 -0
  52. memframe/db_manager/setup/base.py +196 -0
  53. memframe/db_manager/setup/clickhouse.py +129 -0
  54. memframe/db_manager/setup/duckdb.py +97 -0
  55. memframe/db_manager/setup/postgres.py +98 -0
  56. memframe/exceptions.py +25 -0
  57. memframe/main.py +205 -0
  58. memframe/main.pyi +59 -0
  59. memframe/py.typed +0 -0
  60. memframe/utils/__init__.py +1 -0
  61. memframe/utils/async_sync.py +43 -0
  62. memframe/utils/helper.py +291 -0
  63. memframe/utils/plot_renderer.py +126 -0
  64. memframe/wrappers/__init__.py +1 -0
  65. memframe/wrappers/analytix/__init__.py +0 -0
  66. memframe/wrappers/analytix/arithmetic.py +546 -0
  67. memframe/wrappers/analytix/arithmetic.pyi +366 -0
  68. memframe/wrappers/analytix/cleaning.py +506 -0
  69. memframe/wrappers/analytix/cleaning.pyi +230 -0
  70. memframe/wrappers/analytix/inspection.py +525 -0
  71. memframe/wrappers/analytix/inspection.pyi +186 -0
  72. memframe/wrappers/analytix/selection.py +302 -0
  73. memframe/wrappers/analytix/selection.pyi +136 -0
  74. memframe/wrappers/analytix/stats.py +487 -0
  75. memframe/wrappers/analytix/stats.pyi +264 -0
  76. memframe/wrappers/base.py +2 -0
  77. memframe/wrappers/plots/__init__.py +0 -0
  78. memframe/wrappers/plots/bar.py +203 -0
  79. memframe/wrappers/plots/bar.pyi +151 -0
  80. memframe/wrappers/plots/bar_polar.py +131 -0
  81. memframe/wrappers/plots/bar_polar.pyi +119 -0
  82. memframe/wrappers/plots/line.py +105 -0
  83. memframe/wrappers/plots/line.pyi +80 -0
  84. memframe/wrappers/plots/pie.py +123 -0
  85. memframe/wrappers/plots/pie.pyi +92 -0
  86. memframe/wrappers/plots/scatter.py +100 -0
  87. memframe/wrappers/plots/scatter.pyi +78 -0
  88. memframe/wrappers/plots/scatter3d.py +155 -0
  89. memframe/wrappers/plots/scatter3d.pyi +148 -0
  90. memframe-0.2.0.dist-info/METADATA +197 -0
  91. memframe-0.2.0.dist-info/RECORD +114 -0
  92. memframe-0.2.0.dist-info/WHEEL +4 -0
  93. memframe-0.2.0.dist-info/licenses/LICENSE +661 -0
  94. memframe_ai/__init__.py +4 -0
  95. memframe_ai/agents/__init__.py +18 -0
  96. memframe_ai/agents/analytics.py +316 -0
  97. memframe_ai/agents/planning.py +123 -0
  98. memframe_ai/config.py +14 -0
  99. memframe_ai/domain.py +126 -0
  100. memframe_ai/entrypoints.py +33 -0
  101. memframe_ai/gateway.py +75 -0
  102. memframe_ai/observe.py +112 -0
  103. memframe_ai/sessions.py +194 -0
  104. memframe_ai/tools/__init__.py +11 -0
  105. memframe_ai/tools/_helpers.py +61 -0
  106. memframe_ai/tools/arithmetic.py +141 -0
  107. memframe_ai/tools/clean.py +132 -0
  108. memframe_ai/tools/context.py +19 -0
  109. memframe_ai/tools/inspect.py +175 -0
  110. memframe_ai/tools/plot.py +149 -0
  111. memframe_ai/tools/select.py +85 -0
  112. memframe_ai/tools/stats.py +179 -0
  113. memframe_ai/tools/upload.py +16 -0
  114. memframe_ai/wrappers.py +34 -0
memframe/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """memFrame - Database-backed DataFrame operations."""
2
+
3
+ from .main import MemFrame
4
+ from .core.ingestion.datatype_detector import Backend
5
+ from .db_manager.setup import DatabaseBackend
6
+ from .db_manager.context import ContextManager
7
+
8
+ __all__ = ["MemFrame", "Backend", "DatabaseBackend", "ContextManager"]
@@ -0,0 +1,10 @@
1
+ """Method-call caching for memFrame.
2
+
3
+ The ``record_call`` decorator (a ``CacheManager`` instance) implements the
4
+ two-level data cache used by the orchestrator layer: signature-only logging by
5
+ default, and persistent transient table storage when ``deep_cache`` is enabled.
6
+ """
7
+
8
+ from .cache_manager import CacheManager, record_call
9
+
10
+ __all__ = ["CacheManager", "record_call"]
@@ -0,0 +1,321 @@
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ import time
5
+ from functools import wraps
6
+ from typing import Any, Callable, Dict, Optional
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+ import pyarrow as pa
11
+
12
+ from memframe.core.ingestion.datatype_detector import Backend
13
+ from memframe.exceptions import OperationError
14
+
15
+ logger = logging.getLogger("memFrame.cache")
16
+
17
+
18
+ class CacheManager:
19
+ """Two-level method-call cache backed by the transient registry.
20
+
21
+ Level 1 (default, deep_cache=False): records arg/kwarg signatures only —
22
+ an audit/lineage log; repeat calls re-execute (no table to replay).
23
+ Level 2 (deep, deep_cache=True): persists result DataFrames as typed
24
+ transient tables; repeat calls load the table and skip execution.
25
+ Resolution order: decorator arg -> MemFrame.deep_cache -> False.
26
+
27
+ The instance itself is the ``record_call`` decorator:
28
+ @record_call
29
+ @record_call()
30
+ @record_call(deep_cache=True)
31
+ """
32
+
33
+ # ── cache key (signature) ──────────────────────────────────────
34
+ @staticmethod
35
+ def _json_default(o: Any) -> Any:
36
+ if isinstance(o, np.generic):
37
+ return o.item()
38
+ if isinstance(o, np.ndarray):
39
+ return o.tolist()
40
+ if isinstance(o, pd.DataFrame):
41
+ return {"__dataframe__": list(o.shape), "columns": list(o.columns)}
42
+ if isinstance(o, pd.Series):
43
+ return {"__series__": list(o.shape), "name": str(o.name)}
44
+ return f"<{type(o).__name__}>"
45
+
46
+ def _signature(self, value: Any) -> str:
47
+ return json.dumps(value, sort_keys=True, default=self._json_default)
48
+
49
+ def _qualify(self, schema: str, bare: str, backend) -> str:
50
+ if getattr(backend, "backend", None) == Backend.CLICKHOUSE:
51
+ return f"`{schema}`.`{bare}`"
52
+ return f'{schema}."{bare}"'
53
+
54
+ # ── table load / store ─────────────────────────────────────────
55
+ async def _load_generated_table(self, backend, qualified: str) -> Optional[pd.DataFrame]:
56
+ start = time.perf_counter()
57
+ try:
58
+ rows = await backend.fetch(f"SELECT * FROM {qualified}")
59
+ if not rows:
60
+ return None
61
+ # ponytail: per-backend column name query — real backend difference
62
+ be = getattr(backend, "backend", None)
63
+ if be == Backend.POSTGRES:
64
+ schema, table = qualified.replace('"', "").split(".")
65
+ col_rows = await backend.fetch(
66
+ "SELECT column_name FROM information_schema.columns "
67
+ "WHERE table_schema = $1 AND table_name = $2",
68
+ schema.strip(), table.strip(),
69
+ )
70
+ col_names = [r[0] for r in col_rows]
71
+ elif be == Backend.CLICKHOUSE:
72
+ col_rows = await backend.fetch(f"DESCRIBE TABLE {qualified}")
73
+ col_names = [r[0] for r in col_rows]
74
+ else:
75
+ col_rows = await backend.fetch(f"DESCRIBE {qualified}")
76
+ col_names = [r[0] for r in col_rows]
77
+ df = pd.DataFrame(rows, columns=col_names)
78
+ logger.debug(
79
+ "[cache] reloaded %s (%d rows, %.3fs)", qualified, len(df),
80
+ time.perf_counter() - start,
81
+ )
82
+ return df
83
+ except Exception as exc:
84
+ logger.warning("[cache] reload failed for %s: %s", qualified, exc)
85
+ return None
86
+
87
+ async def _create_deep_cache_table(self, mf, backend, data_id: str, df: pd.DataFrame) -> str:
88
+ """Persist a DataFrame as a typed transient table (deep-cache payload)."""
89
+ op = await backend.fetchval(
90
+ f"SELECT COALESCE(MAX(opidx), 0) FROM {backend.transient_registry_table} "
91
+ f"WHERE data_id = {backend.placeholder(1)}", data_id,
92
+ )
93
+ op = (op or 0) + 1
94
+ qualified = backend.get_transient_table_name(data_id, op)
95
+ arrow = pa.Table.from_pandas(df, preserve_index=False)
96
+ uploader = mf._uploader
97
+ schema = {}
98
+ for i, name in enumerate(arrow.schema.names):
99
+ pg_type = uploader._arrow_type_to_postgres(arrow.schema.field(i).type)
100
+ schema[name] = {
101
+ "postgres_type": pg_type,
102
+ "clickhouse_type": uploader._postgres_type_to_clickhouse(pg_type),
103
+ "is_nullable": True,
104
+ }
105
+ await uploader._create_final_table_typed(qualified, arrow.schema.names, schema)
106
+ await uploader._insert_arrow_table(qualified, arrow)
107
+ return qualified
108
+
109
+ def _make_hit_response(self, payload: Dict[str, Any], cached: pd.DataFrame, bare_name: str, schema: str) -> Dict[str, Any]:
110
+ qualified = self._qualify(schema, bare_name, payload.get("_backend"))
111
+ return {
112
+ "is_error": False,
113
+ "message": (
114
+ f"Cache hit for {payload['class_name']}."
115
+ f"{payload['method_name']}; "
116
+ f"reused generated table '{bare_name}'"
117
+ ),
118
+ "error_message": None,
119
+ "involved_cols": [],
120
+ "generated_cols": list(cached.columns),
121
+ "result": cached,
122
+ "new_table": bare_name,
123
+ "result_metadata": {
124
+ "from_cache": True,
125
+ "saved_table": qualified,
126
+ "row_count": len(cached),
127
+ "column_count": len(cached.columns),
128
+ "strict_args_kwargs_match": True,
129
+ },
130
+ }
131
+
132
+ # ── decorator ──────────────────────────────────────────────────
133
+ def __call__(self, func: Optional[Callable] = None, deep_cache: Optional[bool] = None):
134
+ if func is not None:
135
+ return self._make_decorator(func, deep_cache)
136
+ return lambda f: self._make_decorator(f, deep_cache)
137
+
138
+ def _make_writer(self, mf):
139
+ async def writer(payload, data_id, generated_table_name=None, is_deep_cache=False, schema=None):
140
+ await mf._arecord_method_call(
141
+ data_id=data_id,
142
+ class_name=payload["class_name"],
143
+ method_name=payload["method_name"],
144
+ args_sig=payload["args_sig"],
145
+ kwargs_sig=payload["kwargs_sig"],
146
+ generated_table_name=generated_table_name,
147
+ is_deep_cache=is_deep_cache,
148
+ schema=schema,
149
+ )
150
+ return writer
151
+
152
+ def _make_decorator(self, func: Callable, decorator_deep_cache: Optional[bool]):
153
+ manager = self
154
+
155
+ if asyncio.iscoroutinefunction(func):
156
+ @wraps(func)
157
+ async def async_wrapper(self, *args, **kwargs):
158
+ mf = getattr(self, "_memframe", None)
159
+ if mf is None:
160
+ raise OperationError(
161
+ f"Cannot cache {func.__qualname__}: instance lacks `_memframe`. "
162
+ "Inherit from LoggableMixin or set `self._memframe`."
163
+ )
164
+
165
+ data_id = getattr(self, "_data_id", None) or mf._active_id
166
+ if not data_id:
167
+ raise OperationError(
168
+ f"Cannot cache {func.__qualname__}: no data_id available."
169
+ )
170
+
171
+ # ponytail: master switch — MemFrame(deep_cache=False) overrides all
172
+ mf_deep = getattr(mf, "deep_cache", None)
173
+ is_deep = False
174
+ if decorator_deep_cache is not None:
175
+ is_deep = decorator_deep_cache
176
+ elif mf_deep is True:
177
+ is_deep = True
178
+ if mf_deep is False:
179
+ is_deep = False
180
+ backend = getattr(mf, "_backend", None)
181
+
182
+ writer = getattr(self, "_call_writer", None)
183
+ if writer is None:
184
+ writer = manager._make_writer(mf)
185
+ self._call_writer = writer
186
+
187
+ payload = {
188
+ "class_name": self.__class__.__name__,
189
+ "method_name": func.__name__,
190
+ "args_sig": manager._signature(args),
191
+ "kwargs_sig": manager._signature(kwargs),
192
+ "_backend": backend,
193
+ }
194
+
195
+ # --- Lookup: only for deep cache entries ---
196
+ if is_deep and backend:
197
+ method = f"{payload['class_name']}.{payload['method_name']}"
198
+ logger.debug(
199
+ "[cache] LOOKUP %s data_id=%s args=%s kwargs=%s",
200
+ method, data_id, payload["args_sig"], payload["kwargs_sig"],
201
+ )
202
+ lookup_start = time.perf_counter()
203
+ row = await backend.fetch_row(
204
+ f"""
205
+ SELECT generated_table_name, schema
206
+ FROM {backend.transient_registry_table}
207
+ WHERE data_id = {backend.placeholder(1)}
208
+ AND operation_type = 'method_call'
209
+ AND class_name = {backend.placeholder(2)}
210
+ AND method_name = {backend.placeholder(3)}
211
+ AND args = {backend.placeholder(4)}
212
+ AND kwargs = {backend.placeholder(5)}
213
+ AND {backend.backend == "clickhouse" and "CAST(is_deep_cache AS UInt8) = 1" or "is_deep_cache = TRUE"}
214
+ AND generated_table_name IS NOT NULL
215
+ ORDER BY opidx DESC LIMIT 1
216
+ """,
217
+ data_id,
218
+ payload["class_name"],
219
+ payload["method_name"],
220
+ payload["args_sig"],
221
+ payload["kwargs_sig"],
222
+ )
223
+ if row:
224
+ bare, sch = row[0], row[1]
225
+ sch = sch or backend.transient_schema
226
+ cached = await manager._load_generated_table(backend, manager._qualify(sch, bare, backend))
227
+ if cached is not None:
228
+ logger.info(
229
+ "[cache] HIT %s data_id=%s → %s (%d rows, lookup+reload %.3fs)",
230
+ method, data_id, bare, len(cached),
231
+ time.perf_counter() - lookup_start,
232
+ )
233
+ return manager._make_hit_response(payload, cached, bare, sch)
234
+ logger.info(
235
+ "[cache] MISS %s data_id=%s — registry row found but reload failed for %s",
236
+ method, data_id, bare,
237
+ )
238
+ else:
239
+ logger.info(
240
+ "[cache] MISS %s data_id=%s — no matching registry row",
241
+ method, data_id,
242
+ )
243
+
244
+ # --- Execute ---
245
+ result = await func(self, *args, **kwargs)
246
+ generated_table_name = None
247
+ schema = None
248
+
249
+ if is_deep and isinstance(result, dict) and not result.get("is_error", False):
250
+ bare_table = result.get("new_table") or result.get("generated_table_name")
251
+ if bare_table and backend:
252
+ if await backend.table_exists(manager._qualify(backend.transient_schema, bare_table, backend)):
253
+ generated_table_name = bare_table
254
+ schema = backend.transient_schema
255
+ elif await backend.table_exists(manager._qualify("transient", bare_table, backend)):
256
+ generated_table_name = bare_table
257
+ schema = "transient"
258
+ elif await backend.table_exists(manager._qualify(backend.upload_schema, bare_table, backend)):
259
+ be = getattr(backend, "backend", None)
260
+ if be == Backend.CLICKHOUSE:
261
+ await backend.execute(
262
+ f"RENAME TABLE {manager._qualify(backend.upload_schema, bare_table, backend)} "
263
+ f"TO {manager._qualify(backend.transient_schema, bare_table, backend)}"
264
+ )
265
+ elif be == Backend.DUCKDB:
266
+ await backend.execute(
267
+ f"CREATE TABLE {manager._qualify(backend.transient_schema, bare_table, backend)} "
268
+ f"AS SELECT * FROM {manager._qualify(backend.upload_schema, bare_table, backend)}"
269
+ )
270
+ await backend.execute(
271
+ f"DROP TABLE {manager._qualify(backend.upload_schema, bare_table, backend)}"
272
+ )
273
+ else:
274
+ await backend.execute(
275
+ f"ALTER TABLE {manager._qualify(backend.upload_schema, bare_table, backend)} "
276
+ f"SET SCHEMA {backend.transient_schema}"
277
+ )
278
+ generated_table_name = bare_table
279
+ schema = backend.transient_schema
280
+ elif "result" in result:
281
+ df = result["result"]
282
+ if isinstance(df, pd.DataFrame) and not df.empty and backend:
283
+ qualified = await manager._create_deep_cache_table(
284
+ mf, backend, data_id, df
285
+ )
286
+ if qualified:
287
+ _sch, _bare = backend._split_qualified_table_name(qualified)
288
+ generated_table_name = _bare
289
+ schema = _sch
290
+ elif not is_deep and isinstance(result, dict) and not result.get("is_error", False):
291
+ # ponytail: drop any table the method created — deep_cache=False means no tables
292
+ bare_table = result.get("new_table") or result.get("generated_table_name")
293
+ if bare_table and backend:
294
+ for sch in (backend.transient_schema, "transient", backend.upload_schema):
295
+ q = manager._qualify(sch, bare_table, backend)
296
+ if q and await backend.table_exists(q):
297
+ await backend.drop_table(q)
298
+ break
299
+
300
+ await writer(payload, data_id, generated_table_name, is_deep_cache=is_deep, schema=schema)
301
+ if is_deep and generated_table_name:
302
+ logger.info(
303
+ "[cache] STORE %s.%s data_id=%s → %s.%s (deep)",
304
+ payload["class_name"], payload["method_name"],
305
+ data_id, schema, generated_table_name,
306
+ )
307
+ elif not is_deep:
308
+ logger.debug(
309
+ "[cache] STORE %s.%s data_id=%s (signature-only)",
310
+ payload["class_name"], payload["method_name"], data_id,
311
+ )
312
+ return result
313
+ return async_wrapper
314
+ else:
315
+ @wraps(func)
316
+ def sync_wrapper(self, *args, **kwargs):
317
+ return func(self, *args, **kwargs)
318
+ return sync_wrapper
319
+
320
+
321
+ record_call = CacheManager()
@@ -0,0 +1 @@
1
+ """Core operations package for memFrame."""
@@ -0,0 +1,89 @@
1
+ """Shared response envelope for analytix operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+ from memframe.exceptions import OperationError
10
+
11
+
12
+ class OperationResponse(BaseModel):
13
+ """Validated internal representation of an operation response."""
14
+
15
+ model_config = ConfigDict(
16
+ extra="allow",
17
+ arbitrary_types_allowed=True,
18
+ )
19
+
20
+ is_error: bool
21
+ message: str = ""
22
+ error_message: str | None = None
23
+ involved_cols: list[str] = Field(default_factory=list)
24
+ generated_cols: list[str] = Field(default_factory=list)
25
+ result: Any = None
26
+
27
+ def to_payload(self) -> dict[str, Any]:
28
+ """Return the existing dictionary API payload without dropping None."""
29
+ return self.model_dump(exclude_none=False)
30
+
31
+
32
+ def is_operation_response(value: Any) -> bool:
33
+ """Return whether a value is a canonical analytix response payload."""
34
+ return isinstance(value, dict) and {
35
+ "is_error",
36
+ "error_message",
37
+ "result",
38
+ }.issubset(value)
39
+
40
+
41
+ def unwrap_response(value: Any) -> Any:
42
+ """Return a public operation value or raise its operation error."""
43
+ if not is_operation_response(value):
44
+ return value
45
+ if value["is_error"]:
46
+ raise OperationError(
47
+ value.get("error_message") or value.get("message") or "Operation failed"
48
+ )
49
+ if value.get("result") is None and value.get("iterator") is not None:
50
+ return value["iterator"]
51
+ return value["result"]
52
+
53
+
54
+ # Keep the argument order used by the existing analytix response builders.
55
+ def ok(
56
+ message: str = "",
57
+ involved_cols: list[str] | None = None,
58
+ generated_cols: list[str] | None = None,
59
+ result: Any = None,
60
+ **extra: Any,
61
+ ) -> dict[str, Any]:
62
+ """Validate and build a successful response payload."""
63
+ return OperationResponse(
64
+ is_error=False,
65
+ message=message,
66
+ error_message=None,
67
+ involved_cols=involved_cols or [],
68
+ generated_cols=generated_cols or [],
69
+ result=result,
70
+ **extra,
71
+ ).to_payload()
72
+
73
+
74
+ def fail(
75
+ error_message: str,
76
+ involved_cols: list[str] | None = None,
77
+ generated_cols: list[str] | None = None,
78
+ **extra: Any,
79
+ ) -> dict[str, Any]:
80
+ """Validate and build a failed response payload."""
81
+ return OperationResponse(
82
+ is_error=True,
83
+ message="",
84
+ error_message=error_message,
85
+ involved_cols=involved_cols or [],
86
+ generated_cols=generated_cols or [],
87
+ result=None,
88
+ **extra,
89
+ ).to_payload()