fastbuild123 0.1.0__tar.gz

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 (47) hide show
  1. fastbuild123-0.1.0/.gitignore +10 -0
  2. fastbuild123-0.1.0/.python-version +1 -0
  3. fastbuild123-0.1.0/LICENSE +21 -0
  4. fastbuild123-0.1.0/PKG-INFO +125 -0
  5. fastbuild123-0.1.0/README.md +88 -0
  6. fastbuild123-0.1.0/fastbuild/__init__.py +5 -0
  7. fastbuild123-0.1.0/fastbuild/builder.py +98 -0
  8. fastbuild123-0.1.0/fastbuild/buildloop.py +75 -0
  9. fastbuild123-0.1.0/fastbuild/cache/__init__.py +11 -0
  10. fastbuild123-0.1.0/fastbuild/cache/analysis.py +168 -0
  11. fastbuild123-0.1.0/fastbuild/cache/keys.py +135 -0
  12. fastbuild123-0.1.0/fastbuild/cache/store.py +80 -0
  13. fastbuild123-0.1.0/fastbuild/cache/wrap.py +157 -0
  14. fastbuild123-0.1.0/fastbuild/cli.py +160 -0
  15. fastbuild123-0.1.0/fastbuild/errors.py +8 -0
  16. fastbuild123-0.1.0/fastbuild/examples/enclosure.py +65 -0
  17. fastbuild123-0.1.0/fastbuild/examples/finned_enclosure.py +200 -0
  18. fastbuild123-0.1.0/fastbuild/examples/knob.py +152 -0
  19. fastbuild123-0.1.0/fastbuild/harness.py +136 -0
  20. fastbuild123-0.1.0/fastbuild/params.py +377 -0
  21. fastbuild123-0.1.0/fastbuild/protocol.py +95 -0
  22. fastbuild123-0.1.0/fastbuild/py.typed +0 -0
  23. fastbuild123-0.1.0/fastbuild/registry.py +43 -0
  24. fastbuild123-0.1.0/fastbuild/server.py +297 -0
  25. fastbuild123-0.1.0/fastbuild/session.py +95 -0
  26. fastbuild123-0.1.0/fastbuild/statelog.py +58 -0
  27. fastbuild123-0.1.0/fastbuild/static/app.css +241 -0
  28. fastbuild123-0.1.0/fastbuild/static/app.js +363 -0
  29. fastbuild123-0.1.0/fastbuild/static/index.html +40 -0
  30. fastbuild123-0.1.0/fastbuild/static/vendor/LICENSE-three-cad-viewer +18 -0
  31. fastbuild123-0.1.0/fastbuild/static/vendor/VERSION +1 -0
  32. fastbuild123-0.1.0/fastbuild/static/vendor/three-cad-viewer.css +1600 -0
  33. fastbuild123-0.1.0/fastbuild/static/vendor/three-cad-viewer.esm.min.js +19 -0
  34. fastbuild123-0.1.0/pyproject.toml +68 -0
  35. fastbuild123-0.1.0/scripts/bench_model.py +231 -0
  36. fastbuild123-0.1.0/tests/golden/build_status.json +16 -0
  37. fastbuild123-0.1.0/tests/golden/init.json +48 -0
  38. fastbuild123-0.1.0/tests/golden/model_update.json +9 -0
  39. fastbuild123-0.1.0/tests/test_buildloop.py +68 -0
  40. fastbuild123-0.1.0/tests/test_cache.py +576 -0
  41. fastbuild123-0.1.0/tests/test_harness_and_cli.py +118 -0
  42. fastbuild123-0.1.0/tests/test_params.py +180 -0
  43. fastbuild123-0.1.0/tests/test_protocol.py +83 -0
  44. fastbuild123-0.1.0/tests/test_reference_model.py +45 -0
  45. fastbuild123-0.1.0/tests/test_server.py +159 -0
  46. fastbuild123-0.1.0/tests/test_statelog.py +111 -0
  47. fastbuild123-0.1.0/uv.lock +2239 -0
