openscad-parser 2.5.2__tar.gz → 2.6.1__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.1
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.1"
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,7 @@
1
+ import hashlib
2
+ import json
1
3
  import os
4
+ import pickle
2
5
  import platform
3
6
  from typing import Optional
4
7
  from arpeggio import NoMatch
@@ -253,22 +256,240 @@ def getASTfromString(code: str, include_comments: bool = False, origin: str = "<
253
256
  return ast
254
257
 
255
258
 
256
- # Module-level cache for AST trees
259
+ # Module-level in-memory cache for per-file AST trees (no includes resolved)
260
+ # Key: tuple of (absolute file path (str), include_comments (bool))
261
+ # Value: tuple of (AST nodes, modification timestamp)
262
+ _ast_cache: dict[tuple[str, bool], tuple[list[ASTNode] | None, float]] = {}
263
+
264
+ # Resolved (includes-expanded) cache
257
265
  # Key: tuple of (absolute file path (str), include_comments (bool), process_includes (bool))
258
266
  # Value: tuple of (AST nodes, modification timestamp)
259
- _ast_cache: dict[tuple[str, bool, bool], tuple[list[ASTNode] | None, float]] = {}
267
+ _resolved_cache: dict[tuple[str, bool, bool], tuple[list[ASTNode] | None, float]] = {}
268
+
269
+
270
+ def _get_disk_cache_dir() -> Optional[str]:
271
+ """Get the disk cache directory, creating it if needed."""
272
+ cache_dir = os.environ.get('OPENSCAD_PARSER_CACHE_DIR')
273
+ if not cache_dir:
274
+ home = os.path.expanduser('~')
275
+ if platform.system() == 'Darwin':
276
+ cache_dir = os.path.join(home, 'Library', 'Caches', 'openscad_parser')
277
+ elif platform.system() == 'Windows': # pragma: no cover
278
+ cache_dir = os.path.join(os.environ.get('LOCALAPPDATA', home), 'openscad_parser', 'cache')
279
+ else:
280
+ cache_dir = os.path.join(home, '.cache', 'openscad_parser')
281
+ try:
282
+ os.makedirs(cache_dir, exist_ok=True)
283
+ return cache_dir
284
+ except OSError: # pragma: no cover
285
+ return None
286
+
287
+
288
+ def _disk_cache_path(file_path: str, include_comments: bool) -> Optional[str]:
289
+ """Get the disk cache file path for a given source file."""
290
+ cache_dir = _get_disk_cache_dir()
291
+ if not cache_dir:
292
+ return None # pragma: no cover
293
+ key = f"{file_path}:{include_comments}"
294
+ h = hashlib.sha256(key.encode()).hexdigest()[:16]
295
+ return os.path.join(cache_dir, f"{h}.pickle")
296
+
297
+
298
+ def _load_from_disk_cache(file_path: str, include_comments: bool, current_mtime: float) -> Optional[list[ASTNode]]:
299
+ """Try to load a file's AST from disk cache."""
300
+ cache_path = _disk_cache_path(file_path, include_comments)
301
+ if not cache_path or not os.path.exists(cache_path):
302
+ return None
303
+ try:
304
+ with open(cache_path, 'rb') as f:
305
+ cached_mtime, ast = pickle.load(f)
306
+ if cached_mtime == current_mtime:
307
+ return ast
308
+ except (OSError, pickle.UnpicklingError, ValueError, EOFError):
309
+ pass
310
+ return None
311
+
312
+
313
+ def _save_to_disk_cache(file_path: str, include_comments: bool, mtime: float, ast: list[ASTNode] | None):
314
+ """Save a file's AST to disk cache and update the manifest."""
315
+ cache_path = _disk_cache_path(file_path, include_comments)
316
+ if not cache_path:
317
+ return # pragma: no cover
318
+ try:
319
+ with open(cache_path, 'wb') as f:
320
+ pickle.dump((mtime, ast), f, protocol=pickle.HIGHEST_PROTOCOL)
321
+ except OSError: # pragma: no cover
322
+ return
323
+ cache_fname = os.path.basename(cache_path)
324
+ _manifest_update(cache_fname, file_path)
325
+ _evict_stale_cache()
326
+
327
+
328
+ def _manifest_path() -> Optional[str]:
329
+ """Get the path to the cache manifest file."""
330
+ cache_dir = _get_disk_cache_dir()
331
+ if not cache_dir:
332
+ return None # pragma: no cover
333
+ return os.path.join(cache_dir, "manifest.json")
334
+
335
+
336
+ def _manifest_load() -> dict[str, str]:
337
+ """Load the manifest: {cache_filename: source_file_path}."""
338
+ path = _manifest_path()
339
+ if not path or not os.path.exists(path):
340
+ return {}
341
+ try:
342
+ with open(path, 'r') as f:
343
+ return json.load(f)
344
+ except (OSError, json.JSONDecodeError):
345
+ return {}
346
+
347
+
348
+ def _manifest_save(manifest: dict[str, str]):
349
+ """Save the manifest to disk."""
350
+ path = _manifest_path()
351
+ if not path:
352
+ return # pragma: no cover
353
+ try:
354
+ with open(path, 'w') as f:
355
+ json.dump(manifest, f)
356
+ except OSError: # pragma: no cover
357
+ pass
358
+
359
+
360
+ def _manifest_update(cache_fname: str, source_path: str):
361
+ """Add or update an entry in the manifest."""
362
+ manifest = _manifest_load()
363
+ manifest[cache_fname] = source_path
364
+ _manifest_save(manifest)
365
+
366
+
367
+ def _evict_stale_cache():
368
+ """Remove cache entries whose source files no longer exist."""
369
+ cache_dir = _get_disk_cache_dir()
370
+ if not cache_dir:
371
+ return # pragma: no cover
372
+ manifest = _manifest_load()
373
+ if not manifest:
374
+ return
375
+ stale_keys = [
376
+ fname for fname, source_path in manifest.items()
377
+ if not os.path.exists(source_path)
378
+ ]
379
+ if not stale_keys:
380
+ return
381
+ for fname in stale_keys:
382
+ cache_file = os.path.join(cache_dir, fname)
383
+ try:
384
+ os.remove(cache_file)
385
+ except OSError:
386
+ pass
387
+ del manifest[fname]
388
+ _manifest_save(manifest)
260
389
 
261
390
 
262
391
  def clear_ast_cache():
263
392
  """Clear the in-memory AST cache.
264
-
393
+
265
394
  This function removes all cached AST trees, forcing all subsequent
266
395
  calls to getASTfromFile() to re-parse files.
267
-
396
+
268
397
  Example:
269
398
  clear_ast_cache() # Clear all cached ASTs
270
399
  """
271
400
  _ast_cache.clear()
401
+ _resolved_cache.clear()
402
+
403
+
404
+ def clear_disk_cache():
405
+ """Clear the on-disk AST cache.
406
+
407
+ This function removes all cached AST files from disk, forcing all subsequent
408
+ calls to re-parse files from scratch.
409
+
410
+ Example:
411
+ clear_disk_cache() # Remove all disk-cached ASTs
412
+ """
413
+ cache_dir = _get_disk_cache_dir()
414
+ if cache_dir and os.path.isdir(cache_dir):
415
+ for fname in os.listdir(cache_dir):
416
+ if fname.endswith('.pickle') or fname == 'manifest.json':
417
+ try:
418
+ os.remove(os.path.join(cache_dir, fname))
419
+ except OSError: # pragma: no cover
420
+ pass
421
+
422
+
423
+ def _parse_single_file(file_path: str, include_comments: bool = False) -> list[ASTNode] | None:
424
+ """Parse a single file without resolving includes. Uses memory and disk cache.
425
+
426
+ Returns the AST with IncludeStatement nodes intact (not expanded).
427
+ """
428
+ if not os.path.exists(file_path):
429
+ raise FileNotFoundError(f"File {file_path} not found")
430
+
431
+ current_mtime = os.path.getmtime(file_path)
432
+ cache_key = (file_path, include_comments)
433
+
434
+ # Check in-memory cache
435
+ if cache_key in _ast_cache:
436
+ cached_ast, cached_mtime = _ast_cache[cache_key]
437
+ if cached_mtime == current_mtime:
438
+ return cached_ast
439
+
440
+ # Check disk cache
441
+ disk_result = _load_from_disk_cache(file_path, include_comments, current_mtime)
442
+ if disk_result is not None:
443
+ _ast_cache[cache_key] = (disk_result, current_mtime)
444
+ return disk_result
445
+
446
+ # Parse the file
447
+ with open(file_path, 'r', encoding='utf-8') as f:
448
+ code = f.read()
449
+
450
+ source_map = SourceMap()
451
+ source_map.add_origin(file_path, code)
452
+
453
+ parser = getOpenSCADParser(reduce_tree=False, include_comments=include_comments)
454
+ ast = parse_ast(parser, code, source_map=source_map)
455
+
456
+ # Cache in memory and on disk
457
+ _ast_cache[cache_key] = (ast, current_mtime)
458
+ _save_to_disk_cache(file_path, include_comments, current_mtime, ast)
459
+
460
+ return ast
461
+
462
+
463
+ def _resolve_includes(ast_nodes: list[ASTNode] | None, current_file: str,
464
+ include_comments: bool = False,
465
+ visited: set | None = None) -> list[ASTNode] | None:
466
+ """Resolve IncludeStatement nodes by parsing and inlining referenced files."""
467
+ if ast_nodes is None:
468
+ return None
469
+ if visited is None:
470
+ visited = set()
471
+
472
+ result = []
473
+ for node in ast_nodes:
474
+ if isinstance(node, IncludeStatement):
475
+ filename = node.filepath.val
476
+ lib_file = findLibraryFile(current_file, filename)
477
+ if lib_file is None:
478
+ raise FileNotFoundError(
479
+ f"Included file '{filename}' not found. "
480
+ f"Searched relative to: {current_file if current_file else 'current directory'}"
481
+ )
482
+ lib_file = os.path.abspath(lib_file)
483
+ if lib_file in visited:
484
+ continue
485
+ visited.add(lib_file)
486
+ included_ast = _parse_single_file(lib_file, include_comments)
487
+ included_ast = _resolve_includes(included_ast, lib_file, include_comments, visited)
488
+ if included_ast:
489
+ result.extend(included_ast)
490
+ else:
491
+ result.append(node)
492
+ return result
272
493
 
273
494
 
274
495
  def getASTfromFile(file: str, include_comments: bool = False, process_includes: bool = True) -> list[ASTNode] | None:
@@ -278,20 +499,21 @@ def getASTfromFile(file: str, include_comments: bool = False, process_includes:
278
499
  This function reads the contents of the provided OpenSCAD file, processes include statements,
279
500
  parses it using the OpenSCAD parser, and returns the resulting AST (or list of AST nodes).
280
501
 
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.
502
+ The function caches AST trees both in memory and on disk. Cache entries are automatically
503
+ invalidated if a file's modification timestamp changes, ensuring updated files are re-parsed.
504
+ Each included file is parsed independently and cached separately, so only changed files
505
+ need re-parsing.
283
506
 
284
507
  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
-
508
+
509
+ - When `process_includes=True` (default): Include statements are processed and resolved,
510
+ meaning the included file's AST nodes are inlined, and the AST will NOT contain
511
+ `IncludeStatement` nodes. The AST represents the code as if all includes have been expanded.
512
+
291
513
  - When `process_includes=False`: Include statements are NOT processed, and the AST will
292
514
  contain `IncludeStatement` nodes wherever `include <file>` statements appear in the
293
515
  source code.
294
-
516
+
295
517
  Note: Unlike `include` statements, `use <file>` statements are ALWAYS parsed into
296
518
  `UseStatement` AST nodes, regardless of the `process_includes` setting. This is because
297
519
  `use` statements only affect module and function lookup at runtime, not source inclusion.
@@ -316,54 +538,34 @@ def getASTfromFile(file: str, include_comments: bool = False, process_includes:
316
538
  # Get AST with IncludeStatement nodes instead of processing includes
317
539
  ast_with_include_nodes = getASTfromFile("my_model.scad", process_includes=False)
318
540
  """
319
- # Get absolute path for consistent cache keys
320
541
  file_path = os.path.abspath(file)
321
-
322
- # Check if file exists and get its modification time
542
+
323
543
  if not os.path.exists(file_path):
324
544
  raise FileNotFoundError(f"File {file} not found")
325
-
545
+
326
546
  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
547
+
548
+ # For process_includes=False, just parse the single file
549
+ if not process_includes:
550
+ return _parse_single_file(file_path, include_comments)
551
+
552
+ # Check resolved cache (in-memory only since resolved ASTs depend on multiple files)
553
+ resolved_key = (file_path, include_comments, True)
554
+ if resolved_key in _resolved_cache:
555
+ cached_ast, cached_mtime = _resolved_cache[resolved_key]
335
556
  if cached_mtime == current_mtime:
336
557
  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
-
558
+
559
+ # Parse the file independently (uses per-file cache)
560
+ ast = _parse_single_file(file_path, include_comments)
561
+
562
+ # Resolve all include statements recursively
563
+ visited = {file_path}
564
+ ast = _resolve_includes(ast, file_path, include_comments, visited)
565
+
566
+ # Cache the resolved result
567
+ _resolved_cache[resolved_key] = (ast, current_mtime)
568
+
367
569
  return ast
368
570
 
369
571
 
@@ -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)