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 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())