git:20260903.dc7081f to git:20260904.a9da51f

10 added, 0 removed. Audit A to A.

# partcad
Core Python module implementing PartCAD's digital-thread logic (packages, parts, assemblies, providers).
Source: `./src/partcad`. Tests: `./tests/partcad`. It is one of the packages inside the single `partcad`
wheel, which also carries `partcad_ide_client` (see "The PartCAD IDE viewer client" below) — run all commands
below from the repo root unless noted.
## Setup
All commands on this page run **inside the dev container**, not on the host — see "Where commands run" in the
root [AGENTS.md](../../AGENTS.md) for how to enter it. Dependencies are already installed in the image; re-run
`poetry install` only after changing `pyproject.toml`. The virtualenv is not auto-activated, so prefix the
commands below with `poetry run` (e.g. `poetry run pytest ...`).
```bash
poetry install # from repo root; installs the whole `partcad` wheel in editable mode
```
## Test and validate changes
Running `pytest` to a clean pass is the required validation step for any change under `partcad/`:
```bash
pytest tests/partcad -x -p no:error-for-skips -p no:warnings --dist no # matches CI (test-pytest job)
pytest tests/partcad -n 4 --timeout 300 -m "not slow" # matches the pre-commit hook, faster locally
```
Tests live in `./tests/partcad` (`tests/partcad/unit`); slow tests are marked `slow` and excluded by the pre-commit hook's `-m
"not slow"`. Treat a failing `pytest` run as blocking — do not consider a change to this module complete until
it passes.
## Lint / format
```bash
black --check src/partcad tests/partcad # line-length 120 (pyproject.toml)
flake8 src/partcad tests/partcad
isort --check src/partcad tests/partcad
```
## Conventions
- **Async naming**: every externally visible coroutine has a name ending in `_async`, paired with a synchronous
wrapper of the same name without the suffix. Coroutines run on asyncio's event loop; CPU-heavy work runs on a
separate thread pool (sized to CPU cores minus 1). Tasks on the thread pool must not call coroutines that use
`asyncio.Lock()`. An assembly is instantiated on the *unconstrained* pool instead: it computes nothing itself,
it waits for its parts, and each of those takes a thread from the constrained one -- assemblies waiting there
is how enough of them at once run it out of threads, every one waiting for a part with nowhere left to run.
+ - **Admission limits**: `threadsMax` also caps how many tests and how many linting checks run at once, and both
+ go through `concurrency.ReentrantGate` rather than a bare `asyncio.Semaphore`. A check may run the other
+ checks itself -- `CamTest` runs the whole suite over everything an assembly is procured from, from inside the
+ call the gate has already admitted -- so nested work is charged to the permit its caller already holds. A
+ semaphore counts it as a new arrival instead, and once as many callers as the limit are each waiting on a
+ nested call, every permit is held by somebody waiting for one and the loop stops for good. That is what hung
+ `pc test -r`, and with it the daemon serving it. The gate keeps one semaphore per event loop for the same
+ reason `sandbox_lock.py` polls: these are taken from several loops at once, and the daemon runs one
+ `asyncio.run()` per request, so a semaphore kept for the process belongs to whichever request created it.
+
- **Sandbox concurrency**: a wrapper runs in a sandbox *environment* -- the runtime's own conda prefix, or the
session v-env of a package that has requirements of its own -- and `sandbox_lock.py` is what holds those
apart. `EnvironmentLock` is a readers/writer lock keyed on the environment's path: running a wrapper reads it,
installing into it or creating it writes, so any number of wrappers share an environment while an install has
it to itself. `process_slots` caps how many sandbox interpreters run at once, because a wrapper is a whole CAD
process and it is the machine, not the thread pool, that decides how many fit; `threadsMax` sizes it. Both are
polled rather than blocked on: they are taken from several event loops at once (a part is instantiated on a
worker thread running a loop of its own), and a task that blocked its own loop waiting for a lock the task
beside it is about to release would never see it released.
- **Location/coordinate format**: 3D locations (OpenCASCADE `TopLoc_Location`) are represented as
`[[x, y, z], [rx, ry, rz], angle]` — translation in mm, then an axis vector and rotation angle (degrees)
around it.
- **Software is not a shape** (`software.py`, `software_factory*.py`): a package's `software:` section declares
the files a product ships with -- firmware images, binaries -- and `Software` deliberately does not inherit
`Shape`. There is no geometry, so nothing here renders, exports, tessellates or caches a shape; what it shares
with the shape factories is the `path`/`fileFrom` plumbing, and it shares it by following the same shape of
code rather than by inheriting a class built for shapes. Only one type exists, `raw`; the ones that follow it
name a firmware flashing procedure for the same file, so they belong beside `SoftwareFactoryRaw` and never as
a second way of pointing at a file.
A part or an assembly lists what it ships with in its own `software:`, resolved **once**, by
`ShapeFactory.__init__`, into `software_resolved` -- that is the only place that knows which package authored
the declaration, and an alias or an enrich hands the configuration on to packages where a bare name would mean
something else. Every assembly's bill of materials then lists that software with the commit its package was
read at (`revision.py`), because a firmware image, unlike a bracket, is a different file once its package
publishes again. `lint/software.py` is what keeps that answerable: a file the package does not carry has to
declare a `fileHash`, and `CamTest.software_failure()` enforces the same rule where it bites -- a board nobody
can flash is not a board anybody can make, so a part fails the manufacturing test when its software does not
resolve, cannot be fetched, or does not match its `fileHash`.
`pc add` writes a `fileHash` by itself where it can: given a URL rather than a path it fetches the file once
(`actions/add.py`) and records the hash of what came back, so an object added that way is pinned from the
moment it exists. That is also why `FileFactoryUrl` raises on a failed HTTP status -- without it a 404 page
is written out as the file, and `pc add` would pin the hash of an error page.
`fileHash` itself is **not** a software feature and does not live here: it sits beside `fileFrom`/`fileUrl`
and pins the *bytes* of any file a package fetches rather than carries, so it belongs to `file_factory.py`,
which refuses a download that does not hash to it (and deletes what it refused, or the next run would skip
the download and reuse it). It is optional in the declaration and required for reproducibility:
`unreproducible_reason()` is the one statement of that rule, and `CamTest.reproducibility_failure()` is what
makes a fetched-but-unpinned object fail the manufacturing test -- manufacturing is repetition, and a file
that may be a different file tomorrow cannot be made again. There are three ways out and any one will do: a
`vendor` and an `sku` (ordering the same SKU again is what "the same again" means for a bought thing), a
file the package carries, or a `fileHash`. Only parts and assemblies can take the first -- the schema gives
`vendor`/`sku` to those two alone -- so a sketch and a piece of software fall through to the file, which is
why the check reaches sketches at all even though nothing manufactures a drawing. `lint/software.py`
answers the same rule earlier, on the declaration, and only for software. A file a repository plugin serves
is exempt for now (`PACKAGE_FILE_SOURCES`): a `fileHash` given for one is verified, it is simply not
required yet.
Keep all of it clear of the hashes PartCAD computes for itself -- `CacheHash`, a git revision -- which
identify something PartCAD built or fetched, where this states in advance which bytes were asked for.
That test reads more than the shape's hash covers, which is what `Test.cache_key_suffix()` exists for: a
corrected `fileHash` has to move the cache key, or `pc test` answers the new declaration with the old one's
failure.
- **Built-in packages** (`./src/partcad/builtin`): PartCAD ships two packages inside itself, reachable from
every context as `//builtin/export` and `//builtin/render` (loaded on demand by `Context.get_project`, see
`output.py`). They declare the file types `pc export` and `pc render` write, in exactly the form a user's
package declares one — a `path` to a script, its `pythonRequirements`, and the export parameters. So adding a
format, changing its defaults or changing which dependencies it needs is an edit to `builtin/*/partcad.yaml`, not
to `shape.py`. The scripts run in a sandbox through `wrappers/wrapper_export.py`; they are data files, so
anything new under `builtin/` has to be listed in `pyproject.toml`'s `package-data` and in the PyInstaller
spec (see "Packaging" in the root [AGENTS.md](../../AGENTS.md)). The requirement strings there are the versions
`sandbox_versions.py` pins, which `tests/partcad/unit/test_output.py` enforces.
- **Drawing ports and interfaces** (`./src/partcad/render_overlay.py`, `./src/partcad/wrappers/stroke_text.py`):
`pc render --with-ports`/`--with-interfaces` draws the connection metadata on top of a projection.
`render_overlay.py` answers only *where* the ports are — a lookup for a part, a walk for an assembly (and so
for a scene, which is one), all of it plain arithmetic on `geom.Location` plus the port sketches' existing
envelopes, so the core stays free of OCP — and `builtin/render/render_svg.py` does the drawing, because it is
the only side that knows where the camera is. The labels are line segments from `stroke_text.py` rather than
an SVG `<text>` element: PNG and JPEG go through the SVG and would keep one, but DXF converts paths only, and
real text geometry would need a font whose version this repository does not control. Two things ask for the
overlay and neither overrides the other — the command line, and a `render:` file type declaring
`with_ports:`/`with_interfaces:` — which is `render_overlay.effective()`, and is how
`examples/feature_interface` keeps four such drawings checked in.
- **Sandbox environment** (`./src/partcad/python_env.py`): importing `partcad` sweeps every `PYTHON*` variable
out of `os.environ` and puts back only `PARTCAD_PYTHON_ENV`. Everything PartCAD spawns — the wrappers, `pip`,
`-m venv`, conda — inherits that, which is why a sandbox interpreter runs with plain `-sOOu` rather than the
`-I` it used to: `-I` implies `-E`, and `-E` would have made the sandbox ignore PartCAD's own
`PYTHONHASHSEED=0` along with the user's `PYTHONPATH`. So do not reintroduce `-I`/`-E` on a sandbox command
the environment already covers, and add anything a sandbox interpreter has to be told through the environment
to `PARTCAD_PYTHON_ENV`, where the sweep cannot take it away again.
Below `sandbox_versions.MIN_PYTHON_VERSION_SAFE_PATH` the environment does *not* cover it: `PYTHONSAFEPATH`
arrived in 3.11 and an older interpreter ignores it, which would leave the directory PartCAD runs from first
on `sys.path` for the `-m venv`/`-m pip` calls that provision a sandbox. Those calls — and only those — keep
`-I` there, which is why `PythonRuntime` carries two flag lists (`python_flags` for a wrapper,
`python_provisioning_flags` for a `-m` command) and picks between them in `flags_for()`. A wrapper is run by
path, so its `sys.path[0]` is PartCAD's own `wrappers/` directory rather than anything a user writes to;
giving it `-I` would buy no isolation and would cost it `PYTHONHASHSEED`, since `-I` implies `-E`. So do not
collapse the two lists back into one, and do not hand `-I` to anything but a `-m` command.
`tests/partcad/unit/test_python_env.py` asserts the outcomes (a `venv.py`/`pip.py` beside the interpreter
never wins; a wrapper's sibling import is never shadowed by a file in PartCAD's working directory; the hash
seed is honored on every version) on whichever version is running, so both branches are covered by the CI
matrix rather than by a comment.
## Schemas and linting
**Neither schema is here.** Both `partcad_utils/schema/partcad.json` (the `partcad.yaml` schema) and
`partcad_utils/schema/assy.json` (the ASSY one) live beside `partcad_utils.assy_lint`, the checker that reads
both, and ship through `[tool.setuptools.package-data]` in `pyproject.toml` and the PyInstaller spec's copy of
that directory. They are there because a client checks the file it is editing without a daemon and without a
CAD kernel: a schema under `partcad` would mean importing one to read a JSON file. `lint/all.py` registers the
checks — the names it gives them are what `pc lint -f` filters on — and `get_partcad_schema()` is the one way
in for anything that wants the configuration schema itself.
`lint/schema.py` is the *package* half of both checks: `SchemaLinting` walks a package's `partcad.yaml`,
`AssySchemaLinting` its `.assy` files, and `YamlLinting` under them is the shared body — reading the file and
handing it to `assy_lint.validate_source`. Walking a package needs the package graph, which is daemon work,
while each client checks the one file being edited in its own process (`partcad_client.lint`, reached by
`pc lint --file`). Two implementations of that check would let an editor and CI disagree about a file, so there
is one, in the package both ends already depend on. That is also why `AssySchemaLinting.get_targets` asks
`assy_lint.is_assy_file` rather than "does this file have a schema": both kinds have one now, and the looser
question would have it walk `partcad.yaml` too and report every finding twice.
A `partcad.yaml` is a Jinja2 template exactly as an ASSY file is — `ProjectLocal` renders it, `includePaths`
and all — so it goes through the same masking rather than straight to `yaml.safe_load`, and every finding
carries the line and column it came from. **A gap in the configuration schema is now a squiggle on a working
file**, not a message in a CI log nobody reads: whatever PartCAD's own tooling writes has to validate. `pc init`
writes empty (null) sections, so every section accepts null; every registered part type has to be in the
`parts` enum (`sdf` was not, and two shipped examples failed their own check because of it).
The **scene** schema is that same schema with `how` forbidden, derived from it by
`assy_lint.scene_schema()` rather than kept beside it as a second file — a copy is a copy that stops matching.
Which of the two a given `.assy` is checked against is not a property of the file but of what points at it, so
the package half reads the declaration (exact) and each client works it out best effort: `pc lint --file` from
the `partcad.yaml` files around the file, the VS Code extension from the package contents it has already
loaded. All three lean the same way — unknown means assembly, because reading an assembly as a scene would put
a false error on correct code.
## The PartCAD IDE viewer client
`./src/partcad_ide_client` is the Python half of the socket protocol that connects `partcad` to the PartCAD
IDE extension's **PartCAD Viewer**. It is a sibling package in the same wheel, so `pip install partcad` makes
`import partcad_ide_client` work and nothing has to install it separately.
It ships that way rather than as a distribution of its own because it was never on PyPI and every process that
could import it is a process that already imports `partcad` — `partcad.viewer` is its only importer in the
tree. Two distributions owning one import name is the thing being avoided: pip does not detect the overlap when
installing, and uninstalling either one then deletes the module out from under the other, silently. So do not
give `partcad_ide_client` a `pyproject.toml` of its own, and do not vendor a second copy into the VS Code
extension — a copy that lands first on `sys.path` shadows this one in one process and not another, leaving two
different clients in play depending on which process is asking.
The other half of the protocol is `ide/vscode/src/viewer/protocol.ts`. **A change to the wire format is
a change to both files**, and the frame layout is specified once, in
`src/partcad_ide_client/protocol.py` — that docstring is the normative description.
`ide/vscode/docs/partcad-viewer.md` walks the whole path end to end.
Two properties of the package are deliberate and easy to break:
- **No dependencies, standard library only.** It is imported into whatever interpreter is driving PartCAD, and
depending on anything (a CAD library above all — which is exactly what depending on `ocp_vscode` did) risks
dragging a second, differently-provisioned stack into that interpreter. Note that this is a constraint on the
package, not on `partcad`: it is why the package can sit here without adding a single requirement.
- **It does not import `partcad`.** Geometry has already been tessellated into glTF by a PartCAD sandbox before
it reaches here, so there is nothing to import. `partcad` imports *this*, lazily, from `partcad.viewer`.
The glTF payload codec (`encode_gltf`/`decode_gltf`) has two other implementations that have to agree with it:
`ocp_serialize.encode_gltf` in the sandbox, and `decodeGltf` in the extension. Neither can import this package,
which is why each carries its own copy; `tests/partcad/unit/test_viewer.py` and the extension's
`viewerProtocol.test.ts` are what catch a drift.
Its own tests live in `tests/partcad_ide_client`.
## Commit
`pre-commit` hooks (`dev-tools/pre-commit-config.yaml`) run `pytest`, formatting, and lint checks on commit and
are required to pass in CI before a PR can merge.