@@ -0,0 +1,10 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
@@ -0,0 +1 @@
1
+ 3.12
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Devon Peroutky
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.4
2
+ Name: fastbuild123
3
+ Version: 0.1.0
4
+ Summary: Turn build123d models into live, interactive parametric web UI
5
+ Project-URL: Homepage, https://github.com/devonperoutky/FastBuild123
6
+ Project-URL: Repository, https://github.com/devonperoutky/FastBuild123
7
+ Project-URL: Issues, https://github.com/devonperoutky/FastBuild123/issues
8
+ Author-email: Devon Peroutky <devonperoutky@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ License-File: fastbuild/static/vendor/LICENSE-three-cad-viewer
12
+ Keywords: 3d,build123d,cad,modeling,opencascade,parametric
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Environment :: Web Environment
15
+ Classifier: Framework :: FastAPI
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Intended Audience :: Manufacturing
18
+ Classifier: Operating System :: MacOS
19
+ Classifier: Operating System :: Microsoft :: Windows
20
+ Classifier: Operating System :: POSIX :: Linux
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Topic :: Multimedia :: Graphics :: 3D Modeling
26
+ Classifier: Topic :: Scientific/Engineering
27
+ Classifier: Typing :: Typed
28
+ Requires-Python: >=3.10
29
+ Requires-Dist: annotated-types>=0.6
30
+ Requires-Dist: build123d>=0.8.0
31
+ Requires-Dist: fastapi>=0.115
32
+ Requires-Dist: ocp-tessellate>=3.4
33
+ Requires-Dist: pydantic>=2
34
+ Requires-Dist: uvicorn[standard]>=0.30
35
+ Requires-Dist: watchfiles>=1.1.1
36
+ Description-Content-Type: text/markdown
37
+
38
+ # FastBuild123
39
+
40
+ Turn any [build123d](https://github.com/gumyr/build123d) model into a live, interactive
41
+ parametric web UI. Annotate your part function's parameters with a FastAPI-style
42
+ `@app.part()` decorator and get auto-generated sliders in the browser with instant
43
+ 3D preview — the type-annotated signature *is* the UI definition.
44
+
45
+ ## Quick start
46
+
47
+ Run a bundled example without installing anything:
48
+
49
+ ```bash
50
+ uvx fastbuild123 demo enclosure
51
+ ```
52
+
53
+ Or install it into your own project:
54
+
55
+ ```bash
56
+ uv add fastbuild123 # or: pip install fastbuild123
57
+ fastbuild view my_model.py
58
+ ```
59
+
60
+ A browser page opens with parameter controls on the left and a live 3D view on the
61
+ right. Drag a slider or save the file — the model updates in place, keeping your
62
+ camera pose, tree state, and clipping planes. A model that raises keeps the last-good
63
+ geometry on screen with an error banner; fix the file and it recovers.
64
+
65
+ ## Defining parts
66
+
67
+ ```python
68
+ from typing import Annotated, Literal
69
+ from build123d import Part, Box
70
+ from fastbuild import App, Group, Range
71
+
72
+ app = App()
73
+
74
+ @app.part()
75
+ def enclosure(
76
+ width: Annotated[float, Range(20, 200, step=1, unit="mm")] = 80,
77
+ wall: Annotated[float, Range(0.8, 5, step=0.1, unit="mm")] = 2,
78
+ ribs: Annotated[int, Range(0, 8), Group("Ribs")] = 4,
79
+ vented: bool = False,
80
+ style: Literal["rounded", "chamfered"] = "rounded",
81
+ ) -> Part:
82
+ ...
83
+ ```
84
+
85
+ Widget mapping: `float`/`int` + `Range` → slider, bare numbers → number input,
86
+ `bool` → checkbox, `Literal`/`Enum` → dropdown. Every parameter needs a default —
87
+ defaults are the initial build. Multiple `@app.part()` functions in one file get a
88
+ part selector. Add `Group("Label")` to a parameter's annotation to place it under
89
+ a named collapsible section in the panel; ungrouped parameters render first.
90
+
91
+ ## Headless export
92
+
93
+ ```bash
94
+ fastbuild export my_model.py enclosure --param width=120 -o enclosure.step
95
+ ```
96
+
97
+ Values are validated against each parameter's `Range`; output format follows the
98
+ extension (`.step`/`.stp`/`.stl`).
99
+
100
+ ## Requirements
101
+
102
+ Python 3.10–3.13 on macOS (Apple Silicon or Intel), Linux (glibc 2.31+, x86-64 or
103
+ aarch64), or 64-bit Windows — wherever `cadquery-ocp` publishes wheels. Expect a
104
+ ~500 MB install; the OpenCASCADE kernel is most of it. Alpine/musl is not supported.
105
+
106
+ ## Development
107
+
108
+ ```bash
109
+ uv sync
110
+ uv run pytest
111
+ uv run fastbuild demo enclosure
112
+ ```
113
+
114
+ Examples live in `fastbuild/examples/` so they ship with the package and stay
115
+ runnable via `fastbuild demo`. Specs live in `specs/`. The 3D component is a
116
+ vendored, pinned build of
117
+ [three-cad-viewer](https://github.com/bernhard-42/three-cad-viewer)
118
+ (`fastbuild/static/vendor/`, see `VERSION` there) fed by
119
+ [ocp-tessellate](https://github.com/bernhard-42/ocp-tessellate) output over a
120
+ versioned websocket protocol.
121
+
122
+ ## License
123
+
124
+ MIT — see [LICENSE](LICENSE). Bundles three-cad-viewer (MIT), whose license is
125
+ included at `fastbuild/static/vendor/LICENSE-three-cad-viewer`.
@@ -0,0 +1,88 @@
1
+ # FastBuild123
2
+
3
+ Turn any [build123d](https://github.com/gumyr/build123d) model into a live, interactive
4
+ parametric web UI. Annotate your part function's parameters with a FastAPI-style
5
+ `@app.part()` decorator and get auto-generated sliders in the browser with instant
6
+ 3D preview — the type-annotated signature *is* the UI definition.
7
+
8
+ ## Quick start
9
+
10
+ Run a bundled example without installing anything:
11
+
12
+ ```bash
13
+ uvx fastbuild123 demo enclosure
14
+ ```
15
+
16
+ Or install it into your own project:
17
+
18
+ ```bash
19
+ uv add fastbuild123 # or: pip install fastbuild123
20
+ fastbuild view my_model.py
21
+ ```
22
+
23
+ A browser page opens with parameter controls on the left and a live 3D view on the
24
+ right. Drag a slider or save the file — the model updates in place, keeping your
25
+ camera pose, tree state, and clipping planes. A model that raises keeps the last-good
26
+ geometry on screen with an error banner; fix the file and it recovers.
27
+
28
+ ## Defining parts
29
+
30
+ ```python
31
+ from typing import Annotated, Literal
32
+ from build123d import Part, Box
33
+ from fastbuild import App, Group, Range
34
+
35
+ app = App()
36
+
37
+ @app.part()
38
+ def enclosure(
39
+ width: Annotated[float, Range(20, 200, step=1, unit="mm")] = 80,
40
+ wall: Annotated[float, Range(0.8, 5, step=0.1, unit="mm")] = 2,
41
+ ribs: Annotated[int, Range(0, 8), Group("Ribs")] = 4,
42
+ vented: bool = False,
43
+ style: Literal["rounded", "chamfered"] = "rounded",
44
+ ) -> Part:
45
+ ...
46
+ ```
47
+
48
+ Widget mapping: `float`/`int` + `Range` → slider, bare numbers → number input,
49
+ `bool` → checkbox, `Literal`/`Enum` → dropdown. Every parameter needs a default —
50
+ defaults are the initial build. Multiple `@app.part()` functions in one file get a
51
+ part selector. Add `Group("Label")` to a parameter's annotation to place it under
52
+ a named collapsible section in the panel; ungrouped parameters render first.
53
+
54
+ ## Headless export
55
+
56
+ ```bash
57
+ fastbuild export my_model.py enclosure --param width=120 -o enclosure.step
58
+ ```
59
+
60
+ Values are validated against each parameter's `Range`; output format follows the
61
+ extension (`.step`/`.stp`/`.stl`).
62
+
63
+ ## Requirements
64
+
65
+ Python 3.10–3.13 on macOS (Apple Silicon or Intel), Linux (glibc 2.31+, x86-64 or
66
+ aarch64), or 64-bit Windows — wherever `cadquery-ocp` publishes wheels. Expect a
67
+ ~500 MB install; the OpenCASCADE kernel is most of it. Alpine/musl is not supported.
68
+
69
+ ## Development
70
+
71
+ ```bash
72
+ uv sync
73
+ uv run pytest
74
+ uv run fastbuild demo enclosure
75
+ ```
76
+
77
+ Examples live in `fastbuild/examples/` so they ship with the package and stay
78
+ runnable via `fastbuild demo`. Specs live in `specs/`. The 3D component is a
79
+ vendored, pinned build of
80
+ [three-cad-viewer](https://github.com/bernhard-42/three-cad-viewer)
81
+ (`fastbuild/static/vendor/`, see `VERSION` there) fed by
82
+ [ocp-tessellate](https://github.com/bernhard-42/ocp-tessellate) output over a
83
+ versioned websocket protocol.
84
+
85
+ ## License
86
+
87
+ MIT — see [LICENSE](LICENSE). Bundles three-cad-viewer (MIT), whose license is
88
+ included at `fastbuild/static/vendor/LICENSE-three-cad-viewer`.
@@ -0,0 +1,5 @@
1
+ from fastbuild.cache.wrap import no_cache
2
+ from fastbuild.params import Group, Range
3
+ from fastbuild.registry import App
4
+
5
+ __all__ = ["App", "Group", "Range", "no_cache"]
@@ -0,0 +1,98 @@
1
+ """Build execution: part function → geometry → viewer shape JSON.
2
+
3
+ Each shape node in the output carries its tree path as `id` — the slot the
4
+ caching layer's provenance keys plug into (SPEC-ui §6).
5
+
6
+ Meshes are cached by handing `ocp_tessellate` our provenance digest as its
7
+ per-instance cache id instead of letting it hash the shape's serialized bytes
8
+ (SPEC-mesh-caching §3). Its own content hash cannot see through the store's
9
+ `BRepBuilderAPI_Copy`, so a cached child would otherwise re-mesh at full price.
10
+ """
11
+
12
+ import traceback
13
+ from dataclasses import dataclass
14
+ from time import perf_counter
15
+ from typing import Any, Mapping, Union
16
+
17
+ from build123d import Compound
18
+ from ocp_tessellate.cad_objects import OcpGroup, OcpObject
19
+ from ocp_tessellate.convert import numpy_to_buffer_json, tessellate_group, to_ocpgroup
20
+
21
+ from fastbuild.cache.keys import TAG_ATTR, CacheKey
22
+ from fastbuild.params import to_python
23
+ from fastbuild.registry import PartDef
24
+
25
+ FB_CACHE_PREFIX = "fb:"
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class BuildOutput:
30
+ part: str
31
+ shapes: Mapping[str, Any]
32
+ timings: Mapping[str, float]
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class BuildFailure:
37
+ part: str
38
+ traceback: str
39
+
40
+
41
+ BuildResult = Union[BuildOutput, BuildFailure]
42
+
43
+
44
+ def _provenance_ids(root: Any, group: OcpGroup) -> tuple[tuple[int, str], ...]:
45
+ """(instance ref, cache id) pairs, but only when `group` is a flat,
46
+ name-matching image of `root.children`. Any other structure yields ()
47
+ and leaves ocp_tessellate's own content hashing in place — the mapping is
48
+ either exact or absent, so a mesh can never be filed under a sibling's key.
49
+ """
50
+ children = tuple(root.children) if isinstance(root, Compound) and root.children else ()
51
+ objects = group.objects
52
+ if not children or len(objects) != len(children):
53
+ return ()
54
+ ids = []
55
+ for obj, child in zip(objects, children):
56
+ if not isinstance(obj, OcpObject) or obj.ref is None or obj.name != child.label:
57
+ return ()
58
+ key = getattr(child, TAG_ATTR, None)
59
+ if isinstance(key, CacheKey):
60
+ ids.append((obj.ref, FB_CACHE_PREFIX + key.digest))
61
+ return tuple(ids)
62
+
63
+
64
+ def tessellate_to_json(obj: Any, name: str) -> Mapping[str, Any]:
65
+ group, instances = to_ocpgroup(obj, names=[name])
66
+ for ref, cache_id in _provenance_ids(obj, group):
67
+ instances[ref]["cache_id"] = cache_id
68
+ instance_meshes, shapes, _id_map = tessellate_group(group, instances)
69
+ return numpy_to_buffer_json({"instances": instance_meshes, "shapes": shapes})
70
+
71
+
72
+ def build_part(part_def: PartDef, values: Mapping[str, Any]) -> BuildResult:
73
+ """Build the part and return the result. Caching is engaged iff
74
+ `harness.install_cache` wrapped the model's functions."""
75
+ call_args = {
76
+ spec.name: to_python(spec, values[spec.name])
77
+ for spec in part_def.params
78
+ if spec.name in values
79
+ }
80
+ start = perf_counter()
81
+ try:
82
+ result = part_def.fn(**call_args)
83
+ except Exception:
84
+ return BuildFailure(part=part_def.name, traceback=traceback.format_exc())
85
+ built = perf_counter()
86
+ try:
87
+ shapes = tessellate_to_json(result, part_def.name)
88
+ except Exception:
89
+ return BuildFailure(part=part_def.name, traceback=traceback.format_exc())
90
+ done = perf_counter()
91
+ return BuildOutput(
92
+ part=part_def.name,
93
+ shapes=shapes,
94
+ timings={
95
+ "build_ms": round((built - start) * 1000, 1),
96
+ "tessellate_ms": round((done - built) * 1000, 1),
97
+ },
98
+ )
@@ -0,0 +1,75 @@
1
+ """Build scheduling: one coalescing queue plus a trailing-edge drag debouncer.
2
+
3
+ At most one build runs at a time and at most one is pending; a newer request
4
+ replaces the pending one (newest wins). Drag events fire the queue at most
5
+ once per debounce window, reading the freshest state at fire time.
6
+ """
7
+
8
+ import asyncio
9
+ from dataclasses import dataclass
10
+ from typing import Any, Awaitable, Callable, Mapping
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class BuildRequest:
15
+ part: str
16
+ values: Mapping[str, Any]
17
+
18
+
19
+ class BuildQueue:
20
+ def __init__(self, execute: Callable[[BuildRequest], Awaitable[None]]) -> None:
21
+ self._execute = execute
22
+ self._pending: BuildRequest | None = None
23
+ self._worker: asyncio.Task | None = None
24
+
25
+ @property
26
+ def busy(self) -> bool:
27
+ return self._worker is not None and not self._worker.done()
28
+
29
+ def submit(self, request: BuildRequest) -> None:
30
+ self._pending = request
31
+ if not self.busy:
32
+ self._worker = asyncio.get_running_loop().create_task(self._drain())
33
+
34
+ async def _drain(self) -> None:
35
+ while self._pending is not None:
36
+ request = self._pending
37
+ self._pending = None
38
+ await self._execute(request)
39
+
40
+ async def join(self) -> None:
41
+ while self._worker is not None and not self._worker.done():
42
+ await asyncio.shield(self._worker)
43
+
44
+
45
+ class TrailingDebounce:
46
+ """Coalesces a burst of touches into one action per window, trailing edge."""
47
+
48
+ def __init__(self, delay_s: float, action: Callable[[], None]) -> None:
49
+ # window length
50
+ self._delay_s = delay_s
51
+ # What to do when window closes
52
+ self._action = action
53
+ self._handle: asyncio.TimerHandle | None = None
54
+
55
+ def touch(self) -> None:
56
+ """User drag(s) slider, start debounce window"""
57
+ if self._handle is None:
58
+ self._handle = asyncio.get_running_loop().call_later(
59
+ self._delay_s, self._fire
60
+ )
61
+
62
+ def flush(self) -> None:
63
+ """User released the slider. Immediatly close window and call action"""
64
+ self.cancel()
65
+ self._action()
66
+
67
+ def cancel(self) -> None:
68
+ if self._handle is not None:
69
+ self._handle.cancel()
70
+ self._handle = None
71
+
72
+ def _fire(self) -> None:
73
+ """Run the action and close the window"""
74
+ self._handle = None
75
+ self._action()
@@ -0,0 +1,11 @@
1
+ """Provenance-keyed memoization for model-file functions (SPEC-caching Phase 1).
2
+
3
+ `keys` and `analysis` are pure; `store` isolates the mutable cache; `wrap`
4
+ builds the memoizing wrappers that `harness.install_cache` installs.
5
+ """
6
+
7
+ from fastbuild.cache.keys import CacheKey
8
+ from fastbuild.cache.store import FrozenStats, Store
9
+ from fastbuild.cache.wrap import no_cache
10
+
11
+ __all__ = ["CacheKey", "FrozenStats", "Store", "no_cache"]
@@ -0,0 +1,168 @@
1
+ """AST call-closure analysis: what source a function's key must cover, and
2
+ whether it is safe to cache at all. Pure — findings come back as data.
3
+
4
+ Cacheability is a property of the transitive closure, not the node: a cached
5
+ caller's hit short-circuits every callee, so impurity, unresolvable calls and
6
+ `@no_cache` are all contagious upward over the same closure the source hashes
7
+ traverse (SPEC-caching §3 Analysis).
8
+ """
9
+
10
+ import ast
11
+ import builtins
12
+ import hashlib
13
+ from dataclasses import dataclass
14
+ from typing import Mapping
15
+
16
+ IMPURE_NAMES = frozenset(
17
+ {"open", "import_svg", "import_step", "environ", "random", "time", "input"}
18
+ )
19
+
20
+ _BUILTIN_NAMES = frozenset(dir(builtins))
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class FunctionNode:
25
+ name: str
26
+ source_hash: str
27
+ callees: frozenset[str]
28
+ impure: tuple[str, ...]
29
+ unresolved: tuple[str, ...]
30
+ no_cache: bool
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Cacheability:
35
+ closure_hash: str
36
+ reasons: tuple[str, ...]
37
+ members: tuple[str, ...]
38
+
39
+ @property
40
+ def cacheable(self) -> bool:
41
+ return not self.reasons
42
+
43
+
44
+ def _assigned_names(node: ast.stmt) -> tuple[str, ...]:
45
+ match node:
46
+ case ast.Assign(targets=targets):
47
+ return tuple(
48
+ target.id for target in targets if isinstance(target, ast.Name)
49
+ )
50
+ case ast.AnnAssign(target=ast.Name(id=name)) | ast.AugAssign(
51
+ target=ast.Name(id=name)
52
+ ):
53
+ return (name,)
54
+ case _:
55
+ return ()
56
+
57
+
58
+ def _imported_names(node: ast.stmt) -> tuple[str, ...]:
59
+ match node:
60
+ case ast.Import(names=names):
61
+ return tuple(a.asname or a.name.split(".")[0] for a in names)
62
+ case ast.ImportFrom(names=names):
63
+ return tuple(a.asname or a.name for a in names)
64
+ case _:
65
+ return ()
66
+
67
+
68
+ def _has_no_cache_decorator(fn: ast.FunctionDef) -> bool:
69
+ return any(
70
+ isinstance(d, ast.Name)
71
+ and d.id == "no_cache"
72
+ or isinstance(d, ast.Attribute)
73
+ and d.attr == "no_cache"
74
+ for d in fn.decorator_list
75
+ )
76
+
77
+
78
+ def _classify_calls(
79
+ fn: ast.FunctionDef, functions: frozenset[str], external: frozenset[str]
80
+ ) -> tuple[frozenset[str], tuple[str, ...]]:
81
+ callees: set[str] = set()
82
+ unresolved: list[str] = []
83
+ for node in ast.walk(fn):
84
+ if not isinstance(node, ast.Call):
85
+ continue
86
+ match node.func:
87
+ case ast.Name(id=name) if name in functions:
88
+ callees.add(name)
89
+ case ast.Name(id=name) if name in external or name in _BUILTIN_NAMES:
90
+ pass
91
+ case ast.Name(id=name):
92
+ unresolved.append(f"call to '{name}' cannot be resolved")
93
+ case ast.Attribute():
94
+ pass
95
+ case _:
96
+ unresolved.append("dynamic call target")
97
+ return frozenset(callees), tuple(unresolved)
98
+
99
+
100
+ def analyze_functions(
101
+ source: str, source_hashes: Mapping[str, str]
102
+ ) -> Mapping[str, FunctionNode]:
103
+ """One node per top-level function, with its resolved model-file callees."""
104
+ tree = ast.parse(source)
105
+ defs = [n for n in tree.body if isinstance(n, ast.FunctionDef)]
106
+ functions = frozenset(d.name for d in defs)
107
+ external = frozenset(
108
+ name
109
+ for stmt in tree.body
110
+ for name in (*_imported_names(stmt), *_assigned_names(stmt))
111
+ ) - functions
112
+ nodes = {}
113
+ for fn in defs:
114
+ callees, unresolved = _classify_calls(fn, functions, external)
115
+ impure = tuple(
116
+ sorted(
117
+ {
118
+ node.id
119
+ for node in ast.walk(fn)
120
+ if isinstance(node, ast.Name) and node.id in IMPURE_NAMES
121
+ }
122
+ )
123
+ )
124
+ nodes[fn.name] = FunctionNode(
125
+ name=fn.name,
126
+ source_hash=source_hashes.get(fn.name, ""),
127
+ callees=callees,
128
+ impure=impure,
129
+ unresolved=unresolved,
130
+ no_cache=_has_no_cache_decorator(fn),
131
+ )
132
+ return nodes
133
+
134
+
135
+ def _closure(nodes: Mapping[str, FunctionNode], root: str) -> tuple[str, ...]:
136
+ seen = {root}
137
+ stack = [root]
138
+ while stack:
139
+ for callee in nodes[stack.pop()].callees:
140
+ if callee not in seen:
141
+ seen.add(callee)
142
+ stack.append(callee)
143
+ return tuple(sorted(seen))
144
+
145
+
146
+ def resolve(
147
+ nodes: Mapping[str, FunctionNode],
148
+ extra_no_cache: frozenset[str] = frozenset(),
149
+ ) -> Mapping[str, Cacheability]:
150
+ """Fold each function's transitive closure into its hash and its verdict."""
151
+ out = {}
152
+ for name in nodes:
153
+ members = _closure(nodes, name)
154
+ digest = hashlib.sha256(
155
+ "\n".join(f"{m}:{nodes[m].source_hash}" for m in members).encode()
156
+ ).hexdigest()
157
+ reasons = []
158
+ for member in members:
159
+ node = nodes[member]
160
+ via = "" if member == name else f" (via '{member}')"
161
+ reasons.extend(f"impure name '{n}'{via}" for n in node.impure)
162
+ reasons.extend(f"{u}{via}" for u in node.unresolved)
163
+ if node.no_cache or member in extra_no_cache:
164
+ reasons.append(f"@no_cache{via}")
165
+ out[name] = Cacheability(
166
+ closure_hash=digest, reasons=tuple(reasons), members=members
167
+ )
168
+ return out