pycodecad 1.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.
- pycodecad/__init__.py +18 -0
- pycodecad/__main__.py +3 -0
- pycodecad/api.py +105 -0
- pycodecad/app.py +34 -0
- pycodecad/cad.py +261 -0
- pycodecad/camera.py +136 -0
- pycodecad/cli.py +241 -0
- pycodecad/context.py +178 -0
- pycodecad/editor.py +256 -0
- pycodecad/embed.py +16 -0
- pycodecad/examples/assembly.py +7 -0
- pycodecad/examples/assets/logo.svg +1 -0
- pycodecad/examples/assets/pyramid.stl +44 -0
- pycodecad/examples/embedded_app.py +77 -0
- pycodecad/examples/gear.py +6 -0
- pycodecad/examples/gearbox.py +30 -0
- pycodecad/examples/gears_turning.py +11 -0
- pycodecad/examples/import_files.py +13 -0
- pycodecad/examples/parts.py +46 -0
- pycodecad/examples/tray.py +28 -0
- pycodecad/files.py +262 -0
- pycodecad/icons/LICENSE +43 -0
- pycodecad/icons/__init__.py +31 -0
- pycodecad/icons/lucide.ttf +0 -0
- pycodecad/imgui_backend.py +269 -0
- pycodecad/params.py +177 -0
- pycodecad/renderer.py +327 -0
- pycodecad/runner.py +404 -0
- pycodecad/sidecar.py +86 -0
- pycodecad/textedit.py +290 -0
- pycodecad/ui.py +677 -0
- pycodecad/viewcube.py +120 -0
- pycodecad/viewer.py +70 -0
- pycodecad/window.py +129 -0
- pycodecad/workspace.py +545 -0
- pycodecad-1.0.0.dist-info/METADATA +117 -0
- pycodecad-1.0.0.dist-info/RECORD +40 -0
- pycodecad-1.0.0.dist-info/WHEEL +4 -0
- pycodecad-1.0.0.dist-info/entry_points.txt +2 -0
- pycodecad-1.0.0.dist-info/licenses/LICENSE +21 -0
pycodecad/runner.py
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"""Run a user script in a child process and get back what it shows.
|
|
2
|
+
|
|
3
|
+
Every run is a fresh child: on Linux a fork of the current process (build123d is already
|
|
4
|
+
imported, so a run starts instantly), elsewhere a new `python` process (macOS does not support
|
|
5
|
+
forking a process that has a window and threads). The child executes the
|
|
6
|
+
script from source with the script folder as working directory and first entry of sys.path,
|
|
7
|
+
tessellates what it shows, optionally exports, sends a `Result` back through a pipe and exits.
|
|
8
|
+
Stopping a run kills the child, and on Linux anything it started in its process group.
|
|
9
|
+
|
|
10
|
+
run = Run(code, "/path/part.py") # starts at once
|
|
11
|
+
result = run.wait() # or poll run.done() from a UI loop
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import contextlib
|
|
16
|
+
import ctypes
|
|
17
|
+
import datetime
|
|
18
|
+
import importlib.machinery
|
|
19
|
+
import importlib.util
|
|
20
|
+
import io
|
|
21
|
+
import linecache
|
|
22
|
+
import os
|
|
23
|
+
import pickle
|
|
24
|
+
import re
|
|
25
|
+
import signal
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
import traceback
|
|
31
|
+
import types
|
|
32
|
+
import warnings
|
|
33
|
+
from dataclasses import dataclass, field, replace
|
|
34
|
+
|
|
35
|
+
from .cad import Shown, hex_color
|
|
36
|
+
from .params import to_json
|
|
37
|
+
|
|
38
|
+
USE_FORK = sys.platform.startswith("linux")
|
|
39
|
+
PRCTL = ctypes.CDLL(None).prctl if sys.platform.startswith("linux") else None
|
|
40
|
+
STDOUT_LIMIT = 64 * 1024
|
|
41
|
+
NOTHING_SHOWN = "Nothing shown: call show(...)"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Result:
|
|
46
|
+
shown: list[Shown] = field(default_factory=list)
|
|
47
|
+
frames: list[list[Shown]] = field(default_factory=list) # the scenes frame() saved (an animation)
|
|
48
|
+
stdout: str = ""
|
|
49
|
+
error: str | None = None # traceback of the user's code, "Stopped", or an export problem
|
|
50
|
+
error_file: str | None = None # the user file (script or helper module) where it happened
|
|
51
|
+
error_line: int | None = None
|
|
52
|
+
duration: float = 0.0
|
|
53
|
+
exported: str | None = None # path written by an export run
|
|
54
|
+
warnings: list[str] = field(default_factory=list)
|
|
55
|
+
parameters: list = field(default_factory=list) # params.Exposed of each expose() call
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def preload() -> None:
|
|
59
|
+
"""Preload CAD for Linux forks; a fresh interpreter cannot reuse the parent's imports."""
|
|
60
|
+
if USE_FORK:
|
|
61
|
+
import build123d # noqa: F401
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def python_env() -> dict:
|
|
65
|
+
"""Environment for a new Python process that must import this same pycodecad."""
|
|
66
|
+
here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
67
|
+
return dict(os.environ, PYTHONPATH=os.pathsep.join(filter(None, (here, os.environ.get("PYTHONPATH")))))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Run:
|
|
71
|
+
"""One script execution in a child process.
|
|
72
|
+
|
|
73
|
+
export: also write the shown objects to this path (see files.export), inside the child.
|
|
74
|
+
values: parameter values for expose() ("function.param" or "param" -> value); strict: a value
|
|
75
|
+
no expose() took is an error (the command line), else it is ignored (the window).
|
|
76
|
+
sources: {file: text} of modules the script imports, used instead of the files on disk (the
|
|
77
|
+
window's unsaved edits to a helper module).
|
|
78
|
+
on_done: called from a background thread when the result is ready (e.g. to wake a UI loop).
|
|
79
|
+
The final result, also of a stopped or crashed run, is written to the script's last-run file.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(self, code: str, filename: str, export: str | None = None, profile: str = "generic",
|
|
83
|
+
on_done=None, values: dict | None = None, strict: bool = True, sources: dict | None = None):
|
|
84
|
+
job = dict(code=code, filename=filename, export=export, profile=profile, values=values or {},
|
|
85
|
+
strict=strict, sources=sources or {})
|
|
86
|
+
self.filename = filename
|
|
87
|
+
self.on_done = on_done
|
|
88
|
+
self.result: Result | None = None
|
|
89
|
+
self.killed = False
|
|
90
|
+
self.reaped = False
|
|
91
|
+
self.lock = threading.Lock()
|
|
92
|
+
self.finished = threading.Event()
|
|
93
|
+
if USE_FORK:
|
|
94
|
+
self.process = None
|
|
95
|
+
read_end, write_end = os.pipe()
|
|
96
|
+
parent = os.getpid()
|
|
97
|
+
with warnings.catch_warnings(): # the app has threads; the child never touches them
|
|
98
|
+
warnings.simplefilter("ignore", DeprecationWarning)
|
|
99
|
+
pid = os.fork()
|
|
100
|
+
if pid == 0:
|
|
101
|
+
status = 1
|
|
102
|
+
try:
|
|
103
|
+
os.close(read_end)
|
|
104
|
+
start_child(parent)
|
|
105
|
+
child_main(job, os.fdopen(write_end, "wb"))
|
|
106
|
+
status = 0
|
|
107
|
+
except BaseException:
|
|
108
|
+
traceback.print_exc()
|
|
109
|
+
finally:
|
|
110
|
+
os._exit(status)
|
|
111
|
+
with contextlib.suppress(OSError):
|
|
112
|
+
os.setpgid(pid, pid) # also done by the child: whichever runs first
|
|
113
|
+
os.close(write_end)
|
|
114
|
+
self.pid = pid
|
|
115
|
+
stream = os.fdopen(read_end, "rb")
|
|
116
|
+
else:
|
|
117
|
+
code = "from pycodecad.runner import spawned_main; spawned_main()"
|
|
118
|
+
self.process = subprocess.Popen([sys.executable, "-c", code], stdin=subprocess.PIPE,
|
|
119
|
+
stdout=subprocess.PIPE, env=python_env())
|
|
120
|
+
assert self.process.stdin is not None and self.process.stdout is not None # both are PIPE
|
|
121
|
+
self.process.stdin.write(pickle.dumps(job))
|
|
122
|
+
self.process.stdin.close()
|
|
123
|
+
stream = self.process.stdout
|
|
124
|
+
threading.Thread(target=self._finish, args=(stream,), daemon=True).start()
|
|
125
|
+
|
|
126
|
+
def _finish(self, stream) -> None:
|
|
127
|
+
try:
|
|
128
|
+
self.result = self._collect(stream)
|
|
129
|
+
if not self.filename.startswith("<"):
|
|
130
|
+
write_last_run(self.filename, self.result)
|
|
131
|
+
except Exception as exc: # whatever fails (e.g. a folder that is not writable), the run ends
|
|
132
|
+
if self.result is None:
|
|
133
|
+
self.result = Result(error=f"pycodecad could not collect the result: {exc!r}")
|
|
134
|
+
finally:
|
|
135
|
+
self.finished.set()
|
|
136
|
+
if self.on_done:
|
|
137
|
+
self.on_done()
|
|
138
|
+
|
|
139
|
+
def _collect(self, stream) -> Result:
|
|
140
|
+
received: list = []
|
|
141
|
+
reader = threading.Thread(target=lambda: received.append(receive(stream)), daemon=True)
|
|
142
|
+
reader.start() # reads while the child writes: a big result does not fit in the pipe
|
|
143
|
+
code = self._reap()
|
|
144
|
+
reader.join(1.0) # the result was sent before exiting, or nothing holds the pipe any more
|
|
145
|
+
if self.killed:
|
|
146
|
+
return Result(error="Stopped")
|
|
147
|
+
if not received or received[0] is None:
|
|
148
|
+
how = f"exit code {code}" if code >= 0 else f"killed by {signal.Signals(-code).name}"
|
|
149
|
+
return Result(error=f"The script process died unexpectedly ({how})")
|
|
150
|
+
return received[0]
|
|
151
|
+
|
|
152
|
+
def _reap(self) -> int:
|
|
153
|
+
"""Wait for the child to exit (without holding the lock: Stop must never block) and
|
|
154
|
+
return its exit code. Anything the script started in its process group is killed."""
|
|
155
|
+
if self.process is not None:
|
|
156
|
+
code = self.process.wait()
|
|
157
|
+
self.reaped = True
|
|
158
|
+
return code
|
|
159
|
+
os.waitid(os.P_PID, self.pid, os.WEXITED | os.WNOWAIT) # exited, not reaped: its pid stays reserved
|
|
160
|
+
with self.lock:
|
|
161
|
+
with contextlib.suppress(OSError):
|
|
162
|
+
os.killpg(self.pid, signal.SIGKILL)
|
|
163
|
+
code = os.waitstatus_to_exitcode(os.waitpid(self.pid, 0)[1])
|
|
164
|
+
self.reaped = True # from now on the pid may belong to another process
|
|
165
|
+
return code
|
|
166
|
+
|
|
167
|
+
def done(self) -> bool:
|
|
168
|
+
return self.finished.is_set()
|
|
169
|
+
|
|
170
|
+
def wait(self, timeout: float | None = None) -> Result:
|
|
171
|
+
if not self.finished.wait(timeout):
|
|
172
|
+
raise TimeoutError("The script is still running")
|
|
173
|
+
assert self.result is not None # _finish always sets it before `finished`
|
|
174
|
+
return self.result
|
|
175
|
+
|
|
176
|
+
def kill(self) -> None:
|
|
177
|
+
"""Stop the run: kill the child and everything it started."""
|
|
178
|
+
with self.lock:
|
|
179
|
+
if self.reaped:
|
|
180
|
+
return
|
|
181
|
+
self.killed = True
|
|
182
|
+
if self.process is not None:
|
|
183
|
+
self.process.kill()
|
|
184
|
+
else:
|
|
185
|
+
with contextlib.suppress(OSError):
|
|
186
|
+
os.killpg(self.pid, signal.SIGKILL)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def receive(stream) -> Result | None:
|
|
190
|
+
try:
|
|
191
|
+
return pickle.load(stream)
|
|
192
|
+
except Exception:
|
|
193
|
+
return None
|
|
194
|
+
finally:
|
|
195
|
+
stream.close()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# --- inside the child ----------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
def start_child(parent: int) -> None:
|
|
201
|
+
"""First steps of a forked child: its own process group (Stop kills the whole group), death
|
|
202
|
+
with the parent (Linux), no terminal input, and native writes to stdout go to stderr so they
|
|
203
|
+
never mix with what the parent prints (the script's print() output is still captured)."""
|
|
204
|
+
os.setpgid(0, 0)
|
|
205
|
+
if PRCTL is not None:
|
|
206
|
+
PRCTL(1, int(signal.SIGKILL)) # PR_SET_PDEATHSIG
|
|
207
|
+
if os.getppid() != parent: # the parent died before that
|
|
208
|
+
os._exit(1)
|
|
209
|
+
for number in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
|
|
210
|
+
signal.signal(number, signal.SIG_DFL)
|
|
211
|
+
null = os.open(os.devnull, os.O_RDONLY)
|
|
212
|
+
os.dup2(null, 0)
|
|
213
|
+
os.dup2(2, 1)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def spawned_main() -> None:
|
|
217
|
+
"""Child entry point without fork: the job arrives on stdin, the result leaves on stdout."""
|
|
218
|
+
output = os.fdopen(os.dup(1), "wb")
|
|
219
|
+
os.dup2(2, 1) # stray prints from C code must not corrupt the result stream
|
|
220
|
+
child_main(pickle.load(sys.stdin.buffer), output)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def child_main(job: dict, output) -> None:
|
|
224
|
+
result = execute(job["code"], job["filename"], job["values"], job["strict"], job["sources"])
|
|
225
|
+
if job["export"] and result.error is None and result.shown: # nothing shown: nothing written
|
|
226
|
+
from .files import export
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
result.exported = str(export(result.shown, job["export"], job["profile"]))
|
|
230
|
+
except Exception as exc:
|
|
231
|
+
result.error = f"Export failed: {exc}"
|
|
232
|
+
result.shown = [replace(obj, source=None) for obj in result.shown] # shapes stay in the child
|
|
233
|
+
pickle.dump(result, output, protocol=pickle.HIGHEST_PROTOCOL)
|
|
234
|
+
output.flush()
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def execute(code: str, filename: str, values: dict | None = None, strict: bool = True,
|
|
238
|
+
sources: dict | None = None) -> Result:
|
|
239
|
+
"""Run the script in this process (meant for a fresh child) and collect what it shows.
|
|
240
|
+
sources: see Run."""
|
|
241
|
+
from . import api
|
|
242
|
+
|
|
243
|
+
start = time.perf_counter()
|
|
244
|
+
sys.dont_write_bytecode = True # no .pyc of the user's modules: an edit is never masked by a cache
|
|
245
|
+
if not filename.startswith("<"):
|
|
246
|
+
filename = os.path.abspath(filename)
|
|
247
|
+
folder = os.path.dirname(filename)
|
|
248
|
+
os.chdir(folder)
|
|
249
|
+
sys.path.insert(0, folder)
|
|
250
|
+
sys.meta_path.insert(0, ProjectModules(folder, sources or {}))
|
|
251
|
+
elif sources:
|
|
252
|
+
sys.meta_path.insert(0, ProjectModules("", sources))
|
|
253
|
+
for name, text in {filename: code, **(sources or {})}.items(): # tracebacks quote this text, not the disk
|
|
254
|
+
linecache.cache[name] = (len(text), None, text.splitlines(True), name)
|
|
255
|
+
api.scene, api.frames = [], []
|
|
256
|
+
api.values, api.used, api.exposed, api.strict = dict(values or {}), set(), [], strict
|
|
257
|
+
main = types.ModuleType("__main__") # a real __main__ module, as with `python file.py`: typing and
|
|
258
|
+
main.__file__ = filename # dataclass tools look classes' names up in sys.modules
|
|
259
|
+
sys.modules["__main__"] = main # (this child process runs only this script)
|
|
260
|
+
namespace = main.__dict__
|
|
261
|
+
output = CappedOutput()
|
|
262
|
+
result = Result(warnings=absolute_path_warnings(code))
|
|
263
|
+
try:
|
|
264
|
+
with contextlib.redirect_stdout(output):
|
|
265
|
+
try:
|
|
266
|
+
exec(compile(code, filename, "exec"), namespace)
|
|
267
|
+
except SystemExit as exc:
|
|
268
|
+
if exc.code not in (None, 0): # sys.exit() and sys.exit(0) are a normal end
|
|
269
|
+
raise
|
|
270
|
+
result.shown = list(api.scene)
|
|
271
|
+
result.frames = list(api.frames)
|
|
272
|
+
unknown = sorted(set(api.values) - api.used)
|
|
273
|
+
if strict and unknown:
|
|
274
|
+
raise ValueError(f"No exposed parameter {', '.join(unknown)} (see expose() in the script)")
|
|
275
|
+
if not result.shown and not result.frames:
|
|
276
|
+
result.warnings.append(NOTHING_SHOWN)
|
|
277
|
+
except BaseException as exc: # user code may raise anything, SystemExit included
|
|
278
|
+
describe_error(result, exc, filename)
|
|
279
|
+
result.parameters = list(api.exposed)
|
|
280
|
+
result.stdout = output.text()
|
|
281
|
+
result.duration = time.perf_counter() - start
|
|
282
|
+
return result
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class ProjectModules:
|
|
286
|
+
"""An import hook for the modules of the script's folder (and its subfolders): read from their
|
|
287
|
+
source, never from an old .pyc; the ones in sources ({file: text}) from that text. Every other
|
|
288
|
+
module as usual."""
|
|
289
|
+
|
|
290
|
+
def __init__(self, folder: str, sources: dict) -> None:
|
|
291
|
+
self.folder, self.sources = folder, sources
|
|
292
|
+
|
|
293
|
+
def find_spec(self, name, path=None, target=None):
|
|
294
|
+
spec = importlib.machinery.PathFinder.find_spec(name, path)
|
|
295
|
+
if spec is None or spec.origin is None or not spec.origin.endswith(".py"):
|
|
296
|
+
return None
|
|
297
|
+
if spec.origin not in self.sources and not self.in_project(spec.origin):
|
|
298
|
+
return None
|
|
299
|
+
loader = ProjectSource(name, spec.origin, self.sources.get(spec.origin))
|
|
300
|
+
return importlib.util.spec_from_file_location(name, spec.origin, loader=loader,
|
|
301
|
+
submodule_search_locations=spec.submodule_search_locations)
|
|
302
|
+
|
|
303
|
+
def in_project(self, origin: str) -> bool:
|
|
304
|
+
"""In the script's folder or its subfolders, but not in an environment's installed packages."""
|
|
305
|
+
origin = os.path.normcase(origin)
|
|
306
|
+
folder = os.path.normcase(os.path.join(self.folder, ""))
|
|
307
|
+
return bool(self.folder) and origin.startswith(folder) and "site-packages" not in origin
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class ProjectSource(importlib.machinery.SourceFileLoader):
|
|
311
|
+
"""A module file read from its source, or from a text in memory (tracebacks show that text too)."""
|
|
312
|
+
|
|
313
|
+
def __init__(self, name: str, path: str, text: str | None) -> None:
|
|
314
|
+
super().__init__(name, path)
|
|
315
|
+
self.text = text
|
|
316
|
+
|
|
317
|
+
def get_data(self, path: str) -> bytes:
|
|
318
|
+
return self.text.encode("utf-8") if path == self.path and self.text is not None else super().get_data(path)
|
|
319
|
+
|
|
320
|
+
def path_stats(self, path: str):
|
|
321
|
+
raise OSError("no bytecode") # a .pyc can be stale within the same second and size: never used
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def describe_error(result: Result, exc: BaseException, filename: str) -> None:
|
|
325
|
+
"""The traceback of the user's own files (the script and modules in its folder), and the
|
|
326
|
+
innermost place in them where it failed. Library frames between them are left out."""
|
|
327
|
+
folder = os.path.dirname(filename) + os.sep
|
|
328
|
+
|
|
329
|
+
def user_file(path: str | None) -> bool:
|
|
330
|
+
return bool(path) and (path == filename or path.startswith(folder) and not filename.startswith("<")
|
|
331
|
+
and "site-packages" not in path)
|
|
332
|
+
|
|
333
|
+
frames = [frame for frame in traceback.extract_tb(exc.__traceback__) if user_file(frame.filename)]
|
|
334
|
+
if isinstance(exc, SyntaxError) and user_file(exc.filename):
|
|
335
|
+
result.error_file, result.error_line = exc.filename, exc.lineno
|
|
336
|
+
elif frames:
|
|
337
|
+
result.error_file, result.error_line = frames[-1].filename, frames[-1].lineno
|
|
338
|
+
head = "Traceback (most recent call last):\n" if frames else ""
|
|
339
|
+
text = head + "".join(traceback.format_list(frames)) + "".join(traceback.format_exception_only(exc))
|
|
340
|
+
result.error = text.rstrip()
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def absolute_path_warnings(code: str) -> list[str]:
|
|
344
|
+
"""Absolute paths into the home folder make a part work only on this PC."""
|
|
345
|
+
home = re.escape(os.path.expanduser("~"))
|
|
346
|
+
found = sorted(set(re.findall(rf"""['"]({home}[/\\][^'"]*)['"]""", code)))
|
|
347
|
+
return [f"Absolute path {path!r}: use a path relative to the script folder so the part works on any PC"
|
|
348
|
+
for path in found]
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def report(script: str, result: Result) -> dict:
|
|
352
|
+
"""The result as plain JSON data (what `pycodecad check` prints and last-run files hold)."""
|
|
353
|
+
from . import __version__
|
|
354
|
+
|
|
355
|
+
objects = []
|
|
356
|
+
for obj in result.shown[:1000]:
|
|
357
|
+
bbox = obj.bbox()
|
|
358
|
+
objects.append(dict(name=obj.name[:200], color=hex_color(obj.color),
|
|
359
|
+
volume=obj.volume, bbox=dict(min=bbox[0], max=bbox[1]) if bbox else None))
|
|
360
|
+
where = f"{result.error_file}:{result.error_line}" if result.error_file else None
|
|
361
|
+
summary = "".join((result.error or "").splitlines()[-1:])
|
|
362
|
+
summary = summary if len(summary) <= 2000 else summary[:2000] + " ... (truncated)"
|
|
363
|
+
return dict(ok=result.error is None, file=script, error=summary or None, where=where,
|
|
364
|
+
traceback=tail(result.error, 20000, "traceback"), stdout=tail(result.stdout, 4000, "output"),
|
|
365
|
+
warnings=[warning[:1000] for warning in result.warnings[:20]],
|
|
366
|
+
objects=objects, objects_total=len(result.shown), frames=len(result.frames),
|
|
367
|
+
parameters=to_json(result.parameters),
|
|
368
|
+
duration=round(result.duration, 3),
|
|
369
|
+
time=datetime.datetime.now().isoformat(timespec="seconds"), pycodecad=__version__)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def tail(text: str | None, limit: int, what: str) -> str | None:
|
|
373
|
+
"""The end of a long text, marked as cut."""
|
|
374
|
+
if text is None or len(text) <= limit:
|
|
375
|
+
return text
|
|
376
|
+
return f"... ({what} truncated)\n" + text[-limit:]
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def write_last_run(script: str, result: Result) -> None:
|
|
380
|
+
from .sidecar import write_json
|
|
381
|
+
|
|
382
|
+
write_json(script, "last-run", report(script, result))
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
class CappedOutput(io.StringIO):
|
|
386
|
+
"""stdout replacement that keeps only the last STDOUT_LIMIT characters."""
|
|
387
|
+
|
|
388
|
+
truncated = False
|
|
389
|
+
|
|
390
|
+
def write(self, text: str) -> int:
|
|
391
|
+
super().write(text)
|
|
392
|
+
if self.tell() > 2 * STDOUT_LIMIT:
|
|
393
|
+
tail = self.getvalue()[-STDOUT_LIMIT:]
|
|
394
|
+
self.seek(0)
|
|
395
|
+
self.truncate()
|
|
396
|
+
super().write(tail)
|
|
397
|
+
self.truncated = True
|
|
398
|
+
return len(text)
|
|
399
|
+
|
|
400
|
+
def text(self) -> str:
|
|
401
|
+
value = self.getvalue()
|
|
402
|
+
if self.truncated or len(value) > STDOUT_LIMIT:
|
|
403
|
+
return "... (output truncated)\n" + value[-STDOUT_LIMIT:]
|
|
404
|
+
return value
|
pycodecad/sidecar.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Script files: reading them, writing them safely (atomically), and what pycodecad keeps next to them.
|
|
2
|
+
|
|
3
|
+
<folder>/.pycodecad/<file>.last-run.json result of the last run (the JSON of `pycodecad check`)
|
|
4
|
+
<folder>/.pycodecad/<file>.camera.json camera of the window, for `pycodecad render --views window`
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import contextlib
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import tempfile
|
|
12
|
+
from dataclasses import asdict
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .camera import Camera, wrap_yaw
|
|
16
|
+
|
|
17
|
+
UMASK = os.umask(0o022)
|
|
18
|
+
os.umask(UMASK)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def read_script(path: str | Path) -> str:
|
|
22
|
+
"""The text of a script. Raises OSError, also for a file that is not UTF-8 text."""
|
|
23
|
+
try:
|
|
24
|
+
return Path(path).read_text(encoding="utf-8")
|
|
25
|
+
except UnicodeDecodeError:
|
|
26
|
+
raise OSError(f"{path}: not a UTF-8 text file") from None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def error_text(exc: OSError) -> str:
|
|
30
|
+
"""A short message for a failed file operation: "path: reason"."""
|
|
31
|
+
return f"{exc.filename}: {exc.strerror}" if exc.filename and exc.strerror else str(exc)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def sidecar(script: str | Path, kind: str) -> Path:
|
|
35
|
+
"""kind: "last-run" or "camera"."""
|
|
36
|
+
script = Path(script)
|
|
37
|
+
return script.parent / ".pycodecad" / f"{script.name}.{kind}.json"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def write_atomic(path: str | Path, text: str) -> int:
|
|
41
|
+
"""Replace the file in one step (readers see the old or the new text, never a part), keeping
|
|
42
|
+
its permissions. Returns the modification time (ns) of what was written."""
|
|
43
|
+
path = Path(path)
|
|
44
|
+
try:
|
|
45
|
+
mode = path.stat().st_mode & 0o7777
|
|
46
|
+
except FileNotFoundError:
|
|
47
|
+
mode = 0o666 & ~UMASK
|
|
48
|
+
try:
|
|
49
|
+
handle, temporary = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".pycodecad-tmp", dir=path.parent)
|
|
50
|
+
except OSError as exc:
|
|
51
|
+
exc.filename = str(path) # report the file asked for, not the temporary one
|
|
52
|
+
raise
|
|
53
|
+
try:
|
|
54
|
+
with os.fdopen(handle, "w", encoding="utf-8") as stream:
|
|
55
|
+
stream.write(text)
|
|
56
|
+
os.chmod(temporary, mode)
|
|
57
|
+
written = os.stat(temporary).st_mtime_ns
|
|
58
|
+
os.replace(temporary, path)
|
|
59
|
+
except BaseException:
|
|
60
|
+
with contextlib.suppress(OSError):
|
|
61
|
+
os.unlink(temporary)
|
|
62
|
+
raise
|
|
63
|
+
return written
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def write_json(script: str | Path, kind: str, data: dict) -> None:
|
|
67
|
+
target = sidecar(script, kind)
|
|
68
|
+
target.parent.mkdir(exist_ok=True)
|
|
69
|
+
write_atomic(target, json.dumps(data, indent=2))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def save_camera(script: str | Path, camera: Camera, size: tuple[int, int]) -> None:
|
|
73
|
+
write_json(script, "camera", asdict(camera) | dict(width=size[0], height=size[1]))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def load_camera(script: str | Path) -> tuple[Camera, tuple[int, int]]:
|
|
77
|
+
"""The window's camera and view size. Raises OSError or ValueError when there is none."""
|
|
78
|
+
text = sidecar(script, "camera").read_text(encoding="utf-8")
|
|
79
|
+
try:
|
|
80
|
+
saved = json.loads(text)
|
|
81
|
+
x, y, z = saved["target"]
|
|
82
|
+
camera = Camera(target=(float(x), float(y), float(z)), distance=float(saved["distance"]),
|
|
83
|
+
yaw=wrap_yaw(saved["yaw"]), pitch=float(saved["pitch"]), fov=float(saved["fov"]))
|
|
84
|
+
return camera, (int(saved["width"]), int(saved["height"]))
|
|
85
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
86
|
+
raise ValueError(f"invalid camera file: {exc}") from None
|