Phase 10A/10B Inc 0: build system, handle model, first module surface
Builds n3xd-ocp end to end and publishes 7.9.3.1.dev1 to the Gitea registry, where it installs anonymously and passes its suite. - occt/Dockerfile: OCCT 7.9.3 compiled once into a manylinux_2_28 builder image (base digest + tarball sha256 pinned), Draw/VTK/Tk/Xlib/OpenGL off, FreeType on, -O2 without fast-math or march=native. A final layer asserts TKService/TKV3d exist with no libGL/libX11 DT_NEEDED, which is what lets the app image drop libgl1/libx11-6. Mounted into, never built FROM. - scikit-build-core + nanobind STABLE_ABI -> one cp312-abi3 extension that registers every OCP.* submodule via PyImport_AddModule, so `import OCP.TopoDS` needs no shim and cls.__module__ is right. Version <occt>.N is asserted against the OCCT found, keeping occt_version() truthful. - occt_handle.h: type caster for opencascade::handle<T> over OCCT's intrusive refcount. Wrappers are non-owning instances holding exactly one handle in their keep-alive list, reusing an existing wrapper so identity survives a round trip. Transient constructors go through ocp_new (never nb::init<>, which would let OCCT delete nanobind's storage); the caster refuses a refcount-0 object rather than corrupt the heap. Verified under ASAN with no memory-safety errors, plus an RSS bound over 50k create/destroy cycles. - Sub-shapes are returned by value everywhere, making the TShape lifetime class that segfaulted a process-global face memo unrepresentable. - Standard_Failure derives RuntimeError, with ~20 concrete types dispatched on the dynamic OCCT type (cad_pool marshals failures home by type name). - Inc 0 surface: gp subset, TopAbs, TopoDS (+ downcasts), TopExp, TopLoc, TopTools, BRep, BinTools, Poly, Standard. 34 of the app's 139 symbols. - n3xd_ocp: additive APIs kept out of the OCP namespace so parity testing stays meaningful. bintools (shape <-> bytes, GIL-free, byte-identical) and _debug. Two findings worth the record, both verified against the stock wheel rather than assumed: upstream binds __hash__ but leaves __eq__ at identity, which is exactly what geom_memo.py's hash-bucket + IsSame scan is built around, so we match it instead of "fixing" it; and BinTools can release the GIL after all, by slurping the file-like object instead of bridging a streambuf that would call back into Python. Gate: BREP round-trips are byte-identical to cadquery-ocp-novtk across six fixtures (the generator asserts stock idempotency first). That matters beyond IPC — derive.py content-addresses BREP payloads by sha256 and stores the ref.
This commit is contained in:
28
scripts/_env.sh
Executable file
28
scripts/_env.sh
Executable file
@@ -0,0 +1,28 @@
|
||||
# Shared environment for the in-container scripts. Sourced, not executed.
|
||||
#
|
||||
# Everything expensive or bulky (ccache, the CMake tree, pip's cache, the build
|
||||
# venv) lives under $CACHE, which the Makefile bind-mounts from outside the
|
||||
# repo — the root filesystem on this host has little headroom, and these
|
||||
# survive between runs to keep the inner loop incremental.
|
||||
|
||||
CACHE="${CACHE_ROOT:-/cache}"
|
||||
export CCACHE_DIR="$CACHE/ccache"
|
||||
export PIP_CACHE_DIR="$CACHE/pip"
|
||||
export HOME="$CACHE" # containers run as the host uid; /root is not ours
|
||||
export CMAKE_C_COMPILER_LAUNCHER=ccache
|
||||
export CMAKE_CXX_COMPILER_LAUNCHER=ccache
|
||||
export CMAKE_PREFIX_PATH=/opt/occt
|
||||
export LD_LIBRARY_PATH=/opt/occt/lib
|
||||
export SKBUILD_BUILD_DIR="$CACHE/skbuild/{wheel_tag}"
|
||||
|
||||
mkdir -p "$CCACHE_DIR" "$PIP_CACHE_DIR"
|
||||
|
||||
VENV="$CACHE/venv"
|
||||
if [ ! -x "$VENV/bin/python" ]; then
|
||||
echo "--- creating build venv at $VENV ---"
|
||||
/opt/python/cp312-cp312/bin/python -m venv "$VENV"
|
||||
"$VENV/bin/pip" install -q --upgrade pip
|
||||
"$VENV/bin/pip" install -q build nanobind scikit-build-core pytest
|
||||
fi
|
||||
PY="$VENV/bin/python"
|
||||
export PY VENV CACHE
|
||||
49
scripts/asan.sh
Executable file
49
scripts/asan.sh
Executable file
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
# Memory-safety check for the handle model.
|
||||
#
|
||||
# ASAN's leak detector is not usable here — CPython's arenas and OCCT's
|
||||
# process-lifetime singletons produce noise that would drown a real finding —
|
||||
# so this run targets *safety*: use-after-free, double-free, buffer overflow.
|
||||
# That is exactly the failure mode a wrong ownership rule produces (a stale
|
||||
# TShape is what segfaulted a process-global face memo under upstream OCP).
|
||||
# Leak *growth* is checked separately, by the RSS assertion in test_handles.py.
|
||||
set -euo pipefail
|
||||
|
||||
cd /io
|
||||
. scripts/_env.sh
|
||||
|
||||
BUILD="$CACHE/asan"
|
||||
mkdir -p "$BUILD"
|
||||
|
||||
echo "--- configuring ASAN build ---"
|
||||
cmake -G Ninja -S . -B "$BUILD" \
|
||||
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
|
||||
-DCMAKE_CXX_FLAGS="-fsanitize=address -fno-omit-frame-pointer -g" \
|
||||
-DCMAKE_SHARED_LINKER_FLAGS="-fsanitize=address" \
|
||||
-DPython_EXECUTABLE="$PY" \
|
||||
-Dnanobind_DIR="$("$PY" -c 'import nanobind; print(nanobind.cmake_dir())')" \
|
||||
>/dev/null
|
||||
|
||||
cmake --build "$BUILD" -j"$(nproc)"
|
||||
|
||||
STAGE=$(mktemp -d)
|
||||
trap 'rm -rf "$STAGE"' EXIT
|
||||
cp -r python/OCP python/n3xd_ocp tests "$STAGE/"
|
||||
cp "$BUILD"/_OCP*.so "$STAGE/OCP/"
|
||||
cp -r tests/data "$STAGE/tests/" 2>/dev/null || true
|
||||
|
||||
echo "--- running handle tests under ASAN ---"
|
||||
cd "$STAGE"
|
||||
# libasan.so is a linker script, not an ELF object — preloading it is silently
|
||||
# ignored and the run would look clean while ASAN was never active. Resolve
|
||||
# the real soname instead, and fail loudly if it is missing.
|
||||
ASAN_LIB=$(gcc -print-file-name=libasan.so.8)
|
||||
[ -f "$ASAN_LIB" ] || { echo "libasan.so.8 not found ($ASAN_LIB)" >&2; exit 1; }
|
||||
head -c 4 "$ASAN_LIB" | grep -q ELF || { echo "$ASAN_LIB is not an ELF object" >&2; exit 1; }
|
||||
|
||||
LD_PRELOAD="$ASAN_LIB" \
|
||||
N3XD_OCP_ASAN=1 \
|
||||
ASAN_OPTIONS="detect_leaks=0:abort_on_error=1:strict_string_checks=1:detect_stack_use_after_return=1" \
|
||||
"$PY" -m pytest tests/test_handles.py tests/test_shape_identity.py -q
|
||||
|
||||
echo "ASAN: no memory-safety errors"
|
||||
41
scripts/build_wheel.sh
Executable file
41
scripts/build_wheel.sh
Executable file
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the wheel. Runs INSIDE the OCCT builder image (see ../Makefile), which
|
||||
# supplies the kernel at /opt/occt and cp312 at /opt/python.
|
||||
set -euo pipefail
|
||||
|
||||
cd /io
|
||||
. scripts/_env.sh
|
||||
|
||||
echo "--- build (pass 1: compile) ---"
|
||||
rm -rf dist wheelhouse
|
||||
mkdir -p wheelhouse
|
||||
# --no-isolation keeps the build in the cached venv so the CMake tree and
|
||||
# ccache are actually reused between runs.
|
||||
"$PY" -m build --wheel --no-isolation --outdir dist
|
||||
|
||||
# Stubs are generated by importing the freshly built extension, so they cannot
|
||||
# exist before the first compile — and the wheel is packed from the source
|
||||
# tree, so they would miss this wheel and ship one build stale. Pass 2 repacks
|
||||
# with them present; it is incremental (same CMake tree, warm ccache).
|
||||
echo "--- stubs ---"
|
||||
scripts/stubgen.sh
|
||||
|
||||
echo "--- build (pass 2: repack with stubs) ---"
|
||||
rm -rf dist
|
||||
"$PY" -m build --wheel --no-isolation --outdir dist
|
||||
|
||||
echo "--- auditwheel repair ---"
|
||||
auditwheel repair --plat manylinux_2_28_x86_64 -w wheelhouse dist/*.whl
|
||||
|
||||
echo "--- smoke test (no LD_LIBRARY_PATH: proves the wheel is self-contained) ---"
|
||||
rm -rf "$CACHE/smoke"
|
||||
"$PY" -m venv "$CACHE/smoke"
|
||||
"$CACHE/smoke/bin/pip" install -q wheelhouse/*.whl
|
||||
env -u LD_LIBRARY_PATH "$CACHE/smoke/bin/python" -c "
|
||||
import OCP, n3xd_ocp
|
||||
from OCP.TopoDS import TopoDS_Shape
|
||||
print('OCP', OCP.__version__, '/ OCCT', OCP.__occt_version__)
|
||||
print('modules:', ' '.join(OCP._OCP.__all_modules__))
|
||||
"
|
||||
|
||||
ls -la wheelhouse/
|
||||
16
scripts/in_container_test.sh
Executable file
16
scripts/in_container_test.sh
Executable file
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
# Inner development loop: incremental compile, then the test suite.
|
||||
#
|
||||
# Installs into the cached venv rather than building a wheel, so a one-file
|
||||
# change is a recompile plus a relink.
|
||||
set -euo pipefail
|
||||
|
||||
cd /io
|
||||
. scripts/_env.sh
|
||||
|
||||
"$PY" -m pip install -q --no-build-isolation --no-deps -e . 2>&1 | tail -5 || {
|
||||
echo "editable install failed; falling back to a plain install" >&2
|
||||
"$PY" -m pip install -q --no-build-isolation --no-deps --force-reinstall .
|
||||
}
|
||||
|
||||
"$PY" -m pytest tests -q "$@"
|
||||
39
scripts/stubgen.sh
Executable file
39
scripts/stubgen.sh
Executable file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenerate type stubs from the built extension.
|
||||
#
|
||||
# Must run where the OCCT shared libraries resolve (inside the builder image,
|
||||
# pre-auditwheel), since stubgen imports the module to introspect it. Shipping
|
||||
# these plus py.typed is what retires the backend's blanket
|
||||
# `unresolved-import = "ignore"` ty suppression.
|
||||
set -euo pipefail
|
||||
|
||||
cd /io
|
||||
. scripts/_env.sh
|
||||
|
||||
SO=$(find "$CACHE/skbuild" -name '_OCP*.so' 2>/dev/null | head -1)
|
||||
if [ -z "$SO" ]; then
|
||||
echo "no built extension found; run the wheel build first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Stage the package tree next to the freshly built extension so stubgen can
|
||||
# import it without touching the repo copy.
|
||||
STAGE=$(mktemp -d)
|
||||
trap 'rm -rf "$STAGE"' EXIT
|
||||
cp -r python/OCP python/n3xd_ocp "$STAGE/"
|
||||
cp "$SO" "$STAGE/OCP/"
|
||||
|
||||
# -O <pkg> keeps each package's stubs inside its own directory: recursive
|
||||
# stubgen names files after the submodule alone, so a shared output dir would
|
||||
# flatten OCP.gp and n3xd_ocp.bintools into the same namespace.
|
||||
(cd "$STAGE" && "$PY" -m nanobind.stubgen -m OCP -r -O OCP && \
|
||||
"$PY" -m nanobind.stubgen -m n3xd_ocp -r -O n3xd_ocp)
|
||||
|
||||
find "$STAGE" -name '*.pyi' | while read -r f; do
|
||||
rel="${f#"$STAGE"/}"
|
||||
mkdir -p "python/$(dirname "$rel")"
|
||||
cp "$f" "python/$rel"
|
||||
done
|
||||
|
||||
echo "stubs written:"
|
||||
find python -name '*.pyi' | sort
|
||||
Reference in New Issue
Block a user