cad 0.10.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.
- cad/__init__.py +100 -0
- cad/_internal/__init__.py +6 -0
- cad/_internal/atomic_replace.py +137 -0
- cad/_internal/counts.py +36 -0
- cad/_internal/font_scan.py +146 -0
- cad/_internal/lazy_kernel.py +99 -0
- cad/_internal/program.py +71 -0
- cad/_internal/validation_door.py +89 -0
- cad/_internal/version.py +44 -0
- cad/ap242.py +47 -0
- cad/check_fonts.py +105 -0
- cad/eng_drawing.py +1547 -0
- cad/engine/__init__.py +83 -0
- cad/engine/addresses.py +36 -0
- cad/engine/animations.py +628 -0
- cad/engine/api.py +1254 -0
- cad/engine/atomic_replace.py +157 -0
- cad/engine/build.py +1257 -0
- cad/engine/client.py +407 -0
- cad/engine/colors.py +113 -0
- cad/engine/contract.py +191 -0
- cad/engine/delta.py +456 -0
- cad/engine/document.py +2108 -0
- cad/engine/document_migrations.py +298 -0
- cad/engine/door.py +430 -0
- cad/engine/executables.py +35 -0
- cad/engine/export/__init__.py +425 -0
- cad/engine/export/animation.py +556 -0
- cad/engine/export/dxf.py +611 -0
- cad/engine/export/glb.py +336 -0
- cad/engine/export/keys.py +208 -0
- cad/engine/export/mesh.py +109 -0
- cad/engine/export/mesh_file.py +141 -0
- cad/engine/export/motion.py +239 -0
- cad/engine/export/step.py +373 -0
- cad/engine/export/stl.py +34 -0
- cad/engine/export/threemf.py +128 -0
- cad/engine/export/tree.py +127 -0
- cad/engine/export/viewer.py +48 -0
- cad/engine/formats/__init__.py +199 -0
- cad/engine/formats/buffer.py +171 -0
- cad/engine/formats/dxf.py +766 -0
- cad/engine/formats/glb.py +446 -0
- cad/engine/formats/gltf.py +352 -0
- cad/engine/formats/meshes.py +129 -0
- cad/engine/formats/robot.py +970 -0
- cad/engine/formats/stl.py +47 -0
- cad/engine/formats/threemf.py +380 -0
- cad/engine/frames.py +55 -0
- cad/engine/identity.py +336 -0
- cad/engine/ids.py +139 -0
- cad/engine/kernel/__init__.py +2 -0
- cad/engine/kernel/bounds.py +93 -0
- cad/engine/kernel/checks.py +44 -0
- cad/engine/kernel/determinism.py +195 -0
- cad/engine/kernel/history.py +1027 -0
- cad/engine/kernel/imported.py +64 -0
- cad/engine/kernel/memo.py +4873 -0
- cad/engine/kernel/trace.py +2457 -0
- cad/engine/mechanism.py +2379 -0
- cad/engine/mesh.py +805 -0
- cad/engine/mesh_format.py +85 -0
- cad/engine/mesh_uri.py +51 -0
- cad/engine/model.py +1007 -0
- cad/engine/places.py +641 -0
- cad/engine/previous.py +375 -0
- cad/engine/profiling.py +95 -0
- cad/engine/progress.py +473 -0
- cad/engine/reader.py +682 -0
- cad/engine/records.py +166 -0
- cad/engine/refs.py +610 -0
- cad/engine/render/__init__.py +19 -0
- cad/engine/render/clip.py +213 -0
- cad/engine/render/context.py +383 -0
- cad/engine/render/drawing.py +380 -0
- cad/engine/render/explode.py +444 -0
- cad/engine/render/finish.py +270 -0
- cad/engine/render/grid.py +454 -0
- cad/engine/render/look.py +236 -0
- cad/engine/render/meshes.py +64 -0
- cad/engine/render/occt.py +1404 -0
- cad/engine/render/overlay.py +228 -0
- cad/engine/render/service.py +580 -0
- cad/engine/render/shapes.py +252 -0
- cad/engine/render/studio.hdr +0 -0
- cad/engine/render/text.py +533 -0
- cad/engine/said.py +258 -0
- cad/engine/serve.py +2137 -0
- cad/engine/snapshot.py +1051 -0
- cad/engine/sources.py +1207 -0
- cad/engine/step_style.py +275 -0
- cad/engine/windows_spawn.py +88 -0
- cad/engine/worker.py +668 -0
- cad/findings.py +91 -0
- cad/flatten.py +255 -0
- cad/geometry.py +450 -0
- cad/gltf.py +37 -0
- cad/py.typed +1 -0
- cad/results.py +72 -0
- cad/sdf.py +98 -0
- cad/sdf_external.py +118 -0
- cad/sdf_source.py +312 -0
- cad/sdf_validation.py +1122 -0
- cad/srdf.py +77 -0
- cad/srdf_source.py +424 -0
- cad/srdf_validation.py +666 -0
- cad/urdf.py +77 -0
- cad/urdf_source.py +1427 -0
- cad/xml_common.py +74 -0
- cad-0.10.0.dist-info/METADATA +492 -0
- cad-0.10.0.dist-info/RECORD +116 -0
- cad-0.10.0.dist-info/WHEEL +5 -0
- cad-0.10.0.dist-info/licenses/LICENSE +21 -0
- cad-0.10.0.dist-info/top_level.txt +2 -0
- cad-lazy-kernel.pth +1 -0
- cadgen/__init__.py +50 -0
cad/__init__.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""cad: CAD models as Python, built by its engine (``cad.engine``) into documents (``.xbf``)
|
|
2
|
+
and the files they declare.
|
|
3
|
+
|
|
4
|
+
The root exports exactly the engine's surface -- ``model``, the decorators (``xbf``, ``step``,
|
|
5
|
+
``glb``, ``stl``, ``threemf``, ``dxf``), ``memo``, face painting (``paint``), the readers (``read_step``, ``read_xbf``, ``read_mesh``) -- its operations on
|
|
6
|
+
documents as functions (``export``, ``snapshot``, ``inspect``) and ``__version__``; process
|
|
7
|
+
control (``cad serve``, ``status``, ``stop``, ``server``) is the ``cad`` command's alone. Two
|
|
8
|
+
namespaces hold the standards' names: ``cad.ap242`` the mechanism's (``mechanism_state``,
|
|
9
|
+
``configuration_sequence``, ``gear_pair``, ``rack_and_pinion_pair``, ``screw_pair``, ``posed``) and
|
|
10
|
+
``cad.gltf`` a GLB's own ``animation``. Every name loads on first use: ``import cad``
|
|
11
|
+
imports no CAD kernel, and ``import build123d`` binds a module the kernel loads into on its
|
|
12
|
+
first attribute (``_internal/lazy_kernel.py``).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from functools import cache as _cache
|
|
16
|
+
from typing import TYPE_CHECKING as _TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
# Before anything imports build123d, which every entry point of the library eventually does:
|
|
19
|
+
# build123d parses EVERY font in the system font folders at import time, with no
|
|
20
|
+
# per-file guard, so one malformed file aborts the import and every cad command
|
|
21
|
+
# with it (issue #322, upstream in build123d's register_folder), as does a font with
|
|
22
|
+
# no family name, whose empty name OCCT refuses (#636). This hides the fonts build123d
|
|
23
|
+
# cannot register from that one listing.
|
|
24
|
+
#
|
|
25
|
+
# It belongs here rather than in a launcher because the `cad` command, the engine's
|
|
26
|
+
# warm workers and `python -m cad.X` children all reach the library by different routes, and a
|
|
27
|
+
# fix that covered only one of them would leave the builds it spawns still broken.
|
|
28
|
+
#
|
|
29
|
+
# Cost where nothing is wrong: one str.endswith per glob call. CAD_FONT_GUARD=0
|
|
30
|
+
# opts out.
|
|
31
|
+
from cad._internal.font_scan import install_font_guard as _install_font_guard
|
|
32
|
+
from cad._internal.lazy_kernel import install as _install_lazy_kernel
|
|
33
|
+
|
|
34
|
+
# ``import build123d as bd`` binds a module the kernel loads into on its first attribute
|
|
35
|
+
# (``_internal/lazy_kernel.py``). The wheel's cad-lazy-kernel.pth installs it at interpreter
|
|
36
|
+
# start; this covers an editable install, where that file is not placed. Idempotent.
|
|
37
|
+
_install_lazy_kernel()
|
|
38
|
+
del _install_lazy_kernel
|
|
39
|
+
|
|
40
|
+
_install_font_guard()
|
|
41
|
+
del _install_font_guard
|
|
42
|
+
|
|
43
|
+
# The library installs nothing of the server's: what the engine's verbs did is told to the functions in
|
|
44
|
+
# cad.engine.api.observers, which the server's commands fill (``cad_server._internal.engine_telemetry``),
|
|
45
|
+
# and a process the server did not start -- a model's own, in the CAD kernel environment -- counts nothing.
|
|
46
|
+
|
|
47
|
+
# The engine's surface (``cad.engine.__all__``) and its operations on documents as functions
|
|
48
|
+
# (``cad.engine.api``).
|
|
49
|
+
_ENGINE = ("model", "xbf", "memo", "step", "glb", "stl", "threemf", "dxf", "paint",
|
|
50
|
+
"read_step", "read_xbf", "read_mesh")
|
|
51
|
+
_VERBS = ("export", "snapshot", "inspect")
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"__version__",
|
|
55
|
+
*_ENGINE,
|
|
56
|
+
*_VERBS,
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def __getattr__(name: str):
|
|
61
|
+
if name in _ENGINE:
|
|
62
|
+
import cad.engine as _engine
|
|
63
|
+
|
|
64
|
+
return getattr(_engine, name)
|
|
65
|
+
if name in _VERBS:
|
|
66
|
+
from cad.engine import api
|
|
67
|
+
|
|
68
|
+
return getattr(api, name)
|
|
69
|
+
if name == "__version__":
|
|
70
|
+
return _resolve_version()
|
|
71
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def __dir__() -> list[str]:
|
|
75
|
+
"""Its public names (``__all__``) and the submodules imported so far: what ``dir()`` and
|
|
76
|
+
completion offer, without loading any of them, nor the kernel."""
|
|
77
|
+
return sorted({*__all__, *(name for name, value in globals().items() if name.startswith("__")
|
|
78
|
+
or getattr(value, "__name__", "").startswith(__name__ + "."))})
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if _TYPE_CHECKING:
|
|
82
|
+
# Mirrors the lazy __getattr__ above so type checkers and editors see the root exports
|
|
83
|
+
# without importing OCP. Every name here must have a branch there, and comes from the
|
|
84
|
+
# module that defines it.
|
|
85
|
+
from cad.engine.api import export, inspect, snapshot
|
|
86
|
+
from cad.engine.model import (dxf, glb, memo, model, paint, read_step, read_xbf, step, stl, threemf, xbf)
|
|
87
|
+
from cad.engine.reader import read_mesh
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@_cache
|
|
91
|
+
def _resolve_version() -> str:
|
|
92
|
+
"""The version declared beside this very package (``_internal.version.declared_version``):
|
|
93
|
+
the source tree's pyproject for a checkout, the dist-info beside it for an install.
|
|
94
|
+
Never the first ``cad`` metadata on ``sys.path``, which can be another install's.
|
|
95
|
+
Resolved once per process: the code that answers cannot change under a running one."""
|
|
96
|
+
import pathlib
|
|
97
|
+
|
|
98
|
+
from cad._internal.version import declared_version
|
|
99
|
+
|
|
100
|
+
return declared_version(pathlib.Path(__file__).resolve().parent)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""The one atomic rename every file cadgen writes outside the engine goes through.
|
|
2
|
+
|
|
3
|
+
The settings, the model library, the channel record, the telemetry spool and batches, a Quick
|
|
4
|
+
Edit packet: each is written to a temp file and renamed into place, so a reader never sees a
|
|
5
|
+
half-written payload. (The engine keeps its own copy of this rule, ``cad.engine.atomic_replace``:
|
|
6
|
+
it imports nothing of cadgen.) On a Windows SMB share that rename is not reliably atomic-and-instant:
|
|
7
|
+
the redirector can still hold the handle Python just closed, and the rename loses with
|
|
8
|
+
``WinError 32`` (ERROR_SHARING_VIOLATION, "file is being used by another process") -- issue #241,
|
|
9
|
+
where 8 workers failed every time on a NAS and one worker succeeded.
|
|
10
|
+
|
|
11
|
+
Deliberately narrow:
|
|
12
|
+
|
|
13
|
+
* only ``WinError 32``, and ``WinError 5`` onto an existing FILE, retry (see below for the
|
|
14
|
+
second). A denial with no file in the way -- a read-only folder, a directory at the target --
|
|
15
|
+
or a missing directory is a real error and must surface at once, not 350 ms later.
|
|
16
|
+
* the attribute does not exist off Windows, so this is exactly ``os.replace`` on POSIX.
|
|
17
|
+
* five attempts over 750 ms, then the original error propagates.
|
|
18
|
+
|
|
19
|
+
The window is sized for the TAIL, not the median (issue #274): measured on a Synology SMB share,
|
|
20
|
+
the median blocked rename waited 31 ms, p99 265 ms, max 797 ms. The tail is long and thin, so any
|
|
21
|
+
finite window eventually loses to it, and a bigger constant is not the answer: when the ladder is
|
|
22
|
+
exhausted, ``replace_atomic`` copies the temp file sideways under a name the server has never seen
|
|
23
|
+
and runs the ladder once more on the copy. The violation belongs to the handle the server still
|
|
24
|
+
holds on the ORIGINAL temp file; the copy carries no handle of its own.
|
|
25
|
+
|
|
26
|
+
The DESTINATION has its own failure, and it answers with a different code. NTFS refuses to
|
|
27
|
+
rename over a file that any process holds open -- ``MoveFileEx`` without POSIX semantics, which is
|
|
28
|
+
what ``os.replace`` is -- and says ``WinError 5`` (ERROR_ACCESS_DENIED), not 32: two processes
|
|
29
|
+
writing one settings file each read what they just wrote. The hold is a read that ends in
|
|
30
|
+
milliseconds, so the same ladder waits it out; the retry is keyed on a FILE at the target, because
|
|
31
|
+
that is the only case where a ``5`` can be somebody else's handle.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import contextlib
|
|
37
|
+
import os
|
|
38
|
+
import shutil
|
|
39
|
+
import time
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
# ERROR_SHARING_VIOLATION: the file is open in another process -- or, on SMB, was open a moment
|
|
43
|
+
# ago and the server has not caught up.
|
|
44
|
+
WINDOWS_SHARING_VIOLATION = 32
|
|
45
|
+
# ERROR_ACCESS_DENIED: what a rename answers when another process holds the DESTINATION open
|
|
46
|
+
# (or is renaming onto it). Retried only when a file is there -- see the module docstring.
|
|
47
|
+
WINDOWS_ACCESS_DENIED = 5
|
|
48
|
+
RETRY_DELAYS_SECONDS = (0.05, 0.1, 0.2, 0.4)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def temp_suffix() -> str:
|
|
52
|
+
"""A collision-free suffix for a sibling temp file, ending in ``.tmp``.
|
|
53
|
+
|
|
54
|
+
``os.urandom`` rather than a clock: two writes inside one clock tick would otherwise collide,
|
|
55
|
+
and the rescue below depends on the new name being genuinely new rather than on the timer's
|
|
56
|
+
resolution. The ``.tmp`` ending matters too -- ``.gitignore`` carries ``*.tmp``, so a temp
|
|
57
|
+
file leaked in the deep tail stays invisible to ``git add -A``.
|
|
58
|
+
"""
|
|
59
|
+
return f".{os.getpid()}.{os.urandom(4).hex()}.tmp"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _held(error: OSError, target_path: Path | str) -> bool:
|
|
63
|
+
"""Whether a refused rename is a handle someone will let go of.
|
|
64
|
+
|
|
65
|
+
A sharing violation always is. A denial is only when a FILE is at the target: that is the
|
|
66
|
+
code NTFS gives a rename over a file another process has open. With nothing there, or a
|
|
67
|
+
directory in the way, a denial is a real one.
|
|
68
|
+
"""
|
|
69
|
+
code = getattr(error, "winerror", None)
|
|
70
|
+
if code == WINDOWS_SHARING_VIOLATION:
|
|
71
|
+
return True
|
|
72
|
+
return code == WINDOWS_ACCESS_DENIED and os.path.isfile(target_path)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _run_ladder(temp_path: Path | str, target_path: Path | str) -> OSError | None:
|
|
76
|
+
"""Run the bounded retry, returning the held-handle refusal it could not beat.
|
|
77
|
+
|
|
78
|
+
Any other error propagates at once: a real denial or a missing directory must surface
|
|
79
|
+
immediately, not 750 ms later.
|
|
80
|
+
"""
|
|
81
|
+
for delay in (*RETRY_DELAYS_SECONDS, None):
|
|
82
|
+
try:
|
|
83
|
+
os.replace(temp_path, target_path)
|
|
84
|
+
return None
|
|
85
|
+
except OSError as error:
|
|
86
|
+
if not _held(error, target_path):
|
|
87
|
+
raise
|
|
88
|
+
if delay is None:
|
|
89
|
+
return error
|
|
90
|
+
time.sleep(delay)
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def replace_atomic(temp_path: Path | str, target_path: Path | str) -> None:
|
|
95
|
+
"""``os.replace`` with a bounded retry, then one retry from a copy the server has not seen."""
|
|
96
|
+
blocked = _run_ladder(temp_path, target_path)
|
|
97
|
+
if blocked is None:
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
source = Path(temp_path)
|
|
101
|
+
fresh = source.with_name(f"{source.name}{temp_suffix()}")
|
|
102
|
+
try:
|
|
103
|
+
shutil.copyfile(source, fresh)
|
|
104
|
+
except OSError:
|
|
105
|
+
# The source is pinned for reading too, so there is nothing left to try. Report the
|
|
106
|
+
# rename failure, which is the one the caller was waiting on.
|
|
107
|
+
raise blocked from None
|
|
108
|
+
|
|
109
|
+
# Best effort: the handle that blocked the rename usually blocks this as well.
|
|
110
|
+
with contextlib.suppress(OSError):
|
|
111
|
+
source.unlink(missing_ok=True)
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
if _run_ladder(fresh, target_path) is not None:
|
|
115
|
+
raise blocked
|
|
116
|
+
finally:
|
|
117
|
+
with contextlib.suppress(OSError):
|
|
118
|
+
fresh.unlink(missing_ok=True)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def write_bytes_atomic(target_path: Path, payload: bytes) -> None:
|
|
122
|
+
"""Write ``payload`` to ``target_path`` through a temp file in the same directory.
|
|
123
|
+
|
|
124
|
+
Same directory on purpose: a rename across filesystems is not atomic, and a temp dir on
|
|
125
|
+
another volume would silently turn this into a copy.
|
|
126
|
+
"""
|
|
127
|
+
resolved = Path(target_path)
|
|
128
|
+
resolved.parent.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
temp_path = resolved.with_name(f"{resolved.name}{temp_suffix()}")
|
|
130
|
+
try:
|
|
131
|
+
temp_path.write_bytes(payload)
|
|
132
|
+
replace_atomic(temp_path, resolved)
|
|
133
|
+
finally:
|
|
134
|
+
# Suppressed: on Windows the handle that blocks a rename blocks the delete too, and
|
|
135
|
+
# letting that escape here would mask the rename failure the caller needs to see.
|
|
136
|
+
with contextlib.suppress(OSError):
|
|
137
|
+
temp_path.unlink(missing_ok=True)
|
cad/_internal/counts.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""What the library counts for telemetry, told to the server's telemetry where the server installed it.
|
|
2
|
+
|
|
3
|
+
The library sends nothing and keeps nothing: a drawing made, and a mistake of cadgen's own code met making
|
|
4
|
+
one, are handed to ``TELEMETRY`` -- the server's ``cad_server.analytics``, which its commands install here
|
|
5
|
+
(``cad_server._internal.engine_telemetry.install``) -- and dropped where none is installed: a model's own
|
|
6
|
+
process, in the CAD kernel environment, has only the library. Neither ever fails, nor changes, what it
|
|
7
|
+
counts.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
# The server's telemetry (``cad_server.analytics``: ``report`` and ``hand_over``), or None: nothing is counted.
|
|
15
|
+
TELEMETRY: Any = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def report(error: BaseException, where: str, **options: Any) -> None:
|
|
19
|
+
"""A mistake of cadgen's own code, told to the server's telemetry (``analytics.report``). Never raises."""
|
|
20
|
+
if TELEMETRY is None:
|
|
21
|
+
return
|
|
22
|
+
try:
|
|
23
|
+
TELEMETRY.report(error, where, **options)
|
|
24
|
+
except Exception: # noqa: BLE001 - a crash report never adds a failure to one
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def hand_over(counts: dict[str, Any]) -> None:
|
|
29
|
+
"""What a model's process counted (``{"features": ["drawing"]}``), kept with a command's counts by the
|
|
30
|
+
server's telemetry (``analytics.hand_over``). Never raises."""
|
|
31
|
+
if TELEMETRY is None:
|
|
32
|
+
return
|
|
33
|
+
try:
|
|
34
|
+
TELEMETRY.hand_over(counts)
|
|
35
|
+
except Exception: # noqa: BLE001 - a count never fails what it counts
|
|
36
|
+
pass
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Keep the fonts build123d cannot register out of the scan ``import build123d`` runs.
|
|
2
|
+
|
|
3
|
+
build123d registers EVERY font in the system font folders at import time:
|
|
4
|
+
``build123d/text.py`` ends with ``available_fonts = FontManager().available_fonts``,
|
|
5
|
+
whose ``__init__`` calls ``register_system_fonts() -> register_folder() ->
|
|
6
|
+
register_font()``, and ``register_font`` hands each file straight to
|
|
7
|
+
``fontTools.ttLib.TTFont`` with no per-file guard. One malformed file, or one font with
|
|
8
|
+
no family name (whose empty name OCCT refuses, #636), therefore takes down
|
|
9
|
+
``import build123d`` itself, and with it every cad command -- on Windows, where that
|
|
10
|
+
scan is the one platform build123d runs it on (OCCT does not pick up user fonts there).
|
|
11
|
+
|
|
12
|
+
``install_font_guard`` hides such a file from the one folder listing build123d makes;
|
|
13
|
+
``python -m cad.check_fonts`` names it. The permanent fix belongs upstream, in
|
|
14
|
+
``register_folder``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import platform
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def system_font_dirs() -> list[str]:
|
|
24
|
+
"""The folders build123d scans, mirrored from ``register_system_fonts``.
|
|
25
|
+
|
|
26
|
+
Kept in the same order so the report matches the order the scan would hit them.
|
|
27
|
+
``os.getlogin()`` is what build123d uses for the per-user Windows folder; it
|
|
28
|
+
raises with no controlling terminal, so the fallback keeps the diagnostic alive
|
|
29
|
+
where the thing it describes still runs.
|
|
30
|
+
"""
|
|
31
|
+
if platform.system() == "Windows":
|
|
32
|
+
try:
|
|
33
|
+
user = os.getlogin()
|
|
34
|
+
except OSError:
|
|
35
|
+
user = os.environ.get("USERNAME", "")
|
|
36
|
+
dirs = ["C:/Windows/Fonts"]
|
|
37
|
+
if user:
|
|
38
|
+
dirs.append(f"C:/Users/{user}/AppData/Local/Microsoft/Windows/Fonts")
|
|
39
|
+
return dirs
|
|
40
|
+
if platform.system() == "Darwin":
|
|
41
|
+
return ["/System/Library/Fonts", "/Library/Fonts"]
|
|
42
|
+
return ["/system/fonts", "/usr/share/fonts", "/usr/local/share/fonts"]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# build123d's register_folder globs `"*" + ext` with NO dot, so the patterns it passes
|
|
46
|
+
# end in a bare "ttf"/"otf"/"ttc". Matching on ".ttf" here silently disables the guard,
|
|
47
|
+
# which is exactly how the first version of it did nothing.
|
|
48
|
+
_GLOB_PATTERN_SUFFIXES = ("ttf", "otf", "ttc")
|
|
49
|
+
|
|
50
|
+
_ENV_DISABLE = "CAD_FONT_GUARD"
|
|
51
|
+
|
|
52
|
+
skipped_fonts: list[str] = []
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def family_name(font) -> str:
|
|
56
|
+
"""The family name build123d registers a font under, or "" when it has none.
|
|
57
|
+
|
|
58
|
+
Follows ``_get_font_faces`` in build123d/text.py: the first decodable, non-empty
|
|
59
|
+
nameID 16 (preferred family), else the first decodable, non-empty nameID 1. The empty
|
|
60
|
+
check runs on the raw value and the strip comes last, as there, so a blank preferred
|
|
61
|
+
name is "" even when nameID 1 has one. OCCT refuses an empty name, which is #636.
|
|
62
|
+
"""
|
|
63
|
+
family = ""
|
|
64
|
+
preferred = ""
|
|
65
|
+
for record in font["name"].names:
|
|
66
|
+
try:
|
|
67
|
+
value = record.toUnicode()
|
|
68
|
+
except UnicodeDecodeError:
|
|
69
|
+
continue
|
|
70
|
+
if record.nameID == 1 and family == "":
|
|
71
|
+
family = value
|
|
72
|
+
elif record.nameID == 16 and preferred == "":
|
|
73
|
+
preferred = value
|
|
74
|
+
return (preferred if preferred != "" else family).strip()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _font_registers(path: str) -> bool:
|
|
78
|
+
"""Whether build123d could register this file: it parses, and every face has a name.
|
|
79
|
+
|
|
80
|
+
The name table is read, not just the file opened: ``TTFont`` is lazy, so a broken
|
|
81
|
+
table directory behind a valid sfnt header constructs fine and fails later, inside
|
|
82
|
+
``_get_font_faces``, whose first act is ``ft_font["name"].names``. A face with no
|
|
83
|
+
family name fails there too, when OCCT is handed its empty name.
|
|
84
|
+
"""
|
|
85
|
+
try:
|
|
86
|
+
from fontTools.ttLib import TTFont, ttCollection
|
|
87
|
+
except ImportError:
|
|
88
|
+
# No fontTools means no font scan to protect; let everything through.
|
|
89
|
+
return True
|
|
90
|
+
try:
|
|
91
|
+
if os.path.splitext(path)[1].strip(".").lower() == "ttc":
|
|
92
|
+
fonts = list(ttCollection.TTCollection(path))
|
|
93
|
+
else:
|
|
94
|
+
fonts = [TTFont(path)]
|
|
95
|
+
for font in fonts:
|
|
96
|
+
if not family_name(font):
|
|
97
|
+
return False
|
|
98
|
+
except Exception: # noqa: BLE001 - any parse failure is one build123d would hit
|
|
99
|
+
return False
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def install_font_guard() -> bool:
|
|
104
|
+
"""Hide fonts build123d cannot register from the scan it runs at import time.
|
|
105
|
+
|
|
106
|
+
``register_folder`` lists a folder with ``glob.glob`` and hands every result
|
|
107
|
+
straight to ``fontTools``. ``glob`` is resolved on the module object at call time,
|
|
108
|
+
so filtering that one listing keeps a file build123d cannot register from ever being
|
|
109
|
+
opened -- the file is skipped, the rest of the folder still registers, and neither a
|
|
110
|
+
malformed font nor an empty name reaches OCCT's font table.
|
|
111
|
+
|
|
112
|
+
This is the seam because it is the only point between "list the files" and "open
|
|
113
|
+
one". Replacing ``TTFont`` instead cannot work: a stand-in has to survive
|
|
114
|
+
``_get_font_faces``, and one with an empty name table registers an empty-named
|
|
115
|
+
``Font_SystemFont`` into OCCT's global manager.
|
|
116
|
+
|
|
117
|
+
Returns True when the guard is installed. Idempotent, and a no-op when
|
|
118
|
+
``CAD_FONT_GUARD=0``.
|
|
119
|
+
"""
|
|
120
|
+
if os.environ.get(_ENV_DISABLE) == "0":
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
import glob
|
|
124
|
+
|
|
125
|
+
if getattr(glob.glob, "_cadgen_font_guard", False):
|
|
126
|
+
return True
|
|
127
|
+
|
|
128
|
+
real_glob = glob.glob
|
|
129
|
+
|
|
130
|
+
def guarded_glob(pathname, *args, **kwargs):
|
|
131
|
+
results = real_glob(pathname, *args, **kwargs)
|
|
132
|
+
# One string check for every other glob in the process. Only a font listing
|
|
133
|
+
# pays for a parse, and only build123d asks for one.
|
|
134
|
+
if not str(pathname).lower().endswith(_GLOB_PATTERN_SUFFIXES):
|
|
135
|
+
return results
|
|
136
|
+
keep = []
|
|
137
|
+
for path in results:
|
|
138
|
+
if _font_registers(path):
|
|
139
|
+
keep.append(path)
|
|
140
|
+
elif path not in skipped_fonts:
|
|
141
|
+
skipped_fonts.append(path)
|
|
142
|
+
return keep
|
|
143
|
+
|
|
144
|
+
guarded_glob._cadgen_font_guard = True # type: ignore[attr-defined]
|
|
145
|
+
glob.glob = guarded_glob # type: ignore[assignment]
|
|
146
|
+
return True
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""``import build123d`` binds the module; the kernel loads on its first attribute.
|
|
2
|
+
|
|
3
|
+
A model script's module body should cost nothing until the engine has decided whether to build
|
|
4
|
+
-- in the process that only hands the build to the warm worker, it never loads the kernel at
|
|
5
|
+
all -- yet the idiom every build123d author writes is ``import build123d as bd`` at the top.
|
|
6
|
+
This ``sys.meta_path`` finder makes that idiom free: for the top-level names ``build123d`` and
|
|
7
|
+
``OCP`` alone it returns the ordinary spec (``PathFinder``'s) with its loader wrapped in
|
|
8
|
+
``importlib.util.LazyLoader``, so the import binds a module whose body runs on the first
|
|
9
|
+
attribute anything reads from it (``bd.Box``), in whichever process that is.
|
|
10
|
+
|
|
11
|
+
``from build123d import Box`` reads an attribute to bind ``Box``, so it loads the kernel there and
|
|
12
|
+
then, as without the finder; ``import OCP.Standard`` loads ``OCP`` to find its submodule, and the
|
|
13
|
+
submodule as ever. Only the top-level packages are lazy: a submodule is imported when asked.
|
|
14
|
+
|
|
15
|
+
Every later ``import build123d`` -- a project's helper module importing it as the script did --
|
|
16
|
+
binds the same module and loads nothing. The import system reads a module's ``__spec__`` on each
|
|
17
|
+
import that finds it in ``sys.modules`` (to wait on one still initializing), and the standard
|
|
18
|
+
library's lazy module loads on any attribute, so the module is a ``KernelModule``, which answers
|
|
19
|
+
``__spec__`` as it stands and hands every other first read to the standard library's load.
|
|
20
|
+
|
|
21
|
+
Installed twice, harmlessly (``install`` is idempotent): by ``cad-lazy-kernel.pth`` in the
|
|
22
|
+
site-packages of an environment the library is installed into, before any import, and by the library's
|
|
23
|
+
package ``__init__`` for an editable install, where a wheel's ``.pth`` is not placed. Importing
|
|
24
|
+
this module imports no kernel.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import importlib.util
|
|
30
|
+
import sys
|
|
31
|
+
import threading
|
|
32
|
+
import types
|
|
33
|
+
from importlib.machinery import PathFinder
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
KERNEL = ("build123d", "OCP")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_SWAP = threading.Lock()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class KernelModule(types.ModuleType):
|
|
43
|
+
"""A lazy kernel module: ``__spec__`` read as it stands, every other attribute the first
|
|
44
|
+
read of the standard library's lazy module, which loads it."""
|
|
45
|
+
|
|
46
|
+
def __getattribute__(self, attr: str) -> Any:
|
|
47
|
+
if attr == "__spec__":
|
|
48
|
+
return object.__getattribute__(self, attr)
|
|
49
|
+
with _SWAP:
|
|
50
|
+
if type(self) is KernelModule: # by type(): reading ``__class__`` would come back here
|
|
51
|
+
self.__class__ = importlib.util._LazyModule # noqa: SLF001
|
|
52
|
+
try:
|
|
53
|
+
return getattr(self, attr)
|
|
54
|
+
except AttributeError as error:
|
|
55
|
+
# A name the module lacks is the asker's mistake, raised from here as Python raises it,
|
|
56
|
+
# never as a failure of this frame's own (``analytics.signature``).
|
|
57
|
+
raise error.with_traceback(None) from None
|
|
58
|
+
|
|
59
|
+
def __delattr__(self, attr: str) -> None:
|
|
60
|
+
self.__getattribute__(attr)
|
|
61
|
+
delattr(self, attr)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class KernelLoader(importlib.util.LazyLoader):
|
|
65
|
+
"""``LazyLoader``, its module a ``KernelModule``."""
|
|
66
|
+
|
|
67
|
+
def exec_module(self, module: types.ModuleType) -> None:
|
|
68
|
+
super().exec_module(module)
|
|
69
|
+
module.__class__ = KernelModule
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class LazyKernelFinder:
|
|
73
|
+
"""Finds ``build123d`` and ``OCP`` as ``PathFinder`` does, their loaders made lazy."""
|
|
74
|
+
|
|
75
|
+
def find_spec(self, fullname: str, path: Any = None, target: Any = None) -> Any:
|
|
76
|
+
if fullname not in KERNEL:
|
|
77
|
+
return None
|
|
78
|
+
spec = PathFinder.find_spec(fullname, path, target)
|
|
79
|
+
if spec is None or spec.loader is None or not hasattr(spec.loader, "exec_module"):
|
|
80
|
+
return None # nothing to defer: the next finder imports it as ever
|
|
81
|
+
spec.loader = KernelLoader(spec.loader)
|
|
82
|
+
return spec
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def install() -> None:
|
|
86
|
+
"""Put the finder first on ``sys.meta_path``, once."""
|
|
87
|
+
if not any(isinstance(finder, LazyKernelFinder) for finder in sys.meta_path):
|
|
88
|
+
sys.meta_path.insert(0, LazyKernelFinder())
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def loaded(name: str) -> bool:
|
|
92
|
+
"""Whether ``name`` is imported and its body has run: a lazy module bound by ``import`` and
|
|
93
|
+
never read from is not loaded."""
|
|
94
|
+
module = sys.modules.get(name)
|
|
95
|
+
# By its exact type: an isinstance check that misses reads ``__class__``, which loads it.
|
|
96
|
+
return module is not None and type(module) not in (KernelModule, importlib.util._LazyModule) # noqa: SLF001
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
install()
|
cad/_internal/program.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""``python -P -m cad._internal.program ARGS``: ``python ARGS`` -- a script, ``-m MODULE`` or ``-c CODE`` --
|
|
2
|
+
with the program's own folder (the script's; for a module or code, the working folder) LAST on the module
|
|
3
|
+
search path, where Python puts it first.
|
|
4
|
+
|
|
5
|
+
So a project's module that bears the library's name -- a ``cad.py`` beside a model -- never stands in for
|
|
6
|
+
the library, and the program's own modules still import by their names. ``-P`` keeps the working folder
|
|
7
|
+
off the path from the interpreter's start. The server's ``run`` verb runs every program so, and the
|
|
8
|
+
engine's worker seats a model's folder the same way (``cad.engine.model.seat``).
|
|
9
|
+
|
|
10
|
+
A program written for cadgen 0.7 stops at its first import of a name 0.8 took out of ``cadgen``
|
|
11
|
+
(``build123d``, ``revolute``, ``read_scene``, ...): the failure is said as Python says it, then one line
|
|
12
|
+
naming the guide from 0.7 to 0.8 (``EARLIER``).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import runpy
|
|
19
|
+
import sys
|
|
20
|
+
import traceback
|
|
21
|
+
import types
|
|
22
|
+
|
|
23
|
+
USAGE = "usage: python -P -m cad._internal.program SCRIPT [ARGS...] | -m MODULE [ARGS...] | -c CODE [ARGS...]"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# The guide a program written for cadgen 0.7 is migrated by.
|
|
27
|
+
GUIDE = "https://github.com/earthtojake/text-to-cad/blob/main/docs/migrations/migrating-0.7-to-0.8.md"
|
|
28
|
+
EARLIER = f"A program written for cadgen 0.7 imports names 0.8 took out; migrate it: {GUIDE}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def earlier(error: BaseException) -> bool:
|
|
32
|
+
"""Whether ``error`` is an import from ``cadgen`` that failed: a name or a module 0.7 had."""
|
|
33
|
+
name = getattr(error, "name", None) or ""
|
|
34
|
+
return isinstance(error, ImportError) and (name == "cadgen" or name.startswith("cadgen."))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def last(folder: str) -> None:
|
|
38
|
+
"""``folder`` at the end of the module search path, moved there if the path holds it already."""
|
|
39
|
+
while folder in sys.path:
|
|
40
|
+
sys.path.remove(folder)
|
|
41
|
+
sys.path.append(folder)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def main(argv: list[str]) -> None:
|
|
45
|
+
if not argv or (argv[0] in ("-m", "-c") and len(argv) < 2) or (argv[0].startswith("-") and argv[0] not in ("-m", "-c")):
|
|
46
|
+
raise SystemExit(USAGE)
|
|
47
|
+
if argv[0] == "-m":
|
|
48
|
+
last(os.getcwd())
|
|
49
|
+
sys.argv = [argv[1], *argv[2:]] # runpy names the module's file in argv[0], as ``python -m`` does
|
|
50
|
+
runpy.run_module(argv[1], run_name="__main__", alter_sys=True)
|
|
51
|
+
elif argv[0] == "-c":
|
|
52
|
+
last(os.getcwd())
|
|
53
|
+
sys.argv = ["-c", *argv[2:]]
|
|
54
|
+
program = types.ModuleType("__main__")
|
|
55
|
+
sys.modules["__main__"] = program
|
|
56
|
+
exec(compile(argv[1], "<string>", "exec"), program.__dict__) # noqa: S102 -- python -c, as asked
|
|
57
|
+
else:
|
|
58
|
+
last(os.path.dirname(os.path.abspath(argv[0])))
|
|
59
|
+
sys.argv = list(argv)
|
|
60
|
+
runpy.run_path(argv[0], run_name="__main__")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if __name__ == "__main__":
|
|
64
|
+
try:
|
|
65
|
+
main(sys.argv[1:])
|
|
66
|
+
except ImportError as error:
|
|
67
|
+
if not earlier(error):
|
|
68
|
+
raise
|
|
69
|
+
traceback.print_exc()
|
|
70
|
+
print(EARLIER, file=sys.stderr)
|
|
71
|
+
sys.exit(1)
|