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.
Files changed (116) hide show
  1. cad/__init__.py +100 -0
  2. cad/_internal/__init__.py +6 -0
  3. cad/_internal/atomic_replace.py +137 -0
  4. cad/_internal/counts.py +36 -0
  5. cad/_internal/font_scan.py +146 -0
  6. cad/_internal/lazy_kernel.py +99 -0
  7. cad/_internal/program.py +71 -0
  8. cad/_internal/validation_door.py +89 -0
  9. cad/_internal/version.py +44 -0
  10. cad/ap242.py +47 -0
  11. cad/check_fonts.py +105 -0
  12. cad/eng_drawing.py +1547 -0
  13. cad/engine/__init__.py +83 -0
  14. cad/engine/addresses.py +36 -0
  15. cad/engine/animations.py +628 -0
  16. cad/engine/api.py +1254 -0
  17. cad/engine/atomic_replace.py +157 -0
  18. cad/engine/build.py +1257 -0
  19. cad/engine/client.py +407 -0
  20. cad/engine/colors.py +113 -0
  21. cad/engine/contract.py +191 -0
  22. cad/engine/delta.py +456 -0
  23. cad/engine/document.py +2108 -0
  24. cad/engine/document_migrations.py +298 -0
  25. cad/engine/door.py +430 -0
  26. cad/engine/executables.py +35 -0
  27. cad/engine/export/__init__.py +425 -0
  28. cad/engine/export/animation.py +556 -0
  29. cad/engine/export/dxf.py +611 -0
  30. cad/engine/export/glb.py +336 -0
  31. cad/engine/export/keys.py +208 -0
  32. cad/engine/export/mesh.py +109 -0
  33. cad/engine/export/mesh_file.py +141 -0
  34. cad/engine/export/motion.py +239 -0
  35. cad/engine/export/step.py +373 -0
  36. cad/engine/export/stl.py +34 -0
  37. cad/engine/export/threemf.py +128 -0
  38. cad/engine/export/tree.py +127 -0
  39. cad/engine/export/viewer.py +48 -0
  40. cad/engine/formats/__init__.py +199 -0
  41. cad/engine/formats/buffer.py +171 -0
  42. cad/engine/formats/dxf.py +766 -0
  43. cad/engine/formats/glb.py +446 -0
  44. cad/engine/formats/gltf.py +352 -0
  45. cad/engine/formats/meshes.py +129 -0
  46. cad/engine/formats/robot.py +970 -0
  47. cad/engine/formats/stl.py +47 -0
  48. cad/engine/formats/threemf.py +380 -0
  49. cad/engine/frames.py +55 -0
  50. cad/engine/identity.py +336 -0
  51. cad/engine/ids.py +139 -0
  52. cad/engine/kernel/__init__.py +2 -0
  53. cad/engine/kernel/bounds.py +93 -0
  54. cad/engine/kernel/checks.py +44 -0
  55. cad/engine/kernel/determinism.py +195 -0
  56. cad/engine/kernel/history.py +1027 -0
  57. cad/engine/kernel/imported.py +64 -0
  58. cad/engine/kernel/memo.py +4873 -0
  59. cad/engine/kernel/trace.py +2457 -0
  60. cad/engine/mechanism.py +2379 -0
  61. cad/engine/mesh.py +805 -0
  62. cad/engine/mesh_format.py +85 -0
  63. cad/engine/mesh_uri.py +51 -0
  64. cad/engine/model.py +1007 -0
  65. cad/engine/places.py +641 -0
  66. cad/engine/previous.py +375 -0
  67. cad/engine/profiling.py +95 -0
  68. cad/engine/progress.py +473 -0
  69. cad/engine/reader.py +682 -0
  70. cad/engine/records.py +166 -0
  71. cad/engine/refs.py +610 -0
  72. cad/engine/render/__init__.py +19 -0
  73. cad/engine/render/clip.py +213 -0
  74. cad/engine/render/context.py +383 -0
  75. cad/engine/render/drawing.py +380 -0
  76. cad/engine/render/explode.py +444 -0
  77. cad/engine/render/finish.py +270 -0
  78. cad/engine/render/grid.py +454 -0
  79. cad/engine/render/look.py +236 -0
  80. cad/engine/render/meshes.py +64 -0
  81. cad/engine/render/occt.py +1404 -0
  82. cad/engine/render/overlay.py +228 -0
  83. cad/engine/render/service.py +580 -0
  84. cad/engine/render/shapes.py +252 -0
  85. cad/engine/render/studio.hdr +0 -0
  86. cad/engine/render/text.py +533 -0
  87. cad/engine/said.py +258 -0
  88. cad/engine/serve.py +2137 -0
  89. cad/engine/snapshot.py +1051 -0
  90. cad/engine/sources.py +1207 -0
  91. cad/engine/step_style.py +275 -0
  92. cad/engine/windows_spawn.py +88 -0
  93. cad/engine/worker.py +668 -0
  94. cad/findings.py +91 -0
  95. cad/flatten.py +255 -0
  96. cad/geometry.py +450 -0
  97. cad/gltf.py +37 -0
  98. cad/py.typed +1 -0
  99. cad/results.py +72 -0
  100. cad/sdf.py +98 -0
  101. cad/sdf_external.py +118 -0
  102. cad/sdf_source.py +312 -0
  103. cad/sdf_validation.py +1122 -0
  104. cad/srdf.py +77 -0
  105. cad/srdf_source.py +424 -0
  106. cad/srdf_validation.py +666 -0
  107. cad/urdf.py +77 -0
  108. cad/urdf_source.py +1427 -0
  109. cad/xml_common.py +74 -0
  110. cad-0.10.0.dist-info/METADATA +492 -0
  111. cad-0.10.0.dist-info/RECORD +116 -0
  112. cad-0.10.0.dist-info/WHEEL +5 -0
  113. cad-0.10.0.dist-info/licenses/LICENSE +21 -0
  114. cad-0.10.0.dist-info/top_level.txt +2 -0
  115. cad-lazy-kernel.pth +1 -0
  116. 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,6 @@
1
+ """Internal cadgen implementation modules.
2
+
3
+ Everything under ``cad._internal`` is private: no import stability is
4
+ promised between releases. The supported API is the top-level ``cadgen``
5
+ modules and the root ``cadgen`` exports.
6
+ """
@@ -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)
@@ -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()
@@ -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)