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.
- fastbuild123-0.1.0/.gitignore +10 -0
- fastbuild123-0.1.0/.python-version +1 -0
- fastbuild123-0.1.0/LICENSE +21 -0
- fastbuild123-0.1.0/PKG-INFO +125 -0
- fastbuild123-0.1.0/README.md +88 -0
- fastbuild123-0.1.0/fastbuild/__init__.py +5 -0
- fastbuild123-0.1.0/fastbuild/builder.py +98 -0
- fastbuild123-0.1.0/fastbuild/buildloop.py +75 -0
- fastbuild123-0.1.0/fastbuild/cache/__init__.py +11 -0
- fastbuild123-0.1.0/fastbuild/cache/analysis.py +168 -0
- fastbuild123-0.1.0/fastbuild/cache/keys.py +135 -0
- fastbuild123-0.1.0/fastbuild/cache/store.py +80 -0
- fastbuild123-0.1.0/fastbuild/cache/wrap.py +157 -0
- fastbuild123-0.1.0/fastbuild/cli.py +160 -0
- fastbuild123-0.1.0/fastbuild/errors.py +8 -0
- fastbuild123-0.1.0/fastbuild/examples/enclosure.py +65 -0
- fastbuild123-0.1.0/fastbuild/examples/finned_enclosure.py +200 -0
- fastbuild123-0.1.0/fastbuild/examples/knob.py +152 -0
- fastbuild123-0.1.0/fastbuild/harness.py +136 -0
- fastbuild123-0.1.0/fastbuild/params.py +377 -0
- fastbuild123-0.1.0/fastbuild/protocol.py +95 -0
- fastbuild123-0.1.0/fastbuild/py.typed +0 -0
- fastbuild123-0.1.0/fastbuild/registry.py +43 -0
- fastbuild123-0.1.0/fastbuild/server.py +297 -0
- fastbuild123-0.1.0/fastbuild/session.py +95 -0
- fastbuild123-0.1.0/fastbuild/statelog.py +58 -0
- fastbuild123-0.1.0/fastbuild/static/app.css +241 -0
- fastbuild123-0.1.0/fastbuild/static/app.js +363 -0
- fastbuild123-0.1.0/fastbuild/static/index.html +40 -0
- fastbuild123-0.1.0/fastbuild/static/vendor/LICENSE-three-cad-viewer +18 -0
- fastbuild123-0.1.0/fastbuild/static/vendor/VERSION +1 -0
- fastbuild123-0.1.0/fastbuild/static/vendor/three-cad-viewer.css +1600 -0
- fastbuild123-0.1.0/fastbuild/static/vendor/three-cad-viewer.esm.min.js +19 -0
- fastbuild123-0.1.0/pyproject.toml +68 -0
- fastbuild123-0.1.0/scripts/bench_model.py +231 -0
- fastbuild123-0.1.0/tests/golden/build_status.json +16 -0
- fastbuild123-0.1.0/tests/golden/init.json +48 -0
- fastbuild123-0.1.0/tests/golden/model_update.json +9 -0
- fastbuild123-0.1.0/tests/test_buildloop.py +68 -0
- fastbuild123-0.1.0/tests/test_cache.py +576 -0
- fastbuild123-0.1.0/tests/test_harness_and_cli.py +118 -0
- fastbuild123-0.1.0/tests/test_params.py +180 -0
- fastbuild123-0.1.0/tests/test_protocol.py +83 -0
- fastbuild123-0.1.0/tests/test_reference_model.py +45 -0
- fastbuild123-0.1.0/tests/test_server.py +159 -0
- fastbuild123-0.1.0/tests/test_statelog.py +111 -0
- fastbuild123-0.1.0/uv.lock +2239 -0
|
@@ -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,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
|