openscad-parser 2.5.2__tar.gz → 2.6.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openscad_parser
3
- Version: 2.5.2
3
+ Version: 2.6.0
4
4
  Summary: A PEG parser to read OpenSCAD language source code, with optional AST tree generation.
5
5
  Keywords: openscad,openscad parser,parser
6
6
  Author: Revar Desmera
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "openscad_parser"
7
- version = "2.5.2"
7
+ version = "2.6.0"
8
8
  description = "A PEG parser to read OpenSCAD language source code, with optional AST tree generation."
9
9
  readme = "README.rst"
10
10
  authors = [
@@ -1,4 +1,6 @@
1
+ import hashlib
1
2
  import os
3
+ import pickle
2
4
  import platform
3
5
  from typing import Optional
4
6
  from arpeggio import NoMatch
@@ -253,22 +255,174 @@ def getASTfromString(code: str, include_comments: bool = False, origin: str = "<
253
255
  return ast
254
256
 
255
257
 
256
- # Module-level cache for AST trees
258
+ # Module-level in-memory cache for per-file AST trees (no includes resolved)
259
+ # Key: tuple of (absolute file path (str), include_comments (bool))
260
+ # Value: tuple of (AST nodes, modification timestamp)
261
+ _ast_cache: dict[tuple[str, bool], tuple[list[ASTNode] | None, float]] = {}
262
+
263
+ # Resolved (includes-expanded) cache
257
264
  # Key: tuple of (absolute file path (str), include_comments (bool), process_includes (bool))
258
265
  # Value: tuple of (AST nodes, modification timestamp)
259
- _ast_cache: dict[tuple[str, bool, bool], tuple[list[ASTNode] | None, float]] = {}
266
+ _resolved_cache: dict[tuple[str, bool, bool], tuple[list[ASTNode] | None, float]] = {}
267
+
268
+
269
+ def _get_disk_cache_dir() -> Optional[str]:
270
+ """Get the disk cache directory, creating it if needed."""
271
+ cache_dir = os.environ.get('OPENSCAD_PARSER_CACHE_DIR')
272
+ if not cache_dir:
273
+ home = os.path.expanduser('~')
274
+ if platform.system() == 'Darwin':
275
+ cache_dir = os.path.join(home, 'Library', 'Caches', 'openscad_parser')
276
+ elif platform.system() == 'Windows': # pragma: no cover
277
+ cache_dir = os.path.join(os.environ.get('LOCALAPPDATA', home), 'openscad_parser', 'cache')
278
+ else:
279
+ cache_dir = os.path.join(home, '.cache', 'openscad_parser')
280
+ try:
281
+ os.makedirs(cache_dir, exist_ok=True)
282
+ return cache_dir
283
+ except OSError: # pragma: no cover
284
+ return None
285
+
286
+
287
+ def _disk_cache_path(file_path: str, include_comments: bool) -> Optional[str]:
288
+ """Get the disk cache file path for a given source file."""
289
+ cache_dir = _get_disk_cache_dir()
290
+ if not cache_dir:
291
+ return None # pragma: no cover
292
+ key = f"{file_path}:{include_comments}"
293
+ h = hashlib.sha256(key.encode()).hexdigest()[:16]
294
+ return os.path.join(cache_dir, f"{h}.pickle")
295
+
296
+
297
+ def _load_from_disk_cache(file_path: str, include_comments: bool, current_mtime: float) -> Optional[list[ASTNode]]:
298
+ """Try to load a file's AST from disk cache."""
299
+ cache_path = _disk_cache_path(file_path, include_comments)
300
+ if not cache_path or not os.path.exists(cache_path):
301
+ return None
302
+ try:
303
+ with open(cache_path, 'rb') as f:
304
+ cached_mtime, ast = pickle.load(f)
305
+ if cached_mtime == current_mtime:
306
+ return ast
307
+ except (OSError, pickle.UnpicklingError, ValueError, EOFError):
308
+ pass
309
+ return None
310
+
311
+
312
+ def _save_to_disk_cache(file_path: str, include_comments: bool, mtime: float, ast: list[ASTNode] | None):
313
+ """Save a file's AST to disk cache."""
314
+ cache_path = _disk_cache_path(file_path, include_comments)
315
+ if not cache_path:
316
+ return # pragma: no cover
317
+ try:
318
+ with open(cache_path, 'wb') as f:
319
+ pickle.dump((mtime, ast), f, protocol=pickle.HIGHEST_PROTOCOL)
320
+ except OSError: # pragma: no cover
321
+ pass
260
322
 
261
323
 
262
324
  def clear_ast_cache():
263
325
  """Clear the in-memory AST cache.
264
-
326
+
265
327
  This function removes all cached AST trees, forcing all subsequent
266
328
  calls to getASTfromFile() to re-parse files.
267
-
329
+
268
330
  Example:
269
331
  clear_ast_cache() # Clear all cached ASTs
270
332
  """
271
333
  _ast_cache.clear()
334
+ _resolved_cache.clear()
335
+
336
+
337
+ def clear_disk_cache():
338
+ """Clear the on-disk AST cache.
339
+
340
+ This function removes all cached AST files from disk, forcing all subsequent
341
+ calls to re-parse files from scratch.
342
+
343
+ Example:
344
+ clear_disk_cache() # Remove all disk-cached ASTs
345
+ """
346
+ cache_dir = _get_disk_cache_dir()
347
+ if cache_dir and os.path.isdir(cache_dir):
348
+ for fname in os.listdir(cache_dir):
349
+ if fname.endswith('.pickle'):
350
+ try:
351
+ os.remove(os.path.join(cache_dir, fname))
352
+ except OSError: # pragma: no cover
353
+ pass
354
+
355
+
356
+ def _parse_single_file(file_path: str, include_comments: bool = False) -> list[ASTNode] | None:
357
+ """Parse a single file without resolving includes. Uses memory and disk cache.
358
+
359
+ Returns the AST with IncludeStatement nodes intact (not expanded).
360
+ """
361
+ if not os.path.exists(file_path):
362
+ raise FileNotFoundError(f"File {file_path} not found")
363
+
364
+ current_mtime = os.path.getmtime(file_path)
365
+ cache_key = (file_path, include_comments)
366
+
367
+ # Check in-memory cache
368
+ if cache_key in _ast_cache:
369
+ cached_ast, cached_mtime = _ast_cache[cache_key]
370
+ if cached_mtime == current_mtime:
371
+ return cached_ast
372
+
373
+ # Check disk cache
374
+ disk_result = _load_from_disk_cache(file_path, include_comments, current_mtime)
375
+ if disk_result is not None:
376
+ _ast_cache[cache_key] = (disk_result, current_mtime)
377
+ return disk_result
378
+
379
+ # Parse the file
380
+ with open(file_path, 'r', encoding='utf-8') as f:
381
+ code = f.read()
382
+
383
+ source_map = SourceMap()
384
+ source_map.add_origin(file_path, code)
385
+
386
+ parser = getOpenSCADParser(reduce_tree=False, include_comments=include_comments)
387
+ ast = parse_ast(parser, code, source_map=source_map)
388
+
389
+ # Cache in memory and on disk
390
+ _ast_cache[cache_key] = (ast, current_mtime)
391
+ _save_to_disk_cache(file_path, include_comments, current_mtime, ast)
392
+
393
+ return ast
394
+
395
+
396
+ def _resolve_includes(ast_nodes: list[ASTNode] | None, current_file: str,
397
+ include_comments: bool = False,
398
+ visited: set | None = None) -> list[ASTNode] | None:
399
+ """Resolve IncludeStatement nodes by parsing and inlining referenced files."""
400
+ if ast_nodes is None:
401
+ return None
402
+ if visited is None:
403
+ visited = set()
404
+
405
+ result = []
406
+ for node in ast_nodes:
407
+ if isinstance(node, IncludeStatement):
408
+ filename = node.filepath.val
409
+ lib_file = findLibraryFile(current_file, filename)
410
+ if lib_file is None:
411
+ raise FileNotFoundError(
412
+ f"Included file '{filename}' not found. "
413
+ f"Searched relative to: {current_file if current_file else 'current directory'}"
414
+ )
415
+ lib_file = os.path.abspath(lib_file)
416
+ if lib_file in visited:
417
+ continue
418
+ visited.add(lib_file)
419
+ included_ast = _parse_single_file(lib_file, include_comments)
420
+ included_ast = _resolve_includes(included_ast, lib_file, include_comments, visited)
421
+ if included_ast:
422
+ result.extend(included_ast)
423
+ else:
424
+ result.append(node)
425
+ return result
272
426
 
273
427
 
274
428
  def getASTfromFile(file: str, include_comments: bool = False, process_includes: bool = True) -> list[ASTNode] | None:
@@ -278,20 +432,21 @@ def getASTfromFile(file: str, include_comments: bool = False, process_includes:
278
432
  This function reads the contents of the provided OpenSCAD file, processes include statements,
279
433
  parses it using the OpenSCAD parser, and returns the resulting AST (or list of AST nodes).
280
434
 
281
- The function caches AST trees in memory. Cache entries are automatically invalidated
282
- if the file's modification timestamp changes, ensuring that updated files are re-parsed.
435
+ The function caches AST trees both in memory and on disk. Cache entries are automatically
436
+ invalidated if a file's modification timestamp changes, ensuring updated files are re-parsed.
437
+ Each included file is parsed independently and cached separately, so only changed files
438
+ need re-parsing.
283
439
 
284
440
  Important: The `process_includes` parameter affects the AST structure:
285
-
286
- - When `process_includes=True` (default): Include statements are processed before parsing,
287
- meaning the included file contents are inserted into the source code, and the AST will
288
- NOT contain `IncludeStatement` nodes. The AST represents the code as if all includes
289
- have been expanded.
290
-
441
+
442
+ - When `process_includes=True` (default): Include statements are processed and resolved,
443
+ meaning the included file's AST nodes are inlined, and the AST will NOT contain
444
+ `IncludeStatement` nodes. The AST represents the code as if all includes have been expanded.
445
+
291
446
  - When `process_includes=False`: Include statements are NOT processed, and the AST will
292
447
  contain `IncludeStatement` nodes wherever `include <file>` statements appear in the
293
448
  source code.
294
-
449
+
295
450
  Note: Unlike `include` statements, `use <file>` statements are ALWAYS parsed into
296
451
  `UseStatement` AST nodes, regardless of the `process_includes` setting. This is because
297
452
  `use` statements only affect module and function lookup at runtime, not source inclusion.
@@ -316,54 +471,34 @@ def getASTfromFile(file: str, include_comments: bool = False, process_includes:
316
471
  # Get AST with IncludeStatement nodes instead of processing includes
317
472
  ast_with_include_nodes = getASTfromFile("my_model.scad", process_includes=False)
318
473
  """
319
- # Get absolute path for consistent cache keys
320
474
  file_path = os.path.abspath(file)
321
-
322
- # Check if file exists and get its modification time
475
+
323
476
  if not os.path.exists(file_path):
324
477
  raise FileNotFoundError(f"File {file} not found")
325
-
478
+
326
479
  current_mtime = os.path.getmtime(file_path)
327
-
328
- # Cache key includes file path, include_comments flag, and process_includes flag
329
- cache_key = (file_path, include_comments, process_includes)
330
-
331
- # Check cache
332
- if cache_key in _ast_cache:
333
- cached_ast, cached_mtime = _ast_cache[cache_key]
334
- # If file hasn't been modified, return cached AST
480
+
481
+ # For process_includes=False, just parse the single file
482
+ if not process_includes:
483
+ return _parse_single_file(file_path, include_comments)
484
+
485
+ # Check resolved cache (in-memory only since resolved ASTs depend on multiple files)
486
+ resolved_key = (file_path, include_comments, True)
487
+ if resolved_key in _resolved_cache:
488
+ cached_ast, cached_mtime = _resolved_cache[resolved_key]
335
489
  if cached_mtime == current_mtime:
336
490
  return cached_ast
337
- # Otherwise, invalidate the cache entry
338
- del _ast_cache[cache_key]
339
-
340
- # Read the file
341
- with open(file_path, 'r', encoding='utf-8') as f:
342
- code = f.read()
343
-
344
- # Create source map and process includes if requested
345
- source_map = SourceMap()
346
- source_map.add_origin(file_path, code)
347
-
348
- if process_includes:
349
- try:
350
- source_map = process_includes_func(source_map, file_path)
351
- except FileNotFoundError as e:
352
- # Re-raise file not found errors as-is
353
- raise
354
- except Exception as e: # pragma: no cover
355
- raise Exception(f"Error processing includes: {e}")
356
-
357
- # Get the combined string for parsing
358
- combined_code = source_map.get_combined_string()
359
-
360
- # Parse
361
- parser = getOpenSCADParser(reduce_tree=False, include_comments=include_comments)
362
- ast = parse_ast(parser, combined_code, source_map=source_map)
363
-
364
- # Cache the result with current modification time
365
- _ast_cache[cache_key] = (ast, current_mtime)
366
-
491
+
492
+ # Parse the file independently (uses per-file cache)
493
+ ast = _parse_single_file(file_path, include_comments)
494
+
495
+ # Resolve all include statements recursively
496
+ visited = {file_path}
497
+ ast = _resolve_includes(ast, file_path, include_comments, visited)
498
+
499
+ # Cache the resolved result
500
+ _resolved_cache[resolved_key] = (ast, current_mtime)
501
+
367
502
  return ast
368
503
 
369
504
 
@@ -80,8 +80,13 @@ class SemanticChildren(list):
80
80
  self._rule_map = rule_map
81
81
 
82
82
  def __getattr__(self, name):
83
+ if name == '_rule_map':
84
+ raise AttributeError(name)
83
85
  return self._rule_map.get(name, [])
84
86
 
87
+ def __reduce__(self):
88
+ return (list, (list(self),))
89
+
85
90
  def get_rule(self, rule_name, index=0):
86
91
  """Return the index-th result for rule_name, or [] if absent/out of range."""
87
92
  results = self._rule_map.get(rule_name, [])
@@ -95,7 +100,7 @@ class ASTBuilderVisitor(PTNodeVisitor):
95
100
 
96
101
  def __init__(self, parser, source_map=None, file=""):
97
102
  """Initialize the visitor with the parser and optional source map or file path.
98
-
103
+
99
104
  Args:
100
105
  parser: The Arpeggio parser instance (needed to access input for position conversion)
101
106
  source_map: Optional SourceMap for tracking positions across multiple origins
@@ -105,10 +110,12 @@ class ASTBuilderVisitor(PTNodeVisitor):
105
110
  self.parser = parser
106
111
  if source_map is not None:
107
112
  self.source_map = source_map
113
+ self._has_source_map = bool(source_map._segments)
108
114
  self.file = "" # Not used when source_map is provided
109
115
  else:
110
116
  # Backward compatibility: create a simple source map from file
111
117
  self.source_map = SourceMap()
118
+ self._has_source_map = False
112
119
  if file:
113
120
  # We can't get the content here, but we'll handle it in _get_node_position
114
121
  self.file = file
@@ -194,17 +201,9 @@ class ASTBuilderVisitor(PTNodeVisitor):
194
201
  """Return the combined-string offset one past the last character of node."""
195
202
  if node is None:
196
203
  return 0
197
- try:
198
- # NonTerminal: recurse to find the furthest end among children
199
- end = getattr(node, 'position', 0)
200
- for child in node:
201
- child_end = self._get_node_end_position(child)
202
- if child_end > end:
203
- end = child_end
204
+ end = getattr(node, 'position_end', None)
205
+ if end is not None:
204
206
  return end
205
- except TypeError:
206
- pass
207
- # Terminal: start + length of matched value
208
207
  pos = getattr(node, 'position', 0)
209
208
  val = getattr(node, 'value', '')
210
209
  return pos + len(str(val))
@@ -225,7 +224,7 @@ class ASTBuilderVisitor(PTNodeVisitor):
225
224
  end_pos = self._get_node_end_position(node)
226
225
 
227
226
  # Use SourceMap if available to map position back to original origin
228
- if hasattr(self, 'source_map') and self.source_map.get_segments():
227
+ if self._has_source_map:
229
228
  return self.source_map.get_location(char_pos, end_pos)
230
229
  else:
231
230
  # Fallback: calculate line/column from character position
@@ -172,11 +172,15 @@ class SourceMap:
172
172
  # Calculate the actual line/column for the after segment
173
173
  # Count lines in the part that was removed + before
174
174
  removed_and_before = segment.content[:replace_end_in_segment]
175
- line_count = removed_and_before.count('\n') - line_count_adjustment
175
+ line_count = removed_and_before.count('\n') + line_count_adjustment
176
176
  if line_count > 0:
177
- last_newline = removed_and_before.rfind('\n')
178
- after_segment.start_line = segment.start_line + line_count
179
- after_segment.start_column = len(removed_and_before) - last_newline
177
+ if line_count_adjustment and removed_and_before.count('\n') == 0:
178
+ after_segment.start_line = segment.start_line + line_count
179
+ after_segment.start_column = 1
180
+ else:
181
+ last_newline = removed_and_before.rfind('\n')
182
+ after_segment.start_line = segment.start_line + line_count
183
+ after_segment.start_column = len(removed_and_before) - last_newline
180
184
  else:
181
185
  after_segment.start_line = segment.start_line
182
186
  after_segment.start_column = segment.start_column + len(removed_and_before)