pydpm_xl 0.2.2__py3-none-any.whl → 0.2.4__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.
@@ -7,15 +7,37 @@ without exposing internal complexity or version compatibility issues.
7
7
  """
8
8
 
9
9
  from typing import Dict, Any, Optional, List, Union
10
+ from pathlib import Path
10
11
  import json
12
+ from datetime import datetime
11
13
  from py_dpm.api.dpm_xl.syntax import SyntaxAPI
12
14
  from py_dpm.api.dpm_xl.semantic import SemanticAPI
13
15
 
14
16
 
15
- class ASTGenerator:
17
+
18
+ class ASTGeneratorAPI:
16
19
  """
17
20
  Simplified AST Generator for external packages.
18
21
 
22
+ Provides three levels of AST generation:
23
+
24
+ 1. **Basic AST** (parse_expression):
25
+ - Syntax parsing only, no database required
26
+ - Returns: Clean AST dictionary with version compatibility normalization
27
+ - Use for: Syntax validation, basic AST analysis
28
+
29
+ 2. **Complete AST** (generate_complete_ast):
30
+ - Requires database connection
31
+ - Performs full semantic validation and operand checking
32
+ - Returns: AST with data fields populated (datapoint IDs, operand references)
33
+ - Use for: AST analysis with complete metadata, matching json_scripts/*.json format
34
+
35
+ 3. **Enriched AST** (generate_enriched_ast):
36
+ - Requires database connection
37
+ - Extends complete AST with framework structure for execution engines
38
+ - Returns: Engine-ready AST with operations, variables, tables, preconditions sections
39
+ - Use for: Business rule execution engines, validation frameworks
40
+
19
41
  Handles all internal complexity including:
20
42
  - Version compatibility
21
43
  - Context processing
@@ -49,18 +71,30 @@ class ASTGenerator:
49
71
 
50
72
  def parse_expression(self, expression: str) -> Dict[str, Any]:
51
73
  """
52
- Parse DPM-XL expression into clean AST format.
74
+ Parse DPM-XL expression into clean AST format (Level 1 - Basic AST).
75
+
76
+ Performs syntax parsing only, no database required. Returns a clean AST dictionary
77
+ with version compatibility normalization applied.
78
+
79
+ **What you get:**
80
+ - Clean AST structure (syntax tree)
81
+ - Context information (if WITH clause present)
82
+ - Version compatibility normalization
83
+
84
+ **What you DON'T get:**
85
+ - Data fields (datapoint IDs, operand references) - use generate_complete_ast()
86
+ - Framework structure - use generate_enriched_ast()
53
87
 
54
88
  Args:
55
89
  expression: DPM-XL expression string
56
90
 
57
91
  Returns:
58
92
  Dictionary containing:
59
- - success: bool
60
- - ast: AST dictionary (if successful)
61
- - context: Context information (if WITH clause present)
62
- - error: Error message (if failed)
63
- - metadata: Additional information
93
+ - success (bool): Whether parsing succeeded
94
+ - ast (dict): Clean AST dictionary
95
+ - context (dict): Context information (if WITH clause present)
96
+ - error (str): Error message (if failed)
97
+ - metadata (dict): Additional information (expression type, compatibility mode)
64
98
  """
65
99
  try:
66
100
  # Parse with syntax API
@@ -179,6 +213,260 @@ class ASTGenerator:
179
213
  result['analysis'] = analysis
180
214
  return result
181
215
 
216
+ # ============================================================================
217
+ # Complete AST Generation (requires database)
218
+ # ============================================================================
219
+
220
+ def generate_complete_ast(
221
+ self,
222
+ expression: str,
223
+ release_id: Optional[int] = None,
224
+ ) -> Dict[str, Any]:
225
+ """
226
+ Generate complete AST with all data fields populated (Level 2).
227
+
228
+ This method performs full semantic validation and operand checking using the database,
229
+ populating datapoint IDs and operand references in the AST. The result matches the
230
+ format found in json_scripts/*.json files.
231
+
232
+ **What you get:**
233
+ - Pure AST with data fields (datapoint IDs, operand references)
234
+ - Semantic validation results
235
+ - Context information
236
+
237
+ **What you DON'T get:**
238
+ - Framework structure (operations, variables, tables, preconditions)
239
+ - For that, use generate_enriched_ast() instead
240
+
241
+ Args:
242
+ expression: DPM-XL expression string
243
+ release_id: Optional release ID to filter database lookups by specific release.
244
+ If None, uses all available data (release-agnostic).
245
+
246
+ Returns:
247
+ dict with keys:
248
+ - success (bool): Whether generation succeeded
249
+ - ast (dict): Complete AST with data fields
250
+ - context (dict): Context information (table, rows, columns, etc.)
251
+ - error (str): Error message if failed
252
+ - data_populated (bool): Whether data fields were populated
253
+ - semantic_result: Semantic validation result object
254
+ """
255
+ try:
256
+ from py_dpm.dpm.utils import get_engine
257
+ from py_dpm.dpm_xl.utils.serialization import ASTToJSONVisitor
258
+
259
+ # Initialize database connection if explicitly provided, to surface connection errors early
260
+ try:
261
+ get_engine(database_path=self.database_path, connection_url=self.connection_url)
262
+ except Exception as e:
263
+ return {
264
+ "success": False,
265
+ "ast": None,
266
+ "context": None,
267
+ "error": f"Database connection failed: {e}",
268
+ "data_populated": False,
269
+ }
270
+
271
+ # Create or reuse semantic API for validation
272
+ if not self.semantic_api:
273
+ self.semantic_api = SemanticAPI(
274
+ database_path=self.database_path,
275
+ connection_url=self.connection_url
276
+ )
277
+
278
+ semantic_result = self.semantic_api.validate_expression(
279
+ expression, release_id=release_id
280
+ )
281
+
282
+ # If semantic validation failed, return structured error
283
+ if not semantic_result.is_valid:
284
+ return {
285
+ "success": False,
286
+ "ast": None,
287
+ "context": None,
288
+ "error": semantic_result.error_message,
289
+ "data_populated": False,
290
+ "semantic_result": semantic_result,
291
+ }
292
+
293
+ ast_root = getattr(self.semantic_api, "ast", None)
294
+
295
+ if ast_root is None:
296
+ return {
297
+ "success": False,
298
+ "ast": None,
299
+ "context": None,
300
+ "error": "Semantic validation did not generate AST",
301
+ "data_populated": False,
302
+ "semantic_result": semantic_result,
303
+ }
304
+
305
+ # Extract components
306
+ actual_ast, context = self._extract_complete_components(ast_root)
307
+
308
+ # Convert to JSON using the ASTToJSONVisitor
309
+ visitor = ASTToJSONVisitor(context)
310
+ ast_dict = visitor.visit(actual_ast)
311
+
312
+ # Check if data fields were populated
313
+ data_populated = self._check_data_fields_populated(ast_dict)
314
+
315
+ # Serialize context
316
+ context_dict = self._serialize_context(context)
317
+
318
+ return {
319
+ "success": True,
320
+ "ast": ast_dict,
321
+ "context": context_dict,
322
+ "error": None,
323
+ "data_populated": data_populated,
324
+ "semantic_result": semantic_result,
325
+ }
326
+
327
+ except Exception as e:
328
+ return {
329
+ "success": False,
330
+ "ast": None,
331
+ "context": None,
332
+ "error": f"API error: {str(e)}",
333
+ "data_populated": False,
334
+ }
335
+
336
+ def generate_complete_batch(
337
+ self,
338
+ expressions: List[str],
339
+ release_id: Optional[int] = None,
340
+ ) -> List[Dict[str, Any]]:
341
+ """
342
+ Generate complete ASTs for multiple expressions.
343
+
344
+ Args:
345
+ expressions: List of DPM-XL expression strings
346
+ release_id: Optional release ID to filter database lookups by specific release.
347
+ If None, uses all available data (release-agnostic).
348
+
349
+ Returns:
350
+ list: List of result dictionaries (same format as generate_complete_ast)
351
+ """
352
+ results = []
353
+ for i, expr in enumerate(expressions):
354
+ result = self.generate_complete_ast(expr, release_id=release_id)
355
+ result["batch_index"] = i
356
+ results.append(result)
357
+ return results
358
+
359
+ # ============================================================================
360
+ # Enriched AST Generation (requires database)
361
+ # ============================================================================
362
+
363
+ def generate_enriched_ast(
364
+ self,
365
+ expression: str,
366
+ dpm_version: Optional[str] = None,
367
+ operation_code: Optional[str] = None,
368
+ table_context: Optional[Dict[str, Any]] = None,
369
+ precondition: Optional[str] = None,
370
+ release_id: Optional[int] = None,
371
+ output_path: Optional[Union[str, Path]] = None,
372
+ ) -> Dict[str, Any]:
373
+ """
374
+ Generate enriched, engine-ready AST with framework structure (Level 3).
375
+
376
+ This extends generate_complete_ast() by wrapping the complete AST in an engine-ready
377
+ framework structure with operations, variables, tables, and preconditions sections.
378
+ This is the format required by business rule execution engines.
379
+
380
+ **What you get:**
381
+ - Everything from generate_complete_ast() PLUS:
382
+ - Framework structure: operations, variables, tables, preconditions
383
+ - Module metadata: version, release info, dates
384
+ - Dependency information
385
+ - Coordinates (x/y/z) added to data entries
386
+
387
+ **Typical use case:**
388
+ - Feeding AST to business rule execution engines
389
+ - Validation framework integration
390
+ - Production rule processing
391
+
392
+ Args:
393
+ expression: DPM-XL expression string
394
+ dpm_version: DPM version code (e.g., "4.0", "4.1", "4.2")
395
+ operation_code: Optional operation code (defaults to "default_code")
396
+ table_context: Optional table context dict with keys: 'table', 'columns', 'rows', 'sheets', 'default', 'interval'
397
+ precondition: Optional precondition variable reference (e.g., {v_F_44_04})
398
+ release_id: Optional release ID to filter database lookups by specific release.
399
+ If None, uses all available data (release-agnostic).
400
+ output_path: Optional path (string or Path) to save the enriched_ast as JSON file.
401
+ If provided, the enriched_ast will be automatically saved to this location.
402
+
403
+ Returns:
404
+ dict: {
405
+ 'success': bool,
406
+ 'enriched_ast': dict, # Engine-ready AST with framework structure
407
+ 'error': str # Error message if failed
408
+ }
409
+
410
+ Example:
411
+ >>> generator = ASTGeneratorAPI(database_path="data.db")
412
+ >>> result = generator.generate_enriched_ast(
413
+ ... "{tF_01.00, r0010, c0010}",
414
+ ... dpm_version="4.2",
415
+ ... operation_code="my_validation"
416
+ ... )
417
+ >>> # result['enriched_ast'] contains framework structure ready for engines
418
+ >>>
419
+ >>> # Or save directly to a file:
420
+ >>> result = generator.generate_enriched_ast(
421
+ ... "{tF_01.00, r0010, c0010}",
422
+ ... dpm_version="4.2",
423
+ ... operation_code="my_validation",
424
+ ... output_path="./output/enriched_ast.json"
425
+ ... )
426
+ >>> # The enriched_ast is automatically saved to the specified path
427
+ """
428
+ try:
429
+ # Generate complete AST first
430
+ complete_result = self.generate_complete_ast(expression, release_id=release_id)
431
+
432
+ if not complete_result["success"]:
433
+ return {
434
+ "success": False,
435
+ "enriched_ast": None,
436
+ "error": f"Failed to generate complete AST: {complete_result['error']}",
437
+ }
438
+
439
+ complete_ast = complete_result["ast"]
440
+ context = complete_result.get("context") or table_context
441
+
442
+ # Enrich with framework structure
443
+ enriched_ast = self._enrich_ast_with_metadata(
444
+ ast_dict=complete_ast,
445
+ expression=expression,
446
+ context=context,
447
+ dpm_version=dpm_version,
448
+ operation_code=operation_code,
449
+ precondition=precondition,
450
+ )
451
+
452
+ # Save to file if output_path is provided
453
+ if output_path is not None:
454
+ path = Path(output_path) if isinstance(output_path, str) else output_path
455
+ # Create parent directories if they don't exist
456
+ path.parent.mkdir(parents=True, exist_ok=True)
457
+ # Save enriched_ast as JSON
458
+ with open(path, "w") as f:
459
+ json.dump(enriched_ast, f, indent=4)
460
+
461
+ return {"success": True, "enriched_ast": enriched_ast, "error": None}
462
+
463
+ except Exception as e:
464
+ return {
465
+ "success": False,
466
+ "enriched_ast": None,
467
+ "error": f"Enrichment error: {str(e)}",
468
+ }
469
+
182
470
  # Internal helper methods
183
471
 
184
472
  def _extract_components(self, raw_ast):
@@ -393,6 +681,398 @@ class ASTGenerator:
393
681
  score += self._calculate_complexity(item)
394
682
  return score
395
683
 
684
+ # ============================================================================
685
+ # Helper methods for complete and enriched AST generation
686
+ # ============================================================================
687
+
688
+ def _extract_complete_components(self, ast_obj):
689
+ """Extract context and expression from complete AST object."""
690
+ if hasattr(ast_obj, "children") and len(ast_obj.children) > 0:
691
+ child = ast_obj.children[0]
692
+ if hasattr(child, "expression"):
693
+ return child.expression, child.partial_selection
694
+ else:
695
+ return child, None
696
+ return ast_obj, None
697
+
698
+ def _check_data_fields_populated(self, ast_dict):
699
+ """Check if any VarID nodes have data fields populated."""
700
+ if not isinstance(ast_dict, dict):
701
+ return False
702
+
703
+ if ast_dict.get("class_name") == "VarID" and "data" in ast_dict:
704
+ return True
705
+
706
+ # Recursively check nested structures
707
+ for value in ast_dict.values():
708
+ if isinstance(value, dict):
709
+ if self._check_data_fields_populated(value):
710
+ return True
711
+ elif isinstance(value, list):
712
+ for item in value:
713
+ if isinstance(item, dict) and self._check_data_fields_populated(item):
714
+ return True
715
+
716
+ return False
717
+
718
+ def _enrich_ast_with_metadata(
719
+ self,
720
+ ast_dict: Dict[str, Any],
721
+ expression: str,
722
+ context: Optional[Dict[str, Any]],
723
+ dpm_version: Optional[str] = None,
724
+ operation_code: Optional[str] = None,
725
+ precondition: Optional[str] = None,
726
+ ) -> Dict[str, Any]:
727
+ """
728
+ Add framework structure (operations, variables, tables, preconditions) to complete AST.
729
+
730
+ This creates the engine-ready format with all metadata sections.
731
+ """
732
+ from py_dpm.dpm.utils import get_engine
733
+ import copy
734
+
735
+ # Initialize database connection
736
+ engine = get_engine(database_path=self.database_path, connection_url=self.connection_url)
737
+
738
+ # Generate operation code if not provided
739
+ if not operation_code:
740
+ operation_code = "default_code"
741
+
742
+ # Get current date for framework structure
743
+ current_date = datetime.now().strftime("%Y-%m-%d")
744
+
745
+ # Query database for release information
746
+ release_info = self._get_release_info(dpm_version, engine)
747
+
748
+ # Build module info
749
+ module_info = {
750
+ "module_code": "default",
751
+ "module_version": "1.0.0",
752
+ "framework_code": "default",
753
+ "dpm_release": {
754
+ "release": release_info["release"],
755
+ "publication_date": release_info["publication_date"],
756
+ },
757
+ "dates": {"from": "2001-01-01", "to": None},
758
+ }
759
+
760
+ # Add coordinates to AST data entries
761
+ ast_with_coords = self._add_coordinates_to_ast(ast_dict, context)
762
+
763
+ # Build operations section
764
+ operations = {
765
+ operation_code: {
766
+ "version_id": hash(expression) % 10000,
767
+ "code": operation_code,
768
+ "expression": expression,
769
+ "root_operator_id": 24, # Default for now
770
+ "ast": ast_with_coords,
771
+ "from_submission_date": current_date,
772
+ "severity": "Error",
773
+ }
774
+ }
775
+
776
+ # Build variables section by extracting from the complete AST
777
+ all_variables, variables_by_table = self._extract_variables_from_ast(ast_with_coords)
778
+
779
+ variables = all_variables
780
+ tables = {}
781
+
782
+ # Build tables with their specific variables
783
+ for table_code, table_variables in variables_by_table.items():
784
+ tables[table_code] = {"variables": table_variables, "open_keys": {}}
785
+
786
+ # Build preconditions
787
+ preconditions = {}
788
+ precondition_variables = {}
789
+
790
+ if precondition or (context and "table" in context):
791
+ preconditions, precondition_variables = self._build_preconditions(
792
+ precondition=precondition,
793
+ context=context,
794
+ operation_code=operation_code,
795
+ engine=engine,
796
+ )
797
+
798
+ # Build dependency information
799
+ dependency_info = {
800
+ "intra_instance_validations": [operation_code],
801
+ "cross_instance_dependencies": [],
802
+ }
803
+
804
+ # Build dependency modules
805
+ dependency_modules = {}
806
+
807
+ # Build complete structure
808
+ namespace = "default_module"
809
+
810
+ return {
811
+ namespace: {
812
+ **module_info,
813
+ "operations": operations,
814
+ "variables": variables,
815
+ "tables": tables,
816
+ "preconditions": preconditions,
817
+ "precondition_variables": precondition_variables,
818
+ "dependency_information": dependency_info,
819
+ "dependency_modules": dependency_modules,
820
+ }
821
+ }
822
+
823
+ def _get_release_info(self, dpm_version: Optional[str], engine) -> Dict[str, Any]:
824
+ """Get release information from database using SQLAlchemy."""
825
+ from py_dpm.dpm.models import Release
826
+ from sqlalchemy.orm import sessionmaker
827
+
828
+ Session = sessionmaker(bind=engine)
829
+ session = Session()
830
+
831
+ try:
832
+ if dpm_version:
833
+ # Query for specific version
834
+ version_float = float(dpm_version)
835
+ release = (
836
+ session.query(Release)
837
+ .filter(Release.code == str(version_float))
838
+ .first()
839
+ )
840
+
841
+ if release:
842
+ return {
843
+ "release": str(release.code) if release.code else dpm_version,
844
+ "publication_date": (
845
+ release.date.strftime("%Y-%m-%d")
846
+ if release.date
847
+ else "2001-01-01"
848
+ ),
849
+ }
850
+
851
+ # Fallback: get latest released version
852
+ release = (
853
+ session.query(Release)
854
+ .filter(Release.status == "released")
855
+ .order_by(Release.code.desc())
856
+ .first()
857
+ )
858
+
859
+ if release:
860
+ return {
861
+ "release": str(release.code) if release.code else "4.1",
862
+ "publication_date": (
863
+ release.date.strftime("%Y-%m-%d") if release.date else "2001-01-01"
864
+ ),
865
+ }
866
+
867
+ # Final fallback
868
+ return {"release": "4.1", "publication_date": "2001-01-01"}
869
+
870
+ except Exception:
871
+ # Fallback on any error
872
+ return {"release": "4.1", "publication_date": "2001-01-01"}
873
+ finally:
874
+ session.close()
875
+
876
+ def _get_table_info(self, table_code: str, engine) -> Optional[Dict[str, Any]]:
877
+ """Get table information from database using SQLAlchemy."""
878
+ from py_dpm.dpm.models import TableVersion
879
+ from sqlalchemy.orm import sessionmaker
880
+ import re
881
+
882
+ Session = sessionmaker(bind=engine)
883
+ session = Session()
884
+
885
+ try:
886
+ # Try exact match first
887
+ table = (
888
+ session.query(TableVersion).filter(TableVersion.code == table_code).first()
889
+ )
890
+
891
+ if table:
892
+ return {"table_vid": table.tablevid, "code": table.code}
893
+
894
+ # Handle precondition parser format: F_25_01 -> F_25.01
895
+ if re.match(r"^[A-Z]_\d+_\d+", table_code):
896
+ parts = table_code.split("_", 2)
897
+ if len(parts) >= 3:
898
+ table_code_with_dot = f"{parts[0]}_{parts[1]}.{parts[2]}"
899
+ table = (
900
+ session.query(TableVersion)
901
+ .filter(TableVersion.code == table_code_with_dot)
902
+ .first()
903
+ )
904
+
905
+ if table:
906
+ return {"table_vid": table.tablevid, "code": table.code}
907
+
908
+ # Try LIKE pattern as last resort (handles sub-tables like F_25.01.a)
909
+ table = (
910
+ session.query(TableVersion)
911
+ .filter(TableVersion.code.like(f"{table_code}%"))
912
+ .order_by(TableVersion.code)
913
+ .first()
914
+ )
915
+
916
+ if table:
917
+ return {"table_vid": table.tablevid, "code": table.code}
918
+
919
+ return None
920
+
921
+ except Exception:
922
+ return None
923
+ finally:
924
+ session.close()
925
+
926
+ def _build_preconditions(
927
+ self,
928
+ precondition: Optional[str],
929
+ context: Optional[Dict[str, Any]],
930
+ operation_code: str,
931
+ engine,
932
+ ) -> tuple:
933
+ """Build preconditions and precondition_variables sections."""
934
+ import re
935
+
936
+ preconditions = {}
937
+ precondition_variables = {}
938
+
939
+ # Extract table code from precondition or context
940
+ table_code = None
941
+
942
+ if precondition:
943
+ # Extract variable code from precondition reference like {v_F_44_04}
944
+ match = re.match(r"\{v_([^}]+)\}", precondition)
945
+ if match:
946
+ table_code = match.group(1)
947
+ elif context and "table" in context:
948
+ table_code = context["table"]
949
+
950
+ if table_code:
951
+ # Query database for actual variable ID and version
952
+ table_info = self._get_table_info(table_code, engine)
953
+
954
+ if table_info:
955
+ precondition_var_id = table_info["table_vid"]
956
+ version_id = table_info["table_vid"]
957
+ precondition_code = f"p_{precondition_var_id}"
958
+
959
+ preconditions[precondition_code] = {
960
+ "ast": {
961
+ "class_name": "PreconditionItem",
962
+ "variable_id": precondition_var_id,
963
+ "variable_code": table_code,
964
+ },
965
+ "affected_operations": [operation_code],
966
+ "version_id": version_id,
967
+ "code": precondition_code,
968
+ }
969
+
970
+ precondition_variables[str(precondition_var_id)] = "b"
971
+
972
+ return preconditions, precondition_variables
973
+
974
+ def _extract_variables_from_ast(self, ast_dict: Dict[str, Any]) -> tuple:
975
+ """
976
+ Extract variables from complete AST by table.
977
+
978
+ Returns:
979
+ tuple: (all_variables_dict, variables_by_table_dict)
980
+ """
981
+ variables_by_table = {}
982
+ all_variables = {}
983
+
984
+ def extract_from_node(node):
985
+ if isinstance(node, dict):
986
+ # Check if this is a VarID node with data
987
+ if node.get("class_name") == "VarID" and "data" in node:
988
+ table = node.get("table")
989
+ if table:
990
+ if table not in variables_by_table:
991
+ variables_by_table[table] = {}
992
+
993
+ # Extract variable IDs and data types from AST data array
994
+ for data_item in node["data"]:
995
+ if "datapoint" in data_item:
996
+ var_id = str(int(data_item["datapoint"]))
997
+ data_type = data_item.get("data_type", "e")
998
+ variables_by_table[table][var_id] = data_type
999
+ all_variables[var_id] = data_type
1000
+
1001
+ # Recursively process nested nodes
1002
+ for value in node.values():
1003
+ if isinstance(value, (dict, list)):
1004
+ extract_from_node(value)
1005
+ elif isinstance(node, list):
1006
+ for item in node:
1007
+ extract_from_node(item)
1008
+
1009
+ extract_from_node(ast_dict)
1010
+ return all_variables, variables_by_table
1011
+
1012
+ def _add_coordinates_to_ast(
1013
+ self, ast_dict: Dict[str, Any], context: Optional[Dict[str, Any]]
1014
+ ) -> Dict[str, Any]:
1015
+ """Add x/y/z coordinates to data entries in AST."""
1016
+ import copy
1017
+
1018
+ def add_coords_to_node(node):
1019
+ if isinstance(node, dict):
1020
+ # Handle VarID nodes with data arrays
1021
+ if node.get("class_name") == "VarID" and "data" in node:
1022
+ # Get column information from context
1023
+ cols = []
1024
+ if context and "columns" in context and context["columns"]:
1025
+ cols = context["columns"]
1026
+
1027
+ # Group data entries by row to assign coordinates correctly
1028
+ entries_by_row = {}
1029
+ for data_entry in node["data"]:
1030
+ row_code = data_entry.get("row", "")
1031
+ if row_code not in entries_by_row:
1032
+ entries_by_row[row_code] = []
1033
+ entries_by_row[row_code].append(data_entry)
1034
+
1035
+ # Assign coordinates based on column order and row grouping
1036
+ rows = list(entries_by_row.keys())
1037
+ for x_index, row_code in enumerate(rows, 1):
1038
+ for data_entry in entries_by_row[row_code]:
1039
+ column_code = data_entry.get("column", "")
1040
+
1041
+ # Find y coordinate based on column position in context
1042
+ y_index = 1 # default
1043
+ if cols and column_code in cols:
1044
+ y_index = cols.index(column_code) + 1
1045
+ elif cols:
1046
+ # Fallback to order in data
1047
+ row_columns = [
1048
+ entry.get("column", "")
1049
+ for entry in entries_by_row[row_code]
1050
+ ]
1051
+ if column_code in row_columns:
1052
+ y_index = row_columns.index(column_code) + 1
1053
+
1054
+ # Always add y coordinate
1055
+ data_entry["y"] = y_index
1056
+
1057
+ # Add x coordinate only if there are multiple rows
1058
+ if len(rows) > 1:
1059
+ data_entry["x"] = x_index
1060
+
1061
+ # TODO: Add z coordinate for sheets when needed
1062
+
1063
+ # Recursively process child nodes
1064
+ for key, value in node.items():
1065
+ if isinstance(value, (dict, list)):
1066
+ add_coords_to_node(value)
1067
+ elif isinstance(node, list):
1068
+ for item in node:
1069
+ add_coords_to_node(item)
1070
+
1071
+ # Create a deep copy to avoid modifying the original
1072
+ result = copy.deepcopy(ast_dict)
1073
+ add_coords_to_node(result)
1074
+ return result
1075
+
396
1076
 
397
1077
  # Convenience functions for simple usage
398
1078
 
@@ -407,7 +1087,7 @@ def parse_expression(expression: str, compatibility_mode: str = "auto") -> Dict[
407
1087
  Returns:
408
1088
  Parse result dictionary
409
1089
  """
410
- generator = ASTGenerator(compatibility_mode=compatibility_mode)
1090
+ generator = ASTGeneratorAPI(compatibility_mode=compatibility_mode)
411
1091
  return generator.parse_expression(expression)
412
1092
 
413
1093
 
@@ -421,7 +1101,7 @@ def validate_expression(expression: str) -> bool:
421
1101
  Returns:
422
1102
  True if valid, False otherwise
423
1103
  """
424
- generator = ASTGenerator()
1104
+ generator = ASTGeneratorAPI()
425
1105
  result = generator.validate_expression(expression)
426
1106
  return result['valid']
427
1107
 
@@ -437,5 +1117,5 @@ def parse_batch(expressions: List[str], compatibility_mode: str = "auto") -> Lis
437
1117
  Returns:
438
1118
  List of parse results
439
1119
  """
440
- generator = ASTGenerator(compatibility_mode=compatibility_mode)
1120
+ generator = ASTGeneratorAPI(compatibility_mode=compatibility_mode)
441
1121
  return generator.parse_batch(expressions)