scadbatch 2.0.0__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.
- scadbatch/__init__.py +54 -0
- scadbatch/cli.py +322 -0
- scadbatch/engine.py +171 -0
- scadbatch/export.py +46 -0
- scadbatch/gui.py +717 -0
- scadbatch/params.py +902 -0
- scadbatch/runner.py +760 -0
- scadbatch-2.0.0.dist-info/METADATA +217 -0
- scadbatch-2.0.0.dist-info/RECORD +13 -0
- scadbatch-2.0.0.dist-info/WHEEL +5 -0
- scadbatch-2.0.0.dist-info/entry_points.txt +7 -0
- scadbatch-2.0.0.dist-info/licenses/LICENSE +661 -0
- scadbatch-2.0.0.dist-info/top_level.txt +1 -0
scadbatch/__init__.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Batch export models from parametric OpenSCAD designs using CSV or Customizer JSON
|
|
2
|
+
parameter sets."""
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
from scadbatch.engine import (
|
|
7
|
+
Engine,
|
|
8
|
+
OpenSCADError,
|
|
9
|
+
OpenSCADNotFound,
|
|
10
|
+
detect_engine,
|
|
11
|
+
find_openscad,
|
|
12
|
+
)
|
|
13
|
+
from scadbatch.params import (
|
|
14
|
+
ScadRange,
|
|
15
|
+
coerce_cell,
|
|
16
|
+
construct_d_flags,
|
|
17
|
+
csv_to_json,
|
|
18
|
+
json_to_csv,
|
|
19
|
+
output_name,
|
|
20
|
+
parse_selection,
|
|
21
|
+
read_csv,
|
|
22
|
+
read_json,
|
|
23
|
+
read_parameters,
|
|
24
|
+
sanitize_filename,
|
|
25
|
+
to_scad_literal,
|
|
26
|
+
)
|
|
27
|
+
from scadbatch.runner import BatchResult, ExportResult, batch_export
|
|
28
|
+
|
|
29
|
+
# Library consumers configure logging themselves; without this, ERROR records would
|
|
30
|
+
# reach stderr through logging.lastResort.
|
|
31
|
+
logging.getLogger(__name__).addHandler(logging.NullHandler())
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"BatchResult",
|
|
35
|
+
"Engine",
|
|
36
|
+
"OpenSCADError",
|
|
37
|
+
"OpenSCADNotFound",
|
|
38
|
+
"ExportResult",
|
|
39
|
+
"ScadRange",
|
|
40
|
+
"batch_export",
|
|
41
|
+
"coerce_cell",
|
|
42
|
+
"construct_d_flags",
|
|
43
|
+
"csv_to_json",
|
|
44
|
+
"detect_engine",
|
|
45
|
+
"find_openscad",
|
|
46
|
+
"json_to_csv",
|
|
47
|
+
"output_name",
|
|
48
|
+
"parse_selection",
|
|
49
|
+
"read_csv",
|
|
50
|
+
"read_json",
|
|
51
|
+
"read_parameters",
|
|
52
|
+
"sanitize_filename",
|
|
53
|
+
"to_scad_literal",
|
|
54
|
+
]
|
scadbatch/cli.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Command-line interface.
|
|
2
|
+
|
|
3
|
+
Available subcommands:
|
|
4
|
+
- export: Batch export models from CSV or Customizer JSON parameter sets.
|
|
5
|
+
- csv2json: Convert CSV parameter files to JSON.
|
|
6
|
+
- json2csv: Convert JSON parameter files to CSV.
|
|
7
|
+
- gui: Launch the graphical user interface."""
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import importlib.util
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from scadbatch.engine import OpenSCADError, OpenSCADNotFound, detect_engine
|
|
16
|
+
from scadbatch.params import DEFAULT_ENCODING, csv_to_json, json_to_csv
|
|
17
|
+
from scadbatch.runner import _TOOL_NAME, _tool_version, batch_export
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _VersionAction(argparse.Action):
|
|
21
|
+
"""``--version``: this tool's version, and the OpenSCAD a run here would pick.
|
|
22
|
+
|
|
23
|
+
Both halves are what a bug report needs, and the engine half cannot be read off the
|
|
24
|
+
install: which build answers depends on PATH and on $OPENSCAD, and the same test can
|
|
25
|
+
pass against one and fail against another (#68).
|
|
26
|
+
|
|
27
|
+
Resolving an engine is I/O and can fail, so the tool line is printed either way and a
|
|
28
|
+
failure is reported on the engine line rather than as a non-zero exit. A version flag
|
|
29
|
+
that exits 1 because OpenSCAD is missing would be worse than the question it answers.
|
|
30
|
+
An ordinary run is unaffected: this resolves only when the flag is given.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, option_strings, dest, **kwargs):
|
|
34
|
+
super().__init__(option_strings, dest, nargs=0, **kwargs)
|
|
35
|
+
|
|
36
|
+
def __call__(self, parser, namespace, values, option_string=None):
|
|
37
|
+
print(f"{_TOOL_NAME} {_tool_version() or 'unknown (not installed)'}")
|
|
38
|
+
try:
|
|
39
|
+
engine = detect_engine()
|
|
40
|
+
except OpenSCADNotFound:
|
|
41
|
+
print("openscad: not found")
|
|
42
|
+
except OpenSCADError as e:
|
|
43
|
+
print(f"openscad: {e}")
|
|
44
|
+
else:
|
|
45
|
+
print(f"openscad {engine.version} ({engine.path})")
|
|
46
|
+
parser.exit()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse_arguments(argv=None):
|
|
50
|
+
"""
|
|
51
|
+
Parse and return the command-line arguments.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
argv (list of str or None): Arguments to parse; defaults to sys.argv[1:].
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
argparse.Namespace: Parsed command-line arguments.
|
|
58
|
+
"""
|
|
59
|
+
parser = argparse.ArgumentParser(
|
|
60
|
+
description=(
|
|
61
|
+
"Batch export models from OpenSCAD using CSV or JSON parameters, "
|
|
62
|
+
"and convert between CSV and JSON."
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
parser.add_argument(
|
|
66
|
+
"-v",
|
|
67
|
+
"--verbose",
|
|
68
|
+
action="store_true",
|
|
69
|
+
help="Show the OpenSCAD command line for each export.",
|
|
70
|
+
)
|
|
71
|
+
parser.add_argument(
|
|
72
|
+
"--version",
|
|
73
|
+
action=_VersionAction,
|
|
74
|
+
help="Show the scadbatch version and the OpenSCAD it would use, then exit.",
|
|
75
|
+
)
|
|
76
|
+
subparsers = parser.add_subparsers(dest="command", required=True, help="Sub-commands")
|
|
77
|
+
|
|
78
|
+
# Export subcommand
|
|
79
|
+
export_parser = subparsers.add_parser(
|
|
80
|
+
"export",
|
|
81
|
+
help="Export each parameter set, in any format OpenSCAD can write.",
|
|
82
|
+
)
|
|
83
|
+
export_parser.add_argument("scad_file", help="Path to the OpenSCAD (.scad) file.")
|
|
84
|
+
export_parser.add_argument(
|
|
85
|
+
"parameter_file", help="Path to the CSV or JSON file containing parameters."
|
|
86
|
+
)
|
|
87
|
+
export_parser.add_argument(
|
|
88
|
+
"output_folder", help="Directory where the exported files will be saved."
|
|
89
|
+
)
|
|
90
|
+
export_parser.add_argument(
|
|
91
|
+
"--openscad-path",
|
|
92
|
+
"--openscad_path",
|
|
93
|
+
dest="openscad_path",
|
|
94
|
+
default=None,
|
|
95
|
+
help=(
|
|
96
|
+
"Path to the OpenSCAD executable. By default: $OPENSCAD, then 'openscad' on "
|
|
97
|
+
"PATH, then the platform's default install location."
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
export_parser.add_argument(
|
|
101
|
+
"--format",
|
|
102
|
+
action="append",
|
|
103
|
+
metavar="EXT",
|
|
104
|
+
help=(
|
|
105
|
+
"Output format by extension (stl, off, 3mf, png, csg, ...), as accepted by the "
|
|
106
|
+
"detected OpenSCAD's -o. Repeat to export every case in several formats. "
|
|
107
|
+
"Defaults to stl. A format the build does not advertise is warned about, "
|
|
108
|
+
"not refused."
|
|
109
|
+
),
|
|
110
|
+
)
|
|
111
|
+
export_parser.add_argument(
|
|
112
|
+
"--export-format",
|
|
113
|
+
"--export_format",
|
|
114
|
+
dest="export_format",
|
|
115
|
+
choices=["asciistl", "binstl"],
|
|
116
|
+
default="binstl",
|
|
117
|
+
help="STL flavour: asciistl or binstl. Defaults to binstl. Only applies to stl.",
|
|
118
|
+
)
|
|
119
|
+
image_group = export_parser.add_argument_group("image output (png)")
|
|
120
|
+
image_group.add_argument("--camera", help="OpenSCAD --camera, e.g. 0,0,0,55,0,25,140")
|
|
121
|
+
image_group.add_argument("--imgsize", help="OpenSCAD --imgsize, e.g. 1024,768")
|
|
122
|
+
image_group.add_argument("--colorscheme", help="OpenSCAD --colorscheme, e.g. Tomorrow")
|
|
123
|
+
export_parser.add_argument(
|
|
124
|
+
"--select",
|
|
125
|
+
type=str,
|
|
126
|
+
default=None,
|
|
127
|
+
help=(
|
|
128
|
+
"Select specific parameter sets to export using indices and ranges. "
|
|
129
|
+
"Supported formats: "
|
|
130
|
+
"'0-5' (range), '1-3,7,10-12' (multiple ranges and indices), "
|
|
131
|
+
"'2,4' (specific indices), "
|
|
132
|
+
"'every:2 in 0-10' (every 2nd index in range), "
|
|
133
|
+
"'from:5' (from index 5 onward), "
|
|
134
|
+
"'up_to:4' (up to index 4 inclusive). "
|
|
135
|
+
"You can combine multiple selections separated by commas. "
|
|
136
|
+
"Indices are zero-based."
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
export_parser.add_argument(
|
|
140
|
+
"-j",
|
|
141
|
+
"--jobs",
|
|
142
|
+
type=int,
|
|
143
|
+
metavar="N",
|
|
144
|
+
help="Run up to N OpenSCAD processes at once. Defaults to the number of CPUs.",
|
|
145
|
+
)
|
|
146
|
+
export_parser.add_argument(
|
|
147
|
+
"--sequential",
|
|
148
|
+
action="store_true",
|
|
149
|
+
help="Deprecated: same as --jobs 1.",
|
|
150
|
+
)
|
|
151
|
+
existing = export_parser.add_mutually_exclusive_group()
|
|
152
|
+
existing.add_argument(
|
|
153
|
+
"--overwrite",
|
|
154
|
+
action="store_true",
|
|
155
|
+
help="Re-export a case even if its output file already exists (the default).",
|
|
156
|
+
)
|
|
157
|
+
existing.add_argument(
|
|
158
|
+
"--skip-existing",
|
|
159
|
+
action="store_true",
|
|
160
|
+
help="Skip a case whose output file already exists.",
|
|
161
|
+
)
|
|
162
|
+
export_parser.add_argument(
|
|
163
|
+
"--encoding",
|
|
164
|
+
metavar="NAME",
|
|
165
|
+
help=(
|
|
166
|
+
"Text encoding of the parameter file. Defaults to UTF-8, tolerating the "
|
|
167
|
+
"byte-order mark Excel writes; pass e.g. cp1252 for a spreadsheet export that "
|
|
168
|
+
"is not UTF-8."
|
|
169
|
+
),
|
|
170
|
+
)
|
|
171
|
+
export_parser.add_argument(
|
|
172
|
+
"--name-template",
|
|
173
|
+
metavar="TEMPLATE",
|
|
174
|
+
help=(
|
|
175
|
+
"How to name output files (without extension). Fields: {name} (the set name or "
|
|
176
|
+
"exported_filename, else model_<index>), {index}, and any parameter, e.g. "
|
|
177
|
+
"'{name}_d{diameter}' or 'part_{index:03d}'. Names are made filesystem-safe."
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
export_parser.add_argument(
|
|
181
|
+
"-n",
|
|
182
|
+
"--dry-run",
|
|
183
|
+
action="store_true",
|
|
184
|
+
help="Print the OpenSCAD command for each case and run nothing.",
|
|
185
|
+
)
|
|
186
|
+
export_parser.add_argument(
|
|
187
|
+
"--timeout",
|
|
188
|
+
type=float,
|
|
189
|
+
metavar="SECONDS",
|
|
190
|
+
help="Kill a case that runs longer than this and record it as timed out.",
|
|
191
|
+
)
|
|
192
|
+
export_parser.add_argument(
|
|
193
|
+
"--summary",
|
|
194
|
+
metavar="PATH.json",
|
|
195
|
+
help=(
|
|
196
|
+
"Write a JSON record of the run: OpenSCAD version, inputs, and per-case "
|
|
197
|
+
"status, duration, return code, warnings, errors and command line. counts.errors "
|
|
198
|
+
"is the number of cases OpenSCAD printed an error for, including cases it "
|
|
199
|
+
"exited 0 on."
|
|
200
|
+
),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# csv2json subcommand
|
|
204
|
+
csv2json_parser = subparsers.add_parser("csv2json", help="Convert CSV parameter file to JSON.")
|
|
205
|
+
csv2json_parser.add_argument("csv_file", help="Path to the CSV file.")
|
|
206
|
+
csv2json_parser.add_argument("json_file", help="Path to the output JSON file.")
|
|
207
|
+
csv2json_parser.add_argument(
|
|
208
|
+
"--encoding", metavar="NAME", help="Text encoding of the CSV file. Defaults to UTF-8."
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# json2csv subcommand
|
|
212
|
+
json2csv_parser = subparsers.add_parser("json2csv", help="Convert JSON parameter file to CSV.")
|
|
213
|
+
json2csv_parser.add_argument("json_file", help="Path to the JSON file.")
|
|
214
|
+
json2csv_parser.add_argument("csv_file", help="Path to the output CSV file.")
|
|
215
|
+
json2csv_parser.add_argument(
|
|
216
|
+
"--encoding", metavar="NAME", help="Text encoding of the JSON file. Defaults to UTF-8."
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# GUI subcommand
|
|
220
|
+
subparsers.add_parser("gui", help="Launch the graphical user interface.")
|
|
221
|
+
|
|
222
|
+
return parser.parse_args(argv)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def main(argv=None):
|
|
226
|
+
"""
|
|
227
|
+
Entry point of the module. Parses arguments and executes the corresponding subcommand.
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
int: Process exit code. Non-zero if any export failed or the input was invalid.
|
|
231
|
+
"""
|
|
232
|
+
args = parse_arguments(argv)
|
|
233
|
+
logger = logging.getLogger("scadbatch")
|
|
234
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
235
|
+
handler.setFormatter(logging.Formatter("%(message)s"))
|
|
236
|
+
logger.addHandler(handler)
|
|
237
|
+
logger.setLevel(logging.DEBUG if args.verbose else logging.INFO)
|
|
238
|
+
try:
|
|
239
|
+
return _run(args)
|
|
240
|
+
finally:
|
|
241
|
+
logger.removeHandler(handler)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _run(args):
|
|
245
|
+
if args.command == "export":
|
|
246
|
+
if args.sequential:
|
|
247
|
+
print("Warning: --sequential is deprecated; use --jobs 1.", file=sys.stderr)
|
|
248
|
+
if args.jobs is not None and args.jobs < 1:
|
|
249
|
+
print("Error: --jobs must be at least 1.", file=sys.stderr)
|
|
250
|
+
return 1
|
|
251
|
+
if args.summary:
|
|
252
|
+
# Fail now, not after a long batch, if the summary cannot be written there.
|
|
253
|
+
try:
|
|
254
|
+
os.makedirs(os.path.dirname(os.path.abspath(args.summary)), exist_ok=True)
|
|
255
|
+
with open(args.summary, "a", encoding="utf-8"):
|
|
256
|
+
pass
|
|
257
|
+
except OSError as e:
|
|
258
|
+
print(f"Error: cannot write summary to {args.summary}: {e}", file=sys.stderr)
|
|
259
|
+
return 1
|
|
260
|
+
try:
|
|
261
|
+
result = batch_export(
|
|
262
|
+
args.scad_file,
|
|
263
|
+
args.parameter_file,
|
|
264
|
+
args.output_folder,
|
|
265
|
+
args.openscad_path,
|
|
266
|
+
args.export_format,
|
|
267
|
+
args.select,
|
|
268
|
+
args.sequential,
|
|
269
|
+
formats=args.format or ["stl"],
|
|
270
|
+
image_options={k: getattr(args, k) for k in ("camera", "imgsize", "colorscheme")},
|
|
271
|
+
jobs=args.jobs,
|
|
272
|
+
skip_existing=args.skip_existing,
|
|
273
|
+
dry_run=args.dry_run,
|
|
274
|
+
timeout=args.timeout,
|
|
275
|
+
name_template=args.name_template,
|
|
276
|
+
encoding=args.encoding,
|
|
277
|
+
)
|
|
278
|
+
except (OpenSCADError, ValueError) as e:
|
|
279
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
280
|
+
return 1
|
|
281
|
+
except KeyboardInterrupt:
|
|
282
|
+
print("Interrupted.", file=sys.stderr)
|
|
283
|
+
return 130
|
|
284
|
+
print()
|
|
285
|
+
print(result.summary())
|
|
286
|
+
if args.summary:
|
|
287
|
+
result.write_summary(args.summary)
|
|
288
|
+
print(f"Summary written to {args.summary}")
|
|
289
|
+
return 1 if result.failures else 0
|
|
290
|
+
if args.command in ("csv2json", "json2csv"):
|
|
291
|
+
try:
|
|
292
|
+
encoding = args.encoding or DEFAULT_ENCODING
|
|
293
|
+
if args.command == "csv2json":
|
|
294
|
+
csv_to_json(args.csv_file, args.json_file, encoding)
|
|
295
|
+
else:
|
|
296
|
+
json_to_csv(args.json_file, args.csv_file, encoding)
|
|
297
|
+
except (ValueError, TypeError) as e:
|
|
298
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
299
|
+
return 1
|
|
300
|
+
elif args.command == "gui":
|
|
301
|
+
try:
|
|
302
|
+
# Absolute, not `from . import gui`: in a frozen one-file build this module is
|
|
303
|
+
# __main__ and has no parent package, so a relative import cannot resolve.
|
|
304
|
+
from scadbatch import gui
|
|
305
|
+
except ImportError as e:
|
|
306
|
+
if importlib.util.find_spec("scadbatch.gui") is None:
|
|
307
|
+
# The module is not in this build at all, which is a packaging fault in a
|
|
308
|
+
# frozen binary rather than anything the user can install.
|
|
309
|
+
print(f"This build does not include the graphical interface: {e}", file=sys.stderr)
|
|
310
|
+
else:
|
|
311
|
+
print(
|
|
312
|
+
f"The graphical interface is unavailable: {e}. It needs tkinter, which some "
|
|
313
|
+
"Linux distributions package separately (try installing python3-tk).",
|
|
314
|
+
file=sys.stderr,
|
|
315
|
+
)
|
|
316
|
+
return 1
|
|
317
|
+
gui.main() # outside the try, so a failure inside the GUI is not reported as this
|
|
318
|
+
return 0
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
if __name__ == "__main__":
|
|
322
|
+
sys.exit(main())
|
scadbatch/engine.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Finding the OpenSCAD executable and learning what it can do."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
log = logging.getLogger("scadbatch")
|
|
14
|
+
|
|
15
|
+
ENV_VAR = "OPENSCAD"
|
|
16
|
+
|
|
17
|
+
# Where the official installers put the executable when it is not on PATH.
|
|
18
|
+
PLATFORM_DEFAULTS = {
|
|
19
|
+
"win32": [
|
|
20
|
+
r"C:\Program Files\OpenSCAD\openscad.com",
|
|
21
|
+
r"C:\Program Files\OpenSCAD\openscad.exe",
|
|
22
|
+
r"C:\Program Files (x86)\OpenSCAD\openscad.exe",
|
|
23
|
+
],
|
|
24
|
+
"darwin": ["/Applications/OpenSCAD.app/Contents/MacOS/OpenSCAD"],
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
_VERSION_RE = re.compile(r"OpenSCAD version (\S+)")
|
|
28
|
+
# The -o paragraph of --help: "the file extension specifies the type: stl, off, ..."
|
|
29
|
+
_FORMATS_RE = re.compile(r"the type:\s*([a-z0-9]+(?:\s*,\s*[a-z0-9]+)*)", re.IGNORECASE)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class OpenSCADError(Exception):
|
|
33
|
+
"""OpenSCAD could not be found, started, or understood."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class OpenSCADNotFound(OpenSCADError):
|
|
37
|
+
"""No usable OpenSCAD executable was found."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Engine:
|
|
42
|
+
"""A resolved OpenSCAD executable and its capabilities."""
|
|
43
|
+
|
|
44
|
+
path: str
|
|
45
|
+
version: str
|
|
46
|
+
version_tuple: tuple[int, ...]
|
|
47
|
+
export_formats: frozenset[str] | None = None
|
|
48
|
+
"""Output extensions this build accepts for ``-o``, or None if --help was unreadable."""
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def supports_parameter_sets(self) -> bool:
|
|
52
|
+
"""``-p FILE -P SET`` (Customizer parameter sets), added in 2019.05."""
|
|
53
|
+
return self.version_tuple >= (2019, 5)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def find_openscad(explicit: str | None = None) -> str:
|
|
57
|
+
"""
|
|
58
|
+
Resolve the OpenSCAD executable to run.
|
|
59
|
+
|
|
60
|
+
Order: ``explicit`` (a path or a command name), then ``$OPENSCAD``, then
|
|
61
|
+
``openscad`` on PATH, then the platform's default install location. An explicit
|
|
62
|
+
value or environment variable that does not resolve is an error rather than a
|
|
63
|
+
fallback, since the user asked for that one.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
OpenSCADNotFound: With a message listing what was tried.
|
|
67
|
+
"""
|
|
68
|
+
if explicit:
|
|
69
|
+
candidates = [(f"--openscad-path {explicit!r}", explicit)]
|
|
70
|
+
elif os.environ.get(ENV_VAR):
|
|
71
|
+
candidates = [(f"${ENV_VAR}={os.environ[ENV_VAR]!r}", os.environ[ENV_VAR])]
|
|
72
|
+
else:
|
|
73
|
+
candidates = [("'openscad' on PATH", "openscad")]
|
|
74
|
+
candidates += [(p, p) for p in PLATFORM_DEFAULTS.get(sys.platform, [])]
|
|
75
|
+
|
|
76
|
+
for _, candidate in candidates:
|
|
77
|
+
resolved = _resolve(candidate)
|
|
78
|
+
if resolved:
|
|
79
|
+
return resolved
|
|
80
|
+
tried = ", ".join(label for label, _ in candidates)
|
|
81
|
+
hint = f"Install OpenSCAD, add it to PATH, or pass --openscad-path / set ${ENV_VAR}."
|
|
82
|
+
for _, candidate in candidates:
|
|
83
|
+
if os.path.dirname(candidate) and os.path.isfile(candidate):
|
|
84
|
+
raise OpenSCADNotFound(f"{candidate!r} exists but is not executable. {hint}")
|
|
85
|
+
raise OpenSCADNotFound(f"OpenSCAD executable not found (tried {tried}). {hint}")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _resolve(candidate: str) -> str | None:
|
|
89
|
+
if os.path.dirname(candidate):
|
|
90
|
+
return candidate if os.path.isfile(candidate) and os.access(candidate, os.X_OK) else None
|
|
91
|
+
return shutil.which(candidate)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def parse_version(text: str) -> tuple[str, tuple[int, ...]]:
|
|
95
|
+
"""
|
|
96
|
+
Extract the version from ``openscad --version`` output.
|
|
97
|
+
|
|
98
|
+
Returns the version string as printed and its leading numeric components,
|
|
99
|
+
e.g. ``("2021.01", (2021, 1))`` or ``("2026.09.05", (2026, 9, 5))``.
|
|
100
|
+
|
|
101
|
+
Raises:
|
|
102
|
+
OpenSCADError: If no version line is present.
|
|
103
|
+
"""
|
|
104
|
+
match = _VERSION_RE.search(text)
|
|
105
|
+
if not match:
|
|
106
|
+
raise OpenSCADError(f"Could not parse OpenSCAD version from: {text.strip()!r}")
|
|
107
|
+
version = match.group(1)
|
|
108
|
+
numbers = []
|
|
109
|
+
for part in version.split("."):
|
|
110
|
+
if not part.isdigit():
|
|
111
|
+
break
|
|
112
|
+
numbers.append(int(part))
|
|
113
|
+
if not numbers:
|
|
114
|
+
raise OpenSCADError(f"Unrecognised OpenSCAD version {version!r}")
|
|
115
|
+
return version, tuple(numbers)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def parse_export_formats(help_text: str) -> frozenset[str] | None:
|
|
119
|
+
"""
|
|
120
|
+
Extract the ``-o`` output extensions from ``openscad --help`` output, or None if
|
|
121
|
+
the list is not where this build's help puts it.
|
|
122
|
+
"""
|
|
123
|
+
match = _FORMATS_RE.search(" ".join(help_text.split()))
|
|
124
|
+
if not match:
|
|
125
|
+
return None
|
|
126
|
+
return frozenset(token.strip().lower() for token in match.group(1).split(","))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def detect_engine(explicit: str | None = None) -> Engine:
|
|
130
|
+
"""
|
|
131
|
+
Find OpenSCAD and ask it for its version.
|
|
132
|
+
|
|
133
|
+
Raises:
|
|
134
|
+
OpenSCADNotFound: If no executable resolves.
|
|
135
|
+
OpenSCADError: If it cannot be run or its version output is not understood.
|
|
136
|
+
"""
|
|
137
|
+
path = find_openscad(explicit)
|
|
138
|
+
try:
|
|
139
|
+
completed = subprocess.run(
|
|
140
|
+
[path, "--version"],
|
|
141
|
+
capture_output=True,
|
|
142
|
+
text=True,
|
|
143
|
+
errors="replace",
|
|
144
|
+
stdin=subprocess.DEVNULL,
|
|
145
|
+
timeout=60,
|
|
146
|
+
)
|
|
147
|
+
except (OSError, subprocess.TimeoutExpired) as e:
|
|
148
|
+
raise OpenSCADError(f"Could not run {path!r}: {e}") from e
|
|
149
|
+
if completed.returncode != 0:
|
|
150
|
+
raise OpenSCADError(
|
|
151
|
+
f"{path!r} --version exited with {completed.returncode}: {completed.stderr.strip()}"
|
|
152
|
+
)
|
|
153
|
+
# OpenSCAD prints its version on stderr; look at both streams.
|
|
154
|
+
version, version_tuple = parse_version(completed.stdout + completed.stderr)
|
|
155
|
+
log.info("Using OpenSCAD version %s at %s", version, path)
|
|
156
|
+
export_formats = None
|
|
157
|
+
try:
|
|
158
|
+
help_run = subprocess.run(
|
|
159
|
+
[path, "--help"],
|
|
160
|
+
capture_output=True,
|
|
161
|
+
text=True,
|
|
162
|
+
errors="replace",
|
|
163
|
+
stdin=subprocess.DEVNULL,
|
|
164
|
+
timeout=60,
|
|
165
|
+
)
|
|
166
|
+
export_formats = parse_export_formats(help_run.stdout + help_run.stderr)
|
|
167
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
168
|
+
pass
|
|
169
|
+
if export_formats is None:
|
|
170
|
+
log.debug("Could not read the supported output formats from --help; not validating.")
|
|
171
|
+
return Engine(path, version, version_tuple, export_formats)
|
scadbatch/export.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Compatibility shim.
|
|
2
|
+
|
|
3
|
+
The implementation moved to :mod:`scadbatch.params`, :mod:`scadbatch.runner`
|
|
4
|
+
and :mod:`scadbatch.cli`. This module re-exports the old names so existing imports
|
|
5
|
+
and ``python -m scadbatch.export`` keep working.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from scadbatch.cli import main, parse_arguments
|
|
11
|
+
from scadbatch.params import (
|
|
12
|
+
construct_d_flags,
|
|
13
|
+
csv_to_json,
|
|
14
|
+
json_to_csv,
|
|
15
|
+
parse_selection,
|
|
16
|
+
read_csv,
|
|
17
|
+
read_json,
|
|
18
|
+
read_parameters,
|
|
19
|
+
)
|
|
20
|
+
from scadbatch.runner import (
|
|
21
|
+
BatchResult,
|
|
22
|
+
ExportResult,
|
|
23
|
+
batch_export,
|
|
24
|
+
ensure_output_folder,
|
|
25
|
+
export_stl,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"BatchResult",
|
|
30
|
+
"ExportResult",
|
|
31
|
+
"batch_export",
|
|
32
|
+
"construct_d_flags",
|
|
33
|
+
"csv_to_json",
|
|
34
|
+
"ensure_output_folder",
|
|
35
|
+
"export_stl",
|
|
36
|
+
"json_to_csv",
|
|
37
|
+
"main",
|
|
38
|
+
"parse_arguments",
|
|
39
|
+
"parse_selection",
|
|
40
|
+
"read_csv",
|
|
41
|
+
"read_json",
|
|
42
|
+
"read_parameters",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
if __name__ == "__main__":
|
|
46
|
+
sys.exit(main())
|