openscad-parser 2.6.0__tar.gz → 3.0.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.
- {openscad_parser-2.6.0 → openscad_parser-3.0.1}/PKG-INFO +52 -12
- {openscad_parser-2.6.0 → openscad_parser-3.0.1}/README.rst +51 -11
- {openscad_parser-2.6.0 → openscad_parser-3.0.1}/pyproject.toml +1 -1
- {openscad_parser-2.6.0 → openscad_parser-3.0.1}/src/openscad_parser/__init__.py +1 -1
- {openscad_parser-2.6.0 → openscad_parser-3.0.1}/src/openscad_parser/ast/__init__.py +164 -64
- {openscad_parser-2.6.0 → openscad_parser-3.0.1}/src/openscad_parser/ast/builder.py +9 -1
- {openscad_parser-2.6.0 → openscad_parser-3.0.1}/src/openscad_parser/ast/nodes.py +142 -71
- {openscad_parser-2.6.0 → openscad_parser-3.0.1}/src/openscad_parser/ast/pretty_print.py +282 -158
- {openscad_parser-2.6.0 → openscad_parser-3.0.1}/src/openscad_parser/ast/scope.py +58 -2
- {openscad_parser-2.6.0 → openscad_parser-3.0.1}/src/openscad_parser/ast/serialization.py +3 -0
- {openscad_parser-2.6.0 → openscad_parser-3.0.1}/src/openscad_parser/grammar.py +51 -2
- {openscad_parser-2.6.0 → openscad_parser-3.0.1}/src/openscad_parser/ast/source_map.py +0 -0
- {openscad_parser-2.6.0 → openscad_parser-3.0.1}/src/openscad_parser/cli.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openscad_parser
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.0.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
|
|
@@ -184,12 +184,39 @@ Use ``getASTfromLibraryFile()`` to find and parse library files using OpenSCAD's
|
|
|
184
184
|
The function searches for library files in this order:
|
|
185
185
|
|
|
186
186
|
1. Directory of the current file (if provided)
|
|
187
|
-
2. Directories in the ``OPENSCADPATH`` environment variable
|
|
188
|
-
|
|
189
|
-
|
|
187
|
+
2. Directories in the ``OPENSCADPATH`` environment variable, which add to the
|
|
188
|
+
directories below rather than replacing them, as in OpenSCAD
|
|
189
|
+
3. The platform's libraries folder:
|
|
190
|
+
|
|
191
|
+
- Windows: ``OpenSCAD\libraries`` in the Documents folder Windows reports
|
|
192
|
+
(under OneDrive when Known Folder Move is on)
|
|
190
193
|
- macOS: ``~/Documents/OpenSCAD/libraries``
|
|
191
194
|
- Linux: ``~/.local/share/OpenSCAD/libraries``
|
|
192
195
|
|
|
196
|
+
4. A ``libraries`` directory beside the installed package
|
|
197
|
+
|
|
198
|
+
``librarySearchDirs(currfile)`` returns this list, and a not-found error names
|
|
199
|
+
every directory in it.
|
|
200
|
+
|
|
201
|
+
Strict Commas
|
|
202
|
+
~~~~~~~~~~~~~
|
|
203
|
+
|
|
204
|
+
OpenSCAD accepts a trailing comma almost everywhere. OpenSCAD 2021.01 did not:
|
|
205
|
+
it rejected one after a call's last argument (``cube(1,)``) and after the last
|
|
206
|
+
assignment of a ``let``/``for``/``intersection_for`` (``let(x=1,)``), while
|
|
207
|
+
accepting it in list literals, list comprehensions and parameter lists. To
|
|
208
|
+
parse as 2021.01 did:
|
|
209
|
+
|
|
210
|
+
.. code-block:: python
|
|
211
|
+
|
|
212
|
+
from openscad_parser import strict_commas
|
|
213
|
+
|
|
214
|
+
with strict_commas():
|
|
215
|
+
ast = getASTfromFile("model.scad") # None if it uses a rejected comma
|
|
216
|
+
|
|
217
|
+
The mode nests, restores on exit, and every AST cache keys on it, so a strict
|
|
218
|
+
parse is never served a lenient tree.
|
|
219
|
+
|
|
193
220
|
Advanced AST Generation
|
|
194
221
|
~~~~~~~~~~~~~~~~~~~~~~~~
|
|
195
222
|
|
|
@@ -432,6 +459,7 @@ Expressions
|
|
|
432
459
|
- ``AssertOp(arguments: list[Argument], body: Expression)``: assert clause ``assert(arguments) body``
|
|
433
460
|
- ``FunctionLiteral(parameters: list[ParameterDeclaration], body: Expression)``: Anonymous function expression ``function(parameters) body``
|
|
434
461
|
- ``PrimaryCall(left: Expression, arguments: list[Argument])``: Function calls ``left(arguments)``
|
|
462
|
+
- ``RenderExpression(arguments: list[Argument], children: list[ModuleInstantiation])``: Geometry measured as a value ``render(arguments) { children }``. Braces required; ``render`` stays usable as an ordinary name. Not part of upstream OpenSCAD
|
|
435
463
|
- ``PrimaryIndex(left: Expression, index: Expression)``: Array indexing ``left[index]``
|
|
436
464
|
- ``PrimaryMember(left: Expression, member: Identifier)``: Member access ``left.member``
|
|
437
465
|
|
|
@@ -535,7 +563,7 @@ Main Functions
|
|
|
535
563
|
|
|
536
564
|
``getASTfromLibraryFile(currfile: str, libfile: str, include_comments: bool = False, process_includes: bool = True)``
|
|
537
565
|
Find and parse an OpenSCAD library file using OpenSCAD's search path rules.
|
|
538
|
-
Searches
|
|
566
|
+
Searches the directories ``librarySearchDirs(currfile)`` returns.
|
|
539
567
|
|
|
540
568
|
:param currfile: Full path to the current OpenSCAD file (can be empty string)
|
|
541
569
|
:param libfile: Partial or full path to the library file to find
|
|
@@ -565,11 +593,16 @@ Main Functions
|
|
|
565
593
|
This function removes all cached AST trees from memory.
|
|
566
594
|
|
|
567
595
|
``build_scopes(ast: list[ASTNode]) -> Scope``
|
|
568
|
-
Build a scope tree over an AST
|
|
596
|
+
Build a scope tree over an AST, recording every node's scope in a ``ScopeTable``
|
|
597
|
+
hung on the root (``root.table``). Read a node's scope with ``root.scope_of(node)``.
|
|
569
598
|
|
|
570
599
|
:param ast: A list of top-level AST nodes (as returned by the ``getAST*`` functions)
|
|
571
600
|
:returns: The root ``Scope`` object
|
|
572
601
|
|
|
602
|
+
``build_scopes_into(ast: list[ASTNode], table: ScopeTable) -> Scope``
|
|
603
|
+
The same, recording into a ``ScopeTable`` the caller owns, so several roots (one per
|
|
604
|
+
``use``\ d file, say) can be read back through one table.
|
|
605
|
+
|
|
573
606
|
``Scope``
|
|
574
607
|
Represents a lexical scope with three independent namespaces (variables, functions,
|
|
575
608
|
modules), mirroring OpenSCAD's scoping rules.
|
|
@@ -1006,14 +1039,21 @@ names according to OpenSCAD's three-namespace scoping rules::
|
|
|
1006
1039
|
print(root_scope.lookup_variable("x")) # Assignment node
|
|
1007
1040
|
print(root_scope.lookup_module("box")) # ModuleDeclaration node
|
|
1008
1041
|
|
|
1009
|
-
#
|
|
1042
|
+
# Every node's enclosing scope, as this pass built it
|
|
1010
1043
|
box_decl = ast[1]
|
|
1011
1044
|
cube_call = box_decl.children[0]
|
|
1012
|
-
print(cube_call.
|
|
1013
|
-
|
|
1014
|
-
``build_scopes(ast)`` returns the root ``Scope`` object and
|
|
1015
|
-
|
|
1016
|
-
scopes automatically.
|
|
1045
|
+
print(root_scope.scope_of(cube_call).lookup_variable("size")) # ParameterDeclaration node
|
|
1046
|
+
|
|
1047
|
+
``build_scopes(ast)`` returns the root ``Scope`` object and records the scope of every node
|
|
1048
|
+
in the tree in ``root_scope.table``. Scopes form a parent chain so lookups fall through to
|
|
1049
|
+
enclosing scopes automatically.
|
|
1050
|
+
|
|
1051
|
+
A node's scope is not an attribute of the node. An included file is parsed once and its
|
|
1052
|
+
nodes are shared by every file that includes it, but ``include`` puts them in the
|
|
1053
|
+
*includer's* scope, so the same node sits in a different scope for each includer. Each
|
|
1054
|
+
``build_scopes()`` call therefore has its own table, and two files that include the same
|
|
1055
|
+
library can be scoped, and used, at the same time. (Before 3.0, ``node.scope`` held the
|
|
1056
|
+
scope of whichever file had built its scopes last.) Declarations (variables, functions, modules) inside a block are
|
|
1017
1057
|
hoisted to the top of that block's scope before child nodes are visited.
|
|
1018
1058
|
|
|
1019
1059
|
Testing
|
|
@@ -141,12 +141,39 @@ Use ``getASTfromLibraryFile()`` to find and parse library files using OpenSCAD's
|
|
|
141
141
|
The function searches for library files in this order:
|
|
142
142
|
|
|
143
143
|
1. Directory of the current file (if provided)
|
|
144
|
-
2. Directories in the ``OPENSCADPATH`` environment variable
|
|
145
|
-
|
|
146
|
-
|
|
144
|
+
2. Directories in the ``OPENSCADPATH`` environment variable, which add to the
|
|
145
|
+
directories below rather than replacing them, as in OpenSCAD
|
|
146
|
+
3. The platform's libraries folder:
|
|
147
|
+
|
|
148
|
+
- Windows: ``OpenSCAD\libraries`` in the Documents folder Windows reports
|
|
149
|
+
(under OneDrive when Known Folder Move is on)
|
|
147
150
|
- macOS: ``~/Documents/OpenSCAD/libraries``
|
|
148
151
|
- Linux: ``~/.local/share/OpenSCAD/libraries``
|
|
149
152
|
|
|
153
|
+
4. A ``libraries`` directory beside the installed package
|
|
154
|
+
|
|
155
|
+
``librarySearchDirs(currfile)`` returns this list, and a not-found error names
|
|
156
|
+
every directory in it.
|
|
157
|
+
|
|
158
|
+
Strict Commas
|
|
159
|
+
~~~~~~~~~~~~~
|
|
160
|
+
|
|
161
|
+
OpenSCAD accepts a trailing comma almost everywhere. OpenSCAD 2021.01 did not:
|
|
162
|
+
it rejected one after a call's last argument (``cube(1,)``) and after the last
|
|
163
|
+
assignment of a ``let``/``for``/``intersection_for`` (``let(x=1,)``), while
|
|
164
|
+
accepting it in list literals, list comprehensions and parameter lists. To
|
|
165
|
+
parse as 2021.01 did:
|
|
166
|
+
|
|
167
|
+
.. code-block:: python
|
|
168
|
+
|
|
169
|
+
from openscad_parser import strict_commas
|
|
170
|
+
|
|
171
|
+
with strict_commas():
|
|
172
|
+
ast = getASTfromFile("model.scad") # None if it uses a rejected comma
|
|
173
|
+
|
|
174
|
+
The mode nests, restores on exit, and every AST cache keys on it, so a strict
|
|
175
|
+
parse is never served a lenient tree.
|
|
176
|
+
|
|
150
177
|
Advanced AST Generation
|
|
151
178
|
~~~~~~~~~~~~~~~~~~~~~~~~
|
|
152
179
|
|
|
@@ -389,6 +416,7 @@ Expressions
|
|
|
389
416
|
- ``AssertOp(arguments: list[Argument], body: Expression)``: assert clause ``assert(arguments) body``
|
|
390
417
|
- ``FunctionLiteral(parameters: list[ParameterDeclaration], body: Expression)``: Anonymous function expression ``function(parameters) body``
|
|
391
418
|
- ``PrimaryCall(left: Expression, arguments: list[Argument])``: Function calls ``left(arguments)``
|
|
419
|
+
- ``RenderExpression(arguments: list[Argument], children: list[ModuleInstantiation])``: Geometry measured as a value ``render(arguments) { children }``. Braces required; ``render`` stays usable as an ordinary name. Not part of upstream OpenSCAD
|
|
392
420
|
- ``PrimaryIndex(left: Expression, index: Expression)``: Array indexing ``left[index]``
|
|
393
421
|
- ``PrimaryMember(left: Expression, member: Identifier)``: Member access ``left.member``
|
|
394
422
|
|
|
@@ -492,7 +520,7 @@ Main Functions
|
|
|
492
520
|
|
|
493
521
|
``getASTfromLibraryFile(currfile: str, libfile: str, include_comments: bool = False, process_includes: bool = True)``
|
|
494
522
|
Find and parse an OpenSCAD library file using OpenSCAD's search path rules.
|
|
495
|
-
Searches
|
|
523
|
+
Searches the directories ``librarySearchDirs(currfile)`` returns.
|
|
496
524
|
|
|
497
525
|
:param currfile: Full path to the current OpenSCAD file (can be empty string)
|
|
498
526
|
:param libfile: Partial or full path to the library file to find
|
|
@@ -522,11 +550,16 @@ Main Functions
|
|
|
522
550
|
This function removes all cached AST trees from memory.
|
|
523
551
|
|
|
524
552
|
``build_scopes(ast: list[ASTNode]) -> Scope``
|
|
525
|
-
Build a scope tree over an AST
|
|
553
|
+
Build a scope tree over an AST, recording every node's scope in a ``ScopeTable``
|
|
554
|
+
hung on the root (``root.table``). Read a node's scope with ``root.scope_of(node)``.
|
|
526
555
|
|
|
527
556
|
:param ast: A list of top-level AST nodes (as returned by the ``getAST*`` functions)
|
|
528
557
|
:returns: The root ``Scope`` object
|
|
529
558
|
|
|
559
|
+
``build_scopes_into(ast: list[ASTNode], table: ScopeTable) -> Scope``
|
|
560
|
+
The same, recording into a ``ScopeTable`` the caller owns, so several roots (one per
|
|
561
|
+
``use``\ d file, say) can be read back through one table.
|
|
562
|
+
|
|
530
563
|
``Scope``
|
|
531
564
|
Represents a lexical scope with three independent namespaces (variables, functions,
|
|
532
565
|
modules), mirroring OpenSCAD's scoping rules.
|
|
@@ -963,14 +996,21 @@ names according to OpenSCAD's three-namespace scoping rules::
|
|
|
963
996
|
print(root_scope.lookup_variable("x")) # Assignment node
|
|
964
997
|
print(root_scope.lookup_module("box")) # ModuleDeclaration node
|
|
965
998
|
|
|
966
|
-
#
|
|
999
|
+
# Every node's enclosing scope, as this pass built it
|
|
967
1000
|
box_decl = ast[1]
|
|
968
1001
|
cube_call = box_decl.children[0]
|
|
969
|
-
print(cube_call.
|
|
970
|
-
|
|
971
|
-
``build_scopes(ast)`` returns the root ``Scope`` object and
|
|
972
|
-
|
|
973
|
-
scopes automatically.
|
|
1002
|
+
print(root_scope.scope_of(cube_call).lookup_variable("size")) # ParameterDeclaration node
|
|
1003
|
+
|
|
1004
|
+
``build_scopes(ast)`` returns the root ``Scope`` object and records the scope of every node
|
|
1005
|
+
in the tree in ``root_scope.table``. Scopes form a parent chain so lookups fall through to
|
|
1006
|
+
enclosing scopes automatically.
|
|
1007
|
+
|
|
1008
|
+
A node's scope is not an attribute of the node. An included file is parsed once and its
|
|
1009
|
+
nodes are shared by every file that includes it, but ``include`` puts them in the
|
|
1010
|
+
*includer's* scope, so the same node sits in a different scope for each includer. Each
|
|
1011
|
+
``build_scopes()`` call therefore has its own table, and two files that include the same
|
|
1012
|
+
library can be scoped, and used, at the same time. (Before 3.0, ``node.scope`` held the
|
|
1013
|
+
scope of whichever file had built its scopes last.) Declarations (variables, functions, modules) inside a block are
|
|
974
1014
|
hoisted to the top of that block's scope before child nodes are visited.
|
|
975
1015
|
|
|
976
1016
|
Testing
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
from __future__ import unicode_literals
|
|
6
6
|
|
|
7
7
|
from arpeggio import ParserPython
|
|
8
|
-
from .grammar import openscad_language, openscad_language_with_comments, comment, whitespace_only
|
|
8
|
+
from .grammar import openscad_language, openscad_language_with_comments, comment, whitespace_only, strict_commas
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
# --- The parser ---
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
import functools
|
|
1
2
|
import hashlib
|
|
3
|
+
import json
|
|
2
4
|
import os
|
|
3
5
|
import pickle
|
|
4
6
|
import platform
|
|
5
7
|
from typing import Optional
|
|
6
8
|
from arpeggio import NoMatch
|
|
7
|
-
from openscad_parser import getOpenSCADParser
|
|
9
|
+
from openscad_parser import getOpenSCADParser, strict_commas
|
|
10
|
+
from openscad_parser.grammar import _STRICT_COMMAS
|
|
8
11
|
from .source_map import SourceMap, process_includes as process_includes_func
|
|
9
12
|
|
|
10
13
|
# Import all AST nodes from nodes
|
|
@@ -53,6 +56,7 @@ from .nodes import (
|
|
|
53
56
|
LessThanOrEqualOp,
|
|
54
57
|
FunctionLiteral,
|
|
55
58
|
PrimaryCall,
|
|
59
|
+
RenderExpression,
|
|
56
60
|
PrimaryIndex,
|
|
57
61
|
PrimaryMember,
|
|
58
62
|
VectorElement,
|
|
@@ -86,7 +90,7 @@ from .nodes import (
|
|
|
86
90
|
from .builder import ASTBuilderVisitor, Position
|
|
87
91
|
|
|
88
92
|
# Import scope classes
|
|
89
|
-
from .scope import Scope, build_scopes
|
|
93
|
+
from .scope import Scope, ScopeTable, build_scopes, build_scopes_into
|
|
90
94
|
|
|
91
95
|
# Import pretty-printer
|
|
92
96
|
from .pretty_print import to_openscad
|
|
@@ -104,57 +108,82 @@ from .serialization import (
|
|
|
104
108
|
|
|
105
109
|
# --- AST convenience functions ---
|
|
106
110
|
|
|
111
|
+
def _windows_documents_dir() -> str:
|
|
112
|
+
"""Where Windows says My Documents is -- the same SHGetFolderPathW(
|
|
113
|
+
CSIDL_PERSONAL, SHGFP_TYPE_CURRENT) call OpenSCAD makes -- rather than
|
|
114
|
+
assuming ~/Documents: OneDrive's Known Folder Move, on by default, puts
|
|
115
|
+
it at ~/OneDrive/Documents instead."""
|
|
116
|
+
try:
|
|
117
|
+
import ctypes
|
|
118
|
+
buf = ctypes.create_unicode_buffer(260)
|
|
119
|
+
if ctypes.windll.shell32.SHGetFolderPathW(None, 5, None, 0, buf) == 0: # CSIDL_PERSONAL, SHGFP_TYPE_CURRENT
|
|
120
|
+
return buf.value
|
|
121
|
+
except (AttributeError, OSError): # not actually on Windows
|
|
122
|
+
pass
|
|
123
|
+
return os.path.join(os.path.expanduser("~"), "Documents")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# Libraries shipped beside this package -- OpenSCAD's resourcePath("libraries").
|
|
127
|
+
_BUNDLED_LIBRARY_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "libraries")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def librarySearchDirs(currfile: str) -> list[str]:
|
|
131
|
+
"""The directories `include`/`use` search, in order, as OpenSCAD's
|
|
132
|
+
parser_init() builds them: the including file's own directory, then
|
|
133
|
+
every OPENSCADPATH entry, then the user's libraries folder, then
|
|
134
|
+
libraries shipped beside this package.
|
|
135
|
+
|
|
136
|
+
OPENSCADPATH adds to the libraries folder rather than replacing it --
|
|
137
|
+
it used to replace it, so setting it for one library hid every other
|
|
138
|
+
one, a BOSL2 in the default folder included (openscad_cpp_parser #10).
|
|
139
|
+
"""
|
|
140
|
+
dirs = []
|
|
141
|
+
if currfile:
|
|
142
|
+
dirs.append(os.path.dirname(os.path.abspath(currfile)))
|
|
143
|
+
|
|
144
|
+
system = platform.system()
|
|
145
|
+
pathsep = ";" if system == "Windows" else ":"
|
|
146
|
+
for path in os.getenv("OPENSCADPATH", "").split(pathsep):
|
|
147
|
+
expanded_path = os.path.expandvars(path)
|
|
148
|
+
if expanded_path:
|
|
149
|
+
dirs.append(expanded_path)
|
|
150
|
+
|
|
151
|
+
if system == "Windows":
|
|
152
|
+
dirs.append(os.path.join(_windows_documents_dir(), "OpenSCAD", "libraries"))
|
|
153
|
+
elif system == "Darwin":
|
|
154
|
+
dirs.append(os.path.expanduser("~/Documents/OpenSCAD/libraries"))
|
|
155
|
+
elif system == "Linux":
|
|
156
|
+
dirs.append(os.path.expanduser("~/.local/share/OpenSCAD/libraries"))
|
|
157
|
+
# ponytail: only the folder beside the package, no ../share/openscad walk
|
|
158
|
+
dirs.append(_BUNDLED_LIBRARY_DIR)
|
|
159
|
+
return dirs
|
|
160
|
+
|
|
161
|
+
|
|
107
162
|
def findLibraryFile(currfile: str, libfile: str) -> Optional[str]:
|
|
108
|
-
"""Find a library file using OpenSCAD's search path rules
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
1. Directory of the current file (if currfile is provided)
|
|
112
|
-
2. Directories specified in OPENSCADPATH environment variable
|
|
113
|
-
3. Platform-specific default library directories
|
|
114
|
-
|
|
163
|
+
"""Find a library file using OpenSCAD's search path rules: the first
|
|
164
|
+
directory in librarySearchDirs(currfile) that holds it.
|
|
165
|
+
|
|
115
166
|
Args:
|
|
116
167
|
currfile: Full path to the current OpenSCAD file (can be empty string)
|
|
117
168
|
libfile: Partial or full path to the library file to find
|
|
118
|
-
|
|
169
|
+
|
|
119
170
|
Returns:
|
|
120
171
|
Full path to the found library file, or None if not found
|
|
121
172
|
"""
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
# Add directory of current file if provided
|
|
125
|
-
if currfile:
|
|
126
|
-
dirs.append(os.path.dirname(os.path.abspath(currfile)))
|
|
127
|
-
|
|
128
|
-
# Determine path separator and default path based on platform
|
|
129
|
-
pathsep = ":"
|
|
130
|
-
dflt_path = ""
|
|
131
|
-
system = platform.system()
|
|
132
|
-
|
|
133
|
-
if system == "Windows": # pragma: no cover
|
|
134
|
-
dflt_path = os.path.join(os.path.expanduser("~"), "Documents", "OpenSCAD", "libraries")
|
|
135
|
-
pathsep = ";"
|
|
136
|
-
elif system == "Darwin": # pragma: no cover
|
|
137
|
-
dflt_path = os.path.expanduser("~/Documents/OpenSCAD/libraries")
|
|
138
|
-
elif system == "Linux": # pragma: no cover
|
|
139
|
-
dflt_path = os.path.expanduser("~/.local/share/OpenSCAD/libraries")
|
|
140
|
-
|
|
141
|
-
# Get OPENSCADPATH from environment or use default
|
|
142
|
-
env = os.getenv("OPENSCADPATH", dflt_path)
|
|
143
|
-
if env:
|
|
144
|
-
for path in env.split(pathsep):
|
|
145
|
-
expanded_path = os.path.expandvars(path)
|
|
146
|
-
if expanded_path:
|
|
147
|
-
dirs.append(expanded_path)
|
|
148
|
-
|
|
149
|
-
# Search for the file in each directory
|
|
150
|
-
for d in dirs:
|
|
173
|
+
for d in librarySearchDirs(currfile):
|
|
151
174
|
test_file = os.path.join(d, libfile)
|
|
152
175
|
if os.path.isfile(test_file):
|
|
153
176
|
return test_file
|
|
154
|
-
|
|
155
177
|
return None
|
|
156
178
|
|
|
157
179
|
|
|
180
|
+
def _not_found(what: str, filename: str, currfile: str) -> str:
|
|
181
|
+
"""A not-found message that lists every directory searched. Naming only
|
|
182
|
+
the includer read as "only there was searched"."""
|
|
183
|
+
return f"{what} '{filename}' not found. Searched:" + "".join(
|
|
184
|
+
f"\n {d}" for d in librarySearchDirs(currfile))
|
|
185
|
+
|
|
186
|
+
|
|
158
187
|
# Alias for backward compatibility (test_ast_convenience.py imports _find_library_file)
|
|
159
188
|
_find_library_file = findLibraryFile
|
|
160
189
|
|
|
@@ -256,14 +285,15 @@ def getASTfromString(code: str, include_comments: bool = False, origin: str = "<
|
|
|
256
285
|
|
|
257
286
|
|
|
258
287
|
# Module-level in-memory cache for per-file AST trees (no includes resolved)
|
|
259
|
-
# Key: tuple of (absolute file path (str), include_comments (bool))
|
|
288
|
+
# Key: tuple of (absolute file path (str), include_comments (bool), strict_commas (bool))
|
|
260
289
|
# Value: tuple of (AST nodes, modification timestamp)
|
|
261
|
-
_ast_cache: dict[tuple[str, bool], tuple[list[ASTNode] | None, float]] = {}
|
|
290
|
+
_ast_cache: dict[tuple[str, bool, bool], tuple[list[ASTNode] | None, float]] = {}
|
|
262
291
|
|
|
263
292
|
# Resolved (includes-expanded) cache
|
|
264
|
-
# Key: tuple of (absolute file path (str), include_comments (bool), process_includes (bool)
|
|
293
|
+
# Key: tuple of (absolute file path (str), include_comments (bool), process_includes (bool),
|
|
294
|
+
# strict_commas (bool))
|
|
265
295
|
# Value: tuple of (AST nodes, modification timestamp)
|
|
266
|
-
_resolved_cache: dict[tuple[str, bool, bool], tuple[list[ASTNode] | None, float]] = {}
|
|
296
|
+
_resolved_cache: dict[tuple[str, bool, bool, bool], tuple[list[ASTNode] | None, float]] = {}
|
|
267
297
|
|
|
268
298
|
|
|
269
299
|
def _get_disk_cache_dir() -> Optional[str]:
|
|
@@ -284,12 +314,26 @@ def _get_disk_cache_dir() -> Optional[str]:
|
|
|
284
314
|
return None
|
|
285
315
|
|
|
286
316
|
|
|
317
|
+
@functools.lru_cache(maxsize=None)
|
|
318
|
+
def _ast_format_tag() -> str:
|
|
319
|
+
"""Changes whenever the grammar or the AST classes do, so a pickled AST
|
|
320
|
+
from another version is never served: an older pickle lacks any field
|
|
321
|
+
added since (RangeLiteral.implicit_step, say) and reads its default."""
|
|
322
|
+
here = os.path.dirname(os.path.abspath(__file__))
|
|
323
|
+
h = hashlib.sha256()
|
|
324
|
+
for path in (os.path.join(here, "..", "grammar.py"), os.path.join(here, "nodes.py"),
|
|
325
|
+
os.path.join(here, "builder.py"), os.path.join(here, "__init__.py")):
|
|
326
|
+
with open(path, "rb") as f:
|
|
327
|
+
h.update(f.read())
|
|
328
|
+
return h.hexdigest()[:16]
|
|
329
|
+
|
|
330
|
+
|
|
287
331
|
def _disk_cache_path(file_path: str, include_comments: bool) -> Optional[str]:
|
|
288
332
|
"""Get the disk cache file path for a given source file."""
|
|
289
333
|
cache_dir = _get_disk_cache_dir()
|
|
290
334
|
if not cache_dir:
|
|
291
335
|
return None # pragma: no cover
|
|
292
|
-
key = f"{file_path}:{include_comments}"
|
|
336
|
+
key = f"{file_path}:{include_comments}:{_STRICT_COMMAS.get()}:{_ast_format_tag()}"
|
|
293
337
|
h = hashlib.sha256(key.encode()).hexdigest()[:16]
|
|
294
338
|
return os.path.join(cache_dir, f"{h}.pickle")
|
|
295
339
|
|
|
@@ -310,17 +354,83 @@ def _load_from_disk_cache(file_path: str, include_comments: bool, current_mtime:
|
|
|
310
354
|
|
|
311
355
|
|
|
312
356
|
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."""
|
|
357
|
+
"""Save a file's AST to disk cache and update the manifest."""
|
|
314
358
|
cache_path = _disk_cache_path(file_path, include_comments)
|
|
315
359
|
if not cache_path:
|
|
316
360
|
return # pragma: no cover
|
|
317
361
|
try:
|
|
318
362
|
with open(cache_path, 'wb') as f:
|
|
319
363
|
pickle.dump((mtime, ast), f, protocol=pickle.HIGHEST_PROTOCOL)
|
|
364
|
+
except OSError: # pragma: no cover
|
|
365
|
+
return
|
|
366
|
+
cache_fname = os.path.basename(cache_path)
|
|
367
|
+
_manifest_update(cache_fname, file_path)
|
|
368
|
+
_evict_stale_cache()
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _manifest_path() -> Optional[str]:
|
|
372
|
+
"""Get the path to the cache manifest file."""
|
|
373
|
+
cache_dir = _get_disk_cache_dir()
|
|
374
|
+
if not cache_dir:
|
|
375
|
+
return None # pragma: no cover
|
|
376
|
+
return os.path.join(cache_dir, "manifest.json")
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _manifest_load() -> dict[str, str]:
|
|
380
|
+
"""Load the manifest: {cache_filename: source_file_path}."""
|
|
381
|
+
path = _manifest_path()
|
|
382
|
+
if not path or not os.path.exists(path):
|
|
383
|
+
return {}
|
|
384
|
+
try:
|
|
385
|
+
with open(path, 'r') as f:
|
|
386
|
+
return json.load(f)
|
|
387
|
+
except (OSError, json.JSONDecodeError):
|
|
388
|
+
return {}
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _manifest_save(manifest: dict[str, str]):
|
|
392
|
+
"""Save the manifest to disk."""
|
|
393
|
+
path = _manifest_path()
|
|
394
|
+
if not path:
|
|
395
|
+
return # pragma: no cover
|
|
396
|
+
try:
|
|
397
|
+
with open(path, 'w') as f:
|
|
398
|
+
json.dump(manifest, f)
|
|
320
399
|
except OSError: # pragma: no cover
|
|
321
400
|
pass
|
|
322
401
|
|
|
323
402
|
|
|
403
|
+
def _manifest_update(cache_fname: str, source_path: str):
|
|
404
|
+
"""Add or update an entry in the manifest."""
|
|
405
|
+
manifest = _manifest_load()
|
|
406
|
+
manifest[cache_fname] = source_path
|
|
407
|
+
_manifest_save(manifest)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _evict_stale_cache():
|
|
411
|
+
"""Remove cache entries whose source files no longer exist."""
|
|
412
|
+
cache_dir = _get_disk_cache_dir()
|
|
413
|
+
if not cache_dir:
|
|
414
|
+
return # pragma: no cover
|
|
415
|
+
manifest = _manifest_load()
|
|
416
|
+
if not manifest:
|
|
417
|
+
return
|
|
418
|
+
stale_keys = [
|
|
419
|
+
fname for fname, source_path in manifest.items()
|
|
420
|
+
if not os.path.exists(source_path)
|
|
421
|
+
]
|
|
422
|
+
if not stale_keys:
|
|
423
|
+
return
|
|
424
|
+
for fname in stale_keys:
|
|
425
|
+
cache_file = os.path.join(cache_dir, fname)
|
|
426
|
+
try:
|
|
427
|
+
os.remove(cache_file)
|
|
428
|
+
except OSError:
|
|
429
|
+
pass
|
|
430
|
+
del manifest[fname]
|
|
431
|
+
_manifest_save(manifest)
|
|
432
|
+
|
|
433
|
+
|
|
324
434
|
def clear_ast_cache():
|
|
325
435
|
"""Clear the in-memory AST cache.
|
|
326
436
|
|
|
@@ -346,7 +456,7 @@ def clear_disk_cache():
|
|
|
346
456
|
cache_dir = _get_disk_cache_dir()
|
|
347
457
|
if cache_dir and os.path.isdir(cache_dir):
|
|
348
458
|
for fname in os.listdir(cache_dir):
|
|
349
|
-
if fname.endswith('.pickle'):
|
|
459
|
+
if fname.endswith('.pickle') or fname == 'manifest.json':
|
|
350
460
|
try:
|
|
351
461
|
os.remove(os.path.join(cache_dir, fname))
|
|
352
462
|
except OSError: # pragma: no cover
|
|
@@ -362,7 +472,7 @@ def _parse_single_file(file_path: str, include_comments: bool = False) -> list[A
|
|
|
362
472
|
raise FileNotFoundError(f"File {file_path} not found")
|
|
363
473
|
|
|
364
474
|
current_mtime = os.path.getmtime(file_path)
|
|
365
|
-
cache_key = (file_path, include_comments)
|
|
475
|
+
cache_key = (file_path, include_comments, _STRICT_COMMAS.get())
|
|
366
476
|
|
|
367
477
|
# Check in-memory cache
|
|
368
478
|
if cache_key in _ast_cache:
|
|
@@ -408,10 +518,7 @@ def _resolve_includes(ast_nodes: list[ASTNode] | None, current_file: str,
|
|
|
408
518
|
filename = node.filepath.val
|
|
409
519
|
lib_file = findLibraryFile(current_file, filename)
|
|
410
520
|
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
|
-
)
|
|
521
|
+
raise FileNotFoundError(_not_found("Included file", filename, current_file))
|
|
415
522
|
lib_file = os.path.abspath(lib_file)
|
|
416
523
|
if lib_file in visited:
|
|
417
524
|
continue
|
|
@@ -483,7 +590,7 @@ def getASTfromFile(file: str, include_comments: bool = False, process_includes:
|
|
|
483
590
|
return _parse_single_file(file_path, include_comments)
|
|
484
591
|
|
|
485
592
|
# Check resolved cache (in-memory only since resolved ASTs depend on multiple files)
|
|
486
|
-
resolved_key = (file_path, include_comments, True)
|
|
593
|
+
resolved_key = (file_path, include_comments, True, _STRICT_COMMAS.get())
|
|
487
594
|
if resolved_key in _resolved_cache:
|
|
488
595
|
cached_ast, cached_mtime = _resolved_cache[resolved_key]
|
|
489
596
|
if cached_mtime == current_mtime:
|
|
@@ -507,13 +614,9 @@ def getASTfromLibraryFile(currfile: str, libfile: str, include_comments: bool =
|
|
|
507
614
|
Find and parse an OpenSCAD library file using OpenSCAD's search path rules,
|
|
508
615
|
and return both the AST and absolute path to the file.
|
|
509
616
|
|
|
510
|
-
This function searches
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
3. Platform-specific default library directories:
|
|
514
|
-
- Windows: ~/Documents/OpenSCAD/libraries
|
|
515
|
-
- macOS: ~/Documents/OpenSCAD/libraries
|
|
516
|
-
- Linux: ~/.local/share/OpenSCAD/libraries
|
|
617
|
+
This function searches the directories librarySearchDirs(currfile)
|
|
618
|
+
returns, in order: the current file's directory, OPENSCADPATH, the
|
|
619
|
+
platform's libraries folder, then libraries beside this package.
|
|
517
620
|
|
|
518
621
|
Once found, the file is parsed using getASTfromFile(), which includes
|
|
519
622
|
caching support and include processing.
|
|
@@ -557,10 +660,7 @@ def getASTfromLibraryFile(currfile: str, libfile: str, include_comments: bool =
|
|
|
557
660
|
found_file = findLibraryFile(currfile, libfile)
|
|
558
661
|
|
|
559
662
|
if found_file is None:
|
|
560
|
-
raise FileNotFoundError(
|
|
561
|
-
f"Library file '{libfile}' not found in search paths. "
|
|
562
|
-
f"Searched in: current file directory, OPENSCADPATH, and platform default paths."
|
|
563
|
-
)
|
|
663
|
+
raise FileNotFoundError(_not_found("Library file", libfile, currfile))
|
|
564
664
|
|
|
565
665
|
# Use getASTfromFile() which includes caching and include processing
|
|
566
666
|
ast = getASTfromFile(found_file, include_comments=include_comments, process_includes=process_includes)
|
|
@@ -984,6 +984,13 @@ class ASTBuilderVisitor(PTNodeVisitor):
|
|
|
984
984
|
def visit_primary(self, node, children):
|
|
985
985
|
return children[0]
|
|
986
986
|
|
|
987
|
+
def visit_render_expr(self, node, children):
|
|
988
|
+
# Grammar: (KWD_RENDER, TOK_PAREN, arguments, TOK_ENDPAREN, statement_block)
|
|
989
|
+
arguments = children.get_rule("arguments") or []
|
|
990
|
+
block = children.get_rule("statement_block") or []
|
|
991
|
+
return RenderExpression(arguments=list(arguments), children=list(block),
|
|
992
|
+
position=self._get_node_position(node))
|
|
993
|
+
|
|
987
994
|
def visit_range_expr(self, node, children):
|
|
988
995
|
# OpenSCAD syntax: [start:end] or [start:step:end]
|
|
989
996
|
start = children[0]
|
|
@@ -993,7 +1000,8 @@ class ASTBuilderVisitor(PTNodeVisitor):
|
|
|
993
1000
|
else:
|
|
994
1001
|
end = children[1]
|
|
995
1002
|
step = NumberLiteral(val=1.0, position=self._get_node_position(node))
|
|
996
|
-
return RangeLiteral(start=start, end=end, step=step,
|
|
1003
|
+
return RangeLiteral(start=start, end=end, step=step, implicit_step=len(children) <= 2,
|
|
1004
|
+
position=self._get_node_position(node))
|
|
997
1005
|
|
|
998
1006
|
def visit_vector_expr(self, node, children):
|
|
999
1007
|
elements = children if children else []
|