kumoai 2.14.0.dev202601011731__cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.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.

Potentially problematic release.


This version of kumoai might be problematic. Click here for more details.

Files changed (122) hide show
  1. kumoai/__init__.py +300 -0
  2. kumoai/_logging.py +29 -0
  3. kumoai/_singleton.py +25 -0
  4. kumoai/_version.py +1 -0
  5. kumoai/artifact_export/__init__.py +9 -0
  6. kumoai/artifact_export/config.py +209 -0
  7. kumoai/artifact_export/job.py +108 -0
  8. kumoai/client/__init__.py +5 -0
  9. kumoai/client/client.py +223 -0
  10. kumoai/client/connector.py +110 -0
  11. kumoai/client/endpoints.py +150 -0
  12. kumoai/client/graph.py +120 -0
  13. kumoai/client/jobs.py +471 -0
  14. kumoai/client/online.py +78 -0
  15. kumoai/client/pquery.py +207 -0
  16. kumoai/client/rfm.py +112 -0
  17. kumoai/client/source_table.py +53 -0
  18. kumoai/client/table.py +101 -0
  19. kumoai/client/utils.py +130 -0
  20. kumoai/codegen/__init__.py +19 -0
  21. kumoai/codegen/cli.py +100 -0
  22. kumoai/codegen/context.py +16 -0
  23. kumoai/codegen/edits.py +473 -0
  24. kumoai/codegen/exceptions.py +10 -0
  25. kumoai/codegen/generate.py +222 -0
  26. kumoai/codegen/handlers/__init__.py +4 -0
  27. kumoai/codegen/handlers/connector.py +118 -0
  28. kumoai/codegen/handlers/graph.py +71 -0
  29. kumoai/codegen/handlers/pquery.py +62 -0
  30. kumoai/codegen/handlers/table.py +109 -0
  31. kumoai/codegen/handlers/utils.py +42 -0
  32. kumoai/codegen/identity.py +114 -0
  33. kumoai/codegen/loader.py +93 -0
  34. kumoai/codegen/naming.py +94 -0
  35. kumoai/codegen/registry.py +121 -0
  36. kumoai/connector/__init__.py +31 -0
  37. kumoai/connector/base.py +153 -0
  38. kumoai/connector/bigquery_connector.py +200 -0
  39. kumoai/connector/databricks_connector.py +213 -0
  40. kumoai/connector/file_upload_connector.py +189 -0
  41. kumoai/connector/glue_connector.py +150 -0
  42. kumoai/connector/s3_connector.py +278 -0
  43. kumoai/connector/snowflake_connector.py +252 -0
  44. kumoai/connector/source_table.py +471 -0
  45. kumoai/connector/utils.py +1796 -0
  46. kumoai/databricks.py +14 -0
  47. kumoai/encoder/__init__.py +4 -0
  48. kumoai/exceptions.py +26 -0
  49. kumoai/experimental/__init__.py +0 -0
  50. kumoai/experimental/rfm/__init__.py +210 -0
  51. kumoai/experimental/rfm/authenticate.py +432 -0
  52. kumoai/experimental/rfm/backend/__init__.py +0 -0
  53. kumoai/experimental/rfm/backend/local/__init__.py +42 -0
  54. kumoai/experimental/rfm/backend/local/graph_store.py +297 -0
  55. kumoai/experimental/rfm/backend/local/sampler.py +312 -0
  56. kumoai/experimental/rfm/backend/local/table.py +113 -0
  57. kumoai/experimental/rfm/backend/snow/__init__.py +37 -0
  58. kumoai/experimental/rfm/backend/snow/sampler.py +297 -0
  59. kumoai/experimental/rfm/backend/snow/table.py +242 -0
  60. kumoai/experimental/rfm/backend/sqlite/__init__.py +32 -0
  61. kumoai/experimental/rfm/backend/sqlite/sampler.py +398 -0
  62. kumoai/experimental/rfm/backend/sqlite/table.py +184 -0
  63. kumoai/experimental/rfm/base/__init__.py +30 -0
  64. kumoai/experimental/rfm/base/column.py +152 -0
  65. kumoai/experimental/rfm/base/expression.py +44 -0
  66. kumoai/experimental/rfm/base/sampler.py +761 -0
  67. kumoai/experimental/rfm/base/source.py +19 -0
  68. kumoai/experimental/rfm/base/sql_sampler.py +143 -0
  69. kumoai/experimental/rfm/base/table.py +736 -0
  70. kumoai/experimental/rfm/graph.py +1237 -0
  71. kumoai/experimental/rfm/infer/__init__.py +19 -0
  72. kumoai/experimental/rfm/infer/categorical.py +40 -0
  73. kumoai/experimental/rfm/infer/dtype.py +82 -0
  74. kumoai/experimental/rfm/infer/id.py +46 -0
  75. kumoai/experimental/rfm/infer/multicategorical.py +48 -0
  76. kumoai/experimental/rfm/infer/pkey.py +128 -0
  77. kumoai/experimental/rfm/infer/stype.py +35 -0
  78. kumoai/experimental/rfm/infer/time_col.py +61 -0
  79. kumoai/experimental/rfm/infer/timestamp.py +41 -0
  80. kumoai/experimental/rfm/pquery/__init__.py +7 -0
  81. kumoai/experimental/rfm/pquery/executor.py +102 -0
  82. kumoai/experimental/rfm/pquery/pandas_executor.py +530 -0
  83. kumoai/experimental/rfm/relbench.py +76 -0
  84. kumoai/experimental/rfm/rfm.py +1184 -0
  85. kumoai/experimental/rfm/sagemaker.py +138 -0
  86. kumoai/experimental/rfm/task_table.py +231 -0
  87. kumoai/formatting.py +30 -0
  88. kumoai/futures.py +99 -0
  89. kumoai/graph/__init__.py +12 -0
  90. kumoai/graph/column.py +106 -0
  91. kumoai/graph/graph.py +948 -0
  92. kumoai/graph/table.py +838 -0
  93. kumoai/jobs.py +80 -0
  94. kumoai/kumolib.cpython-310-x86_64-linux-gnu.so +0 -0
  95. kumoai/mixin.py +28 -0
  96. kumoai/pquery/__init__.py +25 -0
  97. kumoai/pquery/prediction_table.py +287 -0
  98. kumoai/pquery/predictive_query.py +641 -0
  99. kumoai/pquery/training_table.py +424 -0
  100. kumoai/spcs.py +121 -0
  101. kumoai/testing/__init__.py +8 -0
  102. kumoai/testing/decorators.py +57 -0
  103. kumoai/testing/snow.py +50 -0
  104. kumoai/trainer/__init__.py +42 -0
  105. kumoai/trainer/baseline_trainer.py +93 -0
  106. kumoai/trainer/config.py +2 -0
  107. kumoai/trainer/distilled_trainer.py +175 -0
  108. kumoai/trainer/job.py +1192 -0
  109. kumoai/trainer/online_serving.py +258 -0
  110. kumoai/trainer/trainer.py +475 -0
  111. kumoai/trainer/util.py +103 -0
  112. kumoai/utils/__init__.py +11 -0
  113. kumoai/utils/datasets.py +83 -0
  114. kumoai/utils/display.py +51 -0
  115. kumoai/utils/forecasting.py +209 -0
  116. kumoai/utils/progress_logger.py +343 -0
  117. kumoai/utils/sql.py +3 -0
  118. kumoai-2.14.0.dev202601011731.dist-info/METADATA +71 -0
  119. kumoai-2.14.0.dev202601011731.dist-info/RECORD +122 -0
  120. kumoai-2.14.0.dev202601011731.dist-info/WHEEL +6 -0
  121. kumoai-2.14.0.dev202601011731.dist-info/licenses/LICENSE +9 -0
  122. kumoai-2.14.0.dev202601011731.dist-info/top_level.txt +1 -0
kumoai/codegen/cli.py ADDED
@@ -0,0 +1,100 @@
1
+ """CLI interface for Kumo SDK code generation utility."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import logging
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from kumoai.codegen import generate_code
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def main() -> None:
17
+ """CLI entry point for kumo-codegen command."""
18
+ parser = argparse.ArgumentParser(
19
+ description="Generate Python SDK code from Kumo entities",
20
+ epilog="""
21
+ Examples:
22
+ kumo-codegen --id myconnector --entity-class S3Connector
23
+ kumo-codegen --id trainingjob-abc123
24
+ kumo-codegen --id myconnector --entity-class S3Connector -o output.py
25
+ kumo-codegen --json config.json -o output.py
26
+ """,
27
+ formatter_class=argparse.RawDescriptionHelpFormatter,
28
+ )
29
+
30
+ input_group = parser.add_mutually_exclusive_group(required=True)
31
+ input_group.add_argument("--id", help="Entity ID to generate code for")
32
+ input_group.add_argument("--json", type=Path,
33
+ help="JSON file with entity specification")
34
+
35
+ parser.add_argument(
36
+ "--entity-class",
37
+ help="Entity class for ID mode (e.g., S3Connector, TrainingJob)",
38
+ type=str,
39
+ )
40
+ parser.add_argument(
41
+ "--output",
42
+ "-o",
43
+ type=Path,
44
+ help="Output file path (default: stdout)",
45
+ )
46
+ parser.add_argument(
47
+ "--verbose",
48
+ "-v",
49
+ action="store_true",
50
+ help="Enable verbose output",
51
+ )
52
+
53
+ args = parser.parse_args()
54
+
55
+ if args.verbose:
56
+ logger.setLevel(logging.INFO)
57
+
58
+ # Build input_spec based on mode
59
+ if args.id:
60
+ input_spec = {"id": args.id}
61
+ if args.entity_class:
62
+ input_spec["entity_class"] = args.entity_class
63
+
64
+ if args.verbose:
65
+ entity_info = f"ID: {args.id}"
66
+ if args.entity_class:
67
+ entity_info += f", Class: {args.entity_class}"
68
+ logger.info(f"Generating code for {entity_info}")
69
+
70
+ else:
71
+ if args.verbose:
72
+ logger.info(f"Using JSON mode with file: {args.json}")
73
+
74
+ try:
75
+ with open(args.json, "r") as f:
76
+ json_data = json.load(f)
77
+ input_spec = {"json": json_data}
78
+ except Exception as e:
79
+ logger.error(f"Error reading JSON file {args.json}: {e}")
80
+ sys.exit(1)
81
+
82
+ try:
83
+ output_path = str(args.output) if args.output else None
84
+ code = generate_code(input_spec, output_path=output_path)
85
+
86
+ if args.verbose and args.output:
87
+ logger.info(f"Code written to {args.output}")
88
+ elif not args.output:
89
+ print(code, end="")
90
+ except Exception as e:
91
+ logger.error(f"Error: {e}")
92
+ if args.verbose:
93
+ import traceback
94
+
95
+ traceback.print_exc(file=sys.stderr)
96
+ sys.exit(1)
97
+
98
+
99
+ if __name__ == "__main__":
100
+ main()
@@ -0,0 +1,16 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Any, Dict
3
+
4
+
5
+ @dataclass
6
+ class CodegenContext:
7
+ """Context for code generation containing shared state and mappings."""
8
+
9
+ # Maps config IDs to shared parent data
10
+ shared_parents: Dict[str, Dict[str, Any]] = field(default_factory=dict)
11
+
12
+ # Maps config IDs to variable names
13
+ object_to_var: Dict[str, str] = field(default_factory=dict)
14
+
15
+ # Execution environment for generated code
16
+ execution_env: Dict[str, Any] = field(default_factory=dict)
@@ -0,0 +1,473 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import logging
5
+ from typing import Any, List, NamedTuple, Set
6
+
7
+ from kumoai.codegen.naming import NameManager
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class EditResult(NamedTuple):
13
+ edits: List["UniversalReplacementEdit"]
14
+ imports: List[str]
15
+
16
+
17
+ def _is_primitive(obj: object) -> bool:
18
+ return obj is None or isinstance(obj, (str, int, float, bool))
19
+
20
+
21
+ def _is_collection(obj: object) -> bool:
22
+ return isinstance(obj, (list, dict, set, tuple))
23
+
24
+
25
+ def _collect_required_imports(obj: object) -> List[str]:
26
+ if _is_primitive(obj):
27
+ return []
28
+
29
+ from kumoai.codegen.handlers.utils import _get_canonical_import_path
30
+
31
+ obj_type = type(obj)
32
+ if hasattr(obj_type, "__module__") and hasattr(obj_type, "__name__"):
33
+ canonical_module = _get_canonical_import_path(obj_type)
34
+ if canonical_module:
35
+ return [f"from {canonical_module} import {obj_type.__name__}"]
36
+
37
+ return []
38
+
39
+
40
+ _TYPE_DEFAULTS = {
41
+ str: "",
42
+ int: 0,
43
+ float: 0.0,
44
+ bool: False,
45
+ list: [],
46
+ dict: {},
47
+ }
48
+
49
+
50
+ def _get_constructor_requirements(obj_type: type) -> dict[str, Any]:
51
+ """Analyze constructor to determine required
52
+ parameters and their default values.
53
+ """
54
+ try:
55
+ sig = inspect.signature(obj_type.__init__) # type: ignore
56
+ required_params = {}
57
+
58
+ for name, param in sig.parameters.items():
59
+ if name == "self":
60
+ continue
61
+
62
+ if param.kind == inspect.Parameter.VAR_POSITIONAL or \
63
+ param.kind == inspect.Parameter.VAR_KEYWORD:
64
+ continue
65
+
66
+ if param.default is param.empty:
67
+ if param.annotation != param.empty:
68
+ required_params[name] = _TYPE_DEFAULTS.get(
69
+ param.annotation, None)
70
+ else:
71
+ required_params[name] = None
72
+
73
+ return required_params
74
+
75
+ except (ValueError, TypeError):
76
+ return {}
77
+
78
+
79
+ def _get_value_repr(value: object) -> str:
80
+ """Get proper string representation for a value
81
+ , handling enums specially.
82
+ """
83
+ if hasattr(value, "value") and hasattr(value, "name"):
84
+ try:
85
+ enum_class = type(value)
86
+ string_value = str(value)
87
+ reconstructed = getattr(enum_class, string_value, None)
88
+ if reconstructed == value:
89
+ return repr(string_value)
90
+ except (ValueError, TypeError, AttributeError):
91
+ pass
92
+
93
+ enum_class_name = type(value).__name__
94
+ return f"{enum_class_name}('{str(value)}')"
95
+ elif hasattr(value, "__str__") and not _is_primitive(value):
96
+ return repr(str(value))
97
+ else:
98
+ return repr(value)
99
+
100
+
101
+ def get_editable_attributes(obj: object) -> List[str]:
102
+ """Extract editable attributes from an object using __dict__."""
103
+ if not hasattr(obj, "__dict__"):
104
+ return []
105
+
106
+ editable_attrs = []
107
+ for key, value in obj.__dict__.items():
108
+ if callable(value):
109
+ continue
110
+ if key.startswith("__"):
111
+ continue
112
+ if key.startswith("_"):
113
+ public_key = key[1:]
114
+ if hasattr(obj, public_key):
115
+ editable_attrs.append(key)
116
+ else:
117
+ editable_attrs.append(key)
118
+
119
+ return editable_attrs
120
+
121
+
122
+ class UniversalReplacementEdit:
123
+ """Represents a single edit operation for an object's attribute or element.
124
+
125
+ This class generates Python code lines to update
126
+ an object's property, collection element, or assign a new value.
127
+
128
+ It handles primitives, collections, and complex objects,
129
+ producing the necessary assignment
130
+ or construction code to perform the edit programmatically.
131
+
132
+ Example usage:
133
+ # Primitive attribute edit
134
+ nm = NameManager()
135
+ edit = UniversalReplacementEdit("name", "Alice", nm)
136
+ lines = edit.emit_lines("person")
137
+ # lines == ["person.name = 'Alice'"]
138
+
139
+ # Collection attribute edit
140
+ edit = UniversalReplacementEdit("items", [1, 2, 3], nm)
141
+ lines = edit.emit_lines("container")
142
+ # lines == ["items_1 = [1, 2, 3]", "container.items = items_1"]
143
+
144
+ # Complex object attribute edit
145
+ address = Address("123 Main St", "NYC")
146
+ edit = UniversalReplacementEdit("address", address, nm)
147
+ lines = edit.emit_lines("person")
148
+ # lines contains something like:
149
+ # ["address_1 = Address(street='123 Main St', city='NYC')",
150
+ # "person.address = address_1"]
151
+ """
152
+ def __init__(self, path: str, value: object, name_manager: NameManager):
153
+ if path is None:
154
+ raise TypeError("path cannot be None")
155
+ self.path = path
156
+ self.value = value
157
+ self.temp_var_name = name_manager.assign_temp_variable(path, value)
158
+ self.required_imports = _collect_required_imports(value)
159
+ self.name_manager = name_manager
160
+
161
+ def emit_lines(self, var_name: str) -> List[str]:
162
+ """Generate Python code lines for this edit.
163
+
164
+ This method handles three cases:
165
+ 1. Primitives: Direct assignment (obj.name = "value")
166
+ 2. Collections: temp_var = [1, 2, 3]; target = temp_var
167
+ 3. Complex objects: Construct object + set properties + assign
168
+
169
+ Args:
170
+ var_name: The target variable name to assign to
171
+
172
+ Returns:
173
+ List of Python code lines to execute this edit
174
+
175
+ Examples:
176
+ For primitive:
177
+ 'obj.name = "new_value"'
178
+
179
+ For object:
180
+ 'user_1 = User(id=123)',
181
+ 'user_1.active = True',
182
+ 'obj.user = user_1'
183
+ """
184
+ lines = []
185
+
186
+ if _is_primitive(self.value):
187
+ # For primitives, assign directly without temp variable
188
+ value_repr = _get_value_repr(self.value)
189
+ if self.path:
190
+ target = (f"{var_name}{self.path}" if self.path.startswith("[")
191
+ or self.path.startswith(".") else
192
+ f"{var_name}.{self.path}")
193
+ lines.append(f"{target} = {value_repr}")
194
+ else:
195
+ lines.append(f"{var_name} = {value_repr}")
196
+ return lines
197
+
198
+ elif _is_collection(self.value):
199
+ lines.append(f"{self.temp_var_name} = {repr(self.value)}")
200
+
201
+ else:
202
+ # Complex object: construction + property decomposition
203
+ obj_type = type(self.value)
204
+ required_params = _get_constructor_requirements(obj_type)
205
+ # For required parameters, use actual values from the object
206
+ # instead of defaults
207
+ for param_name, param_value in required_params.items():
208
+ if hasattr(self.value, param_name):
209
+ required_params[param_name] = getattr(
210
+ self.value, param_name)
211
+ else:
212
+ required_params[param_name] = param_value
213
+
214
+ try:
215
+ if required_params:
216
+ baseline_obj = obj_type(**required_params)
217
+ else:
218
+ baseline_obj = obj_type()
219
+
220
+ property_changes = detect_edits_recursive(
221
+ self.value, baseline_obj, "")
222
+ self.required_imports = self.required_imports + \
223
+ property_changes.imports
224
+ constructor_params = (required_params.copy()
225
+ if required_params else {})
226
+ remaining_edits = []
227
+
228
+ for edit in property_changes.edits:
229
+ remaining_edits.append(edit)
230
+
231
+ if constructor_params:
232
+ params_str = ", ".join(
233
+ f"{name}={_get_value_repr(val)}"
234
+ for name, val in constructor_params.items())
235
+ lines.append(f"{self.temp_var_name} = "
236
+ f"{obj_type.__name__}({params_str})")
237
+ else:
238
+ lines.append(
239
+ f"{self.temp_var_name} = {obj_type.__name__}()")
240
+
241
+ for edit in remaining_edits:
242
+ prop_lines = edit._emit_lines_for_target(
243
+ self.temp_var_name)
244
+ lines.extend(prop_lines)
245
+
246
+ except Exception:
247
+ lines = [f"{self.temp_var_name} = {repr(self.value)}"]
248
+
249
+ # For collections and complex objects, add the final
250
+ # assignment
251
+ if self.path:
252
+ target = (f"{var_name}{self.path}" if self.path.startswith("[") or
253
+ self.path.startswith(".") else f"{var_name}.{self.path}")
254
+ lines.append(f"{target} = {self.temp_var_name}")
255
+ else:
256
+ lines.append(f"{var_name} = {self.temp_var_name}")
257
+
258
+ return lines
259
+
260
+ def _emit_lines_for_target(self, target_var: str) -> List[str]:
261
+ """Helper method to generate edits targeting a specific variable."""
262
+ lines = []
263
+
264
+ if _is_primitive(self.value):
265
+ if self.path.startswith("[") or self.path.startswith("."):
266
+ full_path = f"{target_var}{self.path}"
267
+ else:
268
+ full_path = (f"{target_var}.{self.path}"
269
+ if self.path else target_var)
270
+
271
+ value_repr = _get_value_repr(self.value)
272
+ lines.append(f"{full_path} = {value_repr}")
273
+ else:
274
+ lines.extend(self.emit_lines(target_var))
275
+
276
+ return lines
277
+
278
+ def __repr__(self) -> str:
279
+ return (f"UniversalReplacementEdit(path={self.path}, "
280
+ f"value_type={type(self.value).__name__})")
281
+
282
+
283
+ def get_element_replacement(path: str, value: object,
284
+ name_manager: NameManager) -> EditResult:
285
+ """Mode 1: Generate edit to replace any value at
286
+ any path using universal temp pattern.
287
+ """
288
+ edit = UniversalReplacementEdit(path, value, name_manager)
289
+ return EditResult([edit], edit.required_imports)
290
+
291
+
292
+ def get_collection_element_replacement(target: object, baseline: object,
293
+ path: str, name_manager: NameManager,
294
+ visited: Set[int]) -> EditResult:
295
+ """Mode 2: Generate edits for changes within collections
296
+ (lists, dicts, sets, tuples).
297
+ """
298
+ edits = []
299
+ all_imports = []
300
+
301
+ if isinstance(target, list) and isinstance(baseline, list):
302
+ max_len = max(len(target), len(baseline))
303
+
304
+ for i in range(max_len):
305
+ target_item = target[i] if i < len(target) else None
306
+ base_item = baseline[i] if i < len(baseline) else None
307
+
308
+ if target_item != base_item:
309
+ element_path = f"{path}[{i}]"
310
+ result = get_element_replacement(element_path, target_item,
311
+ name_manager)
312
+ edits.extend(result.edits)
313
+ all_imports.extend(result.imports)
314
+
315
+ elif isinstance(target, dict) and isinstance(baseline, dict):
316
+ all_keys = set(target.keys()) | set(baseline.keys())
317
+
318
+ for key in sorted(all_keys):
319
+ target_val = target.get(key)
320
+ base_val = baseline.get(key)
321
+
322
+ if target_val != base_val:
323
+ key_path = f"{path}['{key}']"
324
+
325
+ if (not _is_primitive(target_val)
326
+ and not _is_collection(target_val)
327
+ and target_val is not None):
328
+ result = detect_edits_recursive(target_val, base_val,
329
+ key_path, name_manager,
330
+ visited)
331
+ edits.extend(result.edits)
332
+ all_imports.extend(result.imports)
333
+ elif _is_collection(target_val) and _is_collection(base_val):
334
+ result = get_collection_element_replacement(
335
+ target_val, base_val, key_path, name_manager, visited)
336
+ edits.extend(result.edits)
337
+ all_imports.extend(result.imports)
338
+ else:
339
+ result = get_element_replacement(key_path, target_val,
340
+ name_manager)
341
+ edits.extend(result.edits)
342
+ all_imports.extend(result.imports)
343
+
344
+ elif isinstance(target, (set, tuple)):
345
+ if target != baseline:
346
+ result = get_element_replacement(path, target, name_manager)
347
+ edits.extend(result.edits)
348
+ all_imports.extend(result.imports)
349
+
350
+ return EditResult(edits, list(set(all_imports)))
351
+
352
+
353
+ def get_property_recursion(target: object, baseline: object, path: str,
354
+ name_manager: NameManager,
355
+ visited: Set[int]) -> EditResult:
356
+ """Mode 3: Generate edits by recursively detecting
357
+ property-level changes in objects.
358
+ """
359
+ edits = []
360
+ all_imports = []
361
+
362
+ attrs = get_editable_attributes(target)
363
+
364
+ for attr in sorted(attrs):
365
+ try:
366
+ target_val = getattr(target, attr)
367
+ base_val = getattr(baseline, attr, None)
368
+
369
+ attr_path = f"{path}.{attr}" if path else attr
370
+
371
+ result = detect_edits_recursive(target_val, base_val, attr_path,
372
+ name_manager, visited)
373
+ edits.extend(result.edits)
374
+ all_imports.extend(result.imports)
375
+
376
+ except (AttributeError, RuntimeError, TypeError):
377
+ continue
378
+
379
+ return EditResult(edits, list(set(all_imports)))
380
+
381
+
382
+ def _determine_edit_strategy(target: object, baseline: object) -> str:
383
+ """Determine which edit strategy to use based
384
+ on object types and values.
385
+ """
386
+ # Type mismatch -> Element replacement
387
+ if not isinstance(target, type(baseline)):
388
+ return "element_replacement"
389
+
390
+ # Equality check -> No edits needed
391
+ if target == baseline:
392
+ return "no_edit"
393
+
394
+ # Primitives -> Element replacement
395
+ if _is_primitive(target):
396
+ return "element_replacement"
397
+
398
+ # Collections -> Collection element replacement
399
+ if _is_collection(target):
400
+ return "collection_element_replacement"
401
+
402
+ # Complex objects -> Property recursion
403
+ return "property_recursion"
404
+
405
+
406
+ # Strategy dispatch table: maps strategy names to handler functions
407
+ _EDIT_STRATEGIES = {
408
+ "no_edit":
409
+ lambda target, base, path, nm, visited: EditResult([], []),
410
+ "element_replacement":
411
+ lambda target, base, path, nm, visited:
412
+ (get_element_replacement(path, target, nm)),
413
+ "collection_element_replacement":
414
+ get_collection_element_replacement,
415
+ "property_recursion":
416
+ get_property_recursion,
417
+ }
418
+
419
+
420
+ def detect_edits_recursive(
421
+ target: object,
422
+ baseline: object,
423
+ path: str = "",
424
+ name_manager: NameManager | None = None,
425
+ visited: Set[int] | None = None,
426
+ ) -> EditResult:
427
+ """Generate edits to transform baseline
428
+ into target using recursive analysis.
429
+
430
+ This is the main entry point for edit detection.
431
+ It analyzes two objects and
432
+ determines what changes are needed, then routes
433
+ to the appropriate mode:
434
+
435
+ Decision:
436
+ 1. Type mismatch or primitives -> Element replacement mode
437
+ 2. Collections -> Collection element replacement mode
438
+ 3. Complex objects -> Property recursion mode
439
+
440
+ Args:
441
+ target: Target object state to achieve
442
+ baseline: Starting object state
443
+ path: Current path in object hierarchy (e.g., "user.profile.name")
444
+ name_manager: The NameManager instance for variable naming.
445
+ visited: Set of visited object IDs for cycle detection
446
+
447
+ Returns:
448
+ EditResult containing list of edits and required imports
449
+
450
+ Examples:
451
+ Simple change: obj.name = "new" ->
452
+ [UniversalReplacementEdit("name", "new")]
453
+ Nested change: obj.user.active = True ->
454
+ [UniversalReplacementEdit("user.active", True)]
455
+ Collection: obj.items[0] = val ->
456
+ [UniversalReplacementEdit("items[0]", val)]
457
+ """
458
+ if visited is None:
459
+ visited = set()
460
+
461
+ if name_manager is None:
462
+ name_manager = NameManager()
463
+
464
+ target_id, base_id = id(target), id(baseline)
465
+ if target_id in visited:
466
+ return EditResult([], [])
467
+
468
+ visited_extended = visited | {target_id, base_id}
469
+
470
+ strategy = _determine_edit_strategy(target, baseline)
471
+ handler = _EDIT_STRATEGIES[strategy]
472
+
473
+ return handler(target, baseline, path, name_manager, visited_extended)
@@ -0,0 +1,10 @@
1
+ class CodegenError(Exception):
2
+ pass
3
+
4
+
5
+ class CyclicDependencyError(CodegenError):
6
+ pass
7
+
8
+
9
+ class UnsupportedEntityError(CodegenError):
10
+ pass