diff --git a/docs/skill/references/discipline/core-rules.md b/docs/skill/references/discipline/core-rules.md index d30545d0..c005d5ab 100644 --- a/docs/skill/references/discipline/core-rules.md +++ b/docs/skill/references/discipline/core-rules.md @@ -15,7 +15,9 @@ the durable export call `capture(result, path)`, whose two required arguments ar notebook; one assembly notebook per product, composing children with `scad.use(...)`; parameters live in the notebook that consumes them. Cell boundaries, `_private` names and `var()` follow `discipline/notebook-cell-design.md`; block structure and the mandatory header `# ---- feature: () ----` follow `discipline/feature-tree-convention.md`. Read both -before writing any cell. +before writing any cell. The one exception is a task that itself asks for a single script +(`python model.py --output part.step`, an unattended batch): that is a plain script, see "One-shot script" in +`domains/export-and-translation.md`; the notebook rules do not apply to it. **R4. Sketch first.** 2D profiles and planar paths go through the sketch API; primitives only when the shape is entirely the basic form. diff --git a/docs/skill/references/docs/api/export_product_package_to_mjcf.md b/docs/skill/references/docs/api/export_product_package_to_mjcf.md index a41c05fd..2058ec01 100644 --- a/docs/skill/references/docs/api/export_product_package_to_mjcf.md +++ b/docs/skill/references/docs/api/export_product_package_to_mjcf.md @@ -3,7 +3,7 @@ ## API Definition ```python -def export_product_package_to_mjcf(data: ProductPackageInput, output_path: str | Path, *, mesh_directory: str | Path | None = None, mapping_path: str | Path | None = None, linear_deflection: float = 0.15, angular_deflection_degrees: float = _DEFAULT_MJCF_ANGULAR_DEFLECTION_DEGREES, default_density_kg_m3: float | None = None) -> ProductMJCFExportReport +def export_product_package_to_mjcf(data: ProductInput, output_path: str | Path, *, mesh_directory: str | Path | None = None, mapping_path: str | Path | None = None, linear_deflection: float = 0.15, angular_deflection_degrees: float = _DEFAULT_MJCF_ANGULAR_DEFLECTION_DEGREES, default_density_kg_m3: float | None = None) -> ProductMJCFExportReport ``` *Source: exporter/mjcf.py* diff --git a/docs/skill/references/docs/api/export_product_package_to_obj.md b/docs/skill/references/docs/api/export_product_package_to_obj.md index 4e84a492..1c82c85b 100644 --- a/docs/skill/references/docs/api/export_product_package_to_obj.md +++ b/docs/skill/references/docs/api/export_product_package_to_obj.md @@ -3,7 +3,7 @@ ## API Definition ```python -def export_product_package_to_obj(data: ProductPackageInput, output_path: str | Path, *, linear_deflection: float = 0.1, angular_deflection_degrees: float = 20.0, relative: bool = False) -> ProductOBJExportReport +def export_product_package_to_obj(data: ProductInput, output_path: str | Path, *, linear_deflection: float = 0.1, angular_deflection_degrees: float = 20.0, relative: bool = False) -> ProductOBJExportReport ``` *Source: exporter/obj.py* diff --git a/docs/skill/references/docs/api/export_product_package_to_step.md b/docs/skill/references/docs/api/export_product_package_to_step.md index 6882ff1a..235d85f1 100644 --- a/docs/skill/references/docs/api/export_product_package_to_step.md +++ b/docs/skill/references/docs/api/export_product_package_to_step.md @@ -3,7 +3,7 @@ ## API Definition ```python -def export_product_package_to_step(data: ProductPackageInput, output_path: str | Path) -> ProductSTEPExportReport +def export_product_package_to_step(data: ProductInput, output_path: str | Path) -> ProductSTEPExportReport ``` *Source: exporter/step.py* @@ -14,4 +14,5 @@ def export_product_package_to_step(data: ProductPackageInput, output_path: str | ## Description -Export a validated `.scadpkg` closure as XCAF-backed AP242 STEP. +Export a product as XCAF-backed AP242 STEP: a validated `.scadpkg` closure (package, bytes or path), or the result of +a build (``@scad.part`` / ``@scad.assemble``) or its definition, which is packaged in memory without a scene. diff --git a/docs/skill/references/docs/api/export_product_package_to_stl.md b/docs/skill/references/docs/api/export_product_package_to_stl.md index 63c9f908..31d8e512 100644 --- a/docs/skill/references/docs/api/export_product_package_to_stl.md +++ b/docs/skill/references/docs/api/export_product_package_to_stl.md @@ -3,7 +3,7 @@ ## API Definition ```python -def export_product_package_to_stl(data: ProductPackageInput, output_path: str | Path, *, linear_deflection: float = 0.1, angular_deflection_degrees: float = 20.0, relative: bool = False) -> ProductSTLExportReport +def export_product_package_to_stl(data: ProductInput, output_path: str | Path, *, linear_deflection: float = 0.1, angular_deflection_degrees: float = 20.0, relative: bool = False) -> ProductSTLExportReport ``` *Source: exporter/stl.py* diff --git a/docs/skill/references/domains/export-and-translation.md b/docs/skill/references/domains/export-and-translation.md index 1e72bbea..6b9d3242 100644 --- a/docs/skill/references/domains/export-and-translation.md +++ b/docs/skill/references/domains/export-and-translation.md @@ -60,8 +60,10 @@ Read the exact page under `references/docs/api/` for every API used: ## Export rules -- Translate only from a validated `.scadpkg` package: `capture` the product - first, then export from the package path. +- Translate only from a validated product package: `capture` the product + first, then export from the package path. In a one-shot script (below) the + exporters also take the build result itself and package it in memory, with + the same validation and without the scene projection nobody reads. - STL and OBJ share one direct OpenCASCADE tessellation of the evaluated BREP; both contain the same oriented triangles. Control curved-surface accuracy with `linear_deflection` (chordal deviation in product units) and @@ -75,6 +77,38 @@ Read the exact page under `references/docs/api/` for every API used: - Use `validate_step_roundtrip_rdescriptor` when a STEP export must survive re-import (roundtrip-sensitive consumers). +## One-shot script (no notebook, no `.scadpkg` on disk) + +For a batch or unattended task whose deliverable is "one script, run as +`python model.py --output part.step`" (read-only source directory, no editor), +a plain script is the supported entry. A notebook is still the form for +anything a person will open, edit or reuse (core rule R3). The runtime keeps +no state next to the source when it runs without the cache. + +```python +import argparse +from pathlib import Path + +import simplecadapi as scad + + +@scad.part(id="plate", project_root=Path(__file__).parent) +def build_plate() -> scad.Part: + body = scad.make_box_rsolid(width=120.0, height=120.0, depth=10.0) + return scad.make_part_rpart("plate", body) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--output", required=True) + args = parser.parse_args() + report = scad.exporter.export_product_package_to_step(build_plate(), args.output) + print(report.output_path, report.definition_ids) +``` + +A notebook run unattended in a read-only directory uses +`run_notebook(path, cache=False)`. + ## Validation gates - Read the returned export report and print its actual fields per format diff --git a/src/simplecadapi/exporter/_surface_mesh.py b/src/simplecadapi/exporter/_surface_mesh.py index 9d27c4c3..501cdc6d 100644 --- a/src/simplecadapi/exporter/_surface_mesh.py +++ b/src/simplecadapi/exporter/_surface_mesh.py @@ -10,7 +10,7 @@ from ..artifacts.assembly_io import materialize_definition from simplecadapi._cpp import Assembly, make_compound_from_assembly_rcompound, Part from ..translator.package_units import ( - ProductPackageInput, + ProductInput, read_product_package_translation_units, ) @@ -122,7 +122,7 @@ def tessellate_solid( def tessellate_product_surface( - data: ProductPackageInput, + data: ProductInput, *, linear_deflection: float, angular_deflection_degrees: float, diff --git a/src/simplecadapi/exporter/mjcf.py b/src/simplecadapi/exporter/mjcf.py index 455df56f..e8aa7f21 100644 --- a/src/simplecadapi/exporter/mjcf.py +++ b/src/simplecadapi/exporter/mjcf.py @@ -20,7 +20,7 @@ from simplecadapi._cpp import Assembly, Part from ..product.placement import Placement, relative_placement from ..translator.package_units import ( - ProductPackageInput, + ProductInput, read_product_package_translation_units, ) from ._surface_mesh import tessellate_solid @@ -339,7 +339,7 @@ def _part_materials(units: tuple[Any, ...]) -> dict[str, tuple[Part, float, bool def export_product_package_to_mjcf( - data: ProductPackageInput, + data: ProductInput, output_path: str | Path, *, mesh_directory: str | Path | None = None, diff --git a/src/simplecadapi/exporter/obj.py b/src/simplecadapi/exporter/obj.py index 07f77fcc..b917c7a4 100644 --- a/src/simplecadapi/exporter/obj.py +++ b/src/simplecadapi/exporter/obj.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from pathlib import Path -from ..translator.package_units import ProductPackageInput +from ..translator.package_units import ProductInput from ._surface_mesh import tessellate_product_surface @@ -26,7 +26,7 @@ class ProductOBJExportReport: def export_product_package_to_obj( - data: ProductPackageInput, + data: ProductInput, output_path: str | Path, *, linear_deflection: float = 0.1, diff --git a/src/simplecadapi/exporter/step.py b/src/simplecadapi/exporter/step.py index d8a823a7..b421ad56 100644 --- a/src/simplecadapi/exporter/step.py +++ b/src/simplecadapi/exporter/step.py @@ -13,7 +13,7 @@ from ..artifacts import materialize_definition from simplecadapi._cpp import Assembly, Part from ..translator.package_units import ( - ProductPackageInput, + ProductInput, read_product_package_translation_units, ) @@ -152,10 +152,11 @@ def visit(value: Part | Assembly) -> str: def export_product_package_to_step( - data: ProductPackageInput, + data: ProductInput, output_path: str | Path, ) -> ProductSTEPExportReport: - """Export a validated `.scadpkg` closure as XCAF-backed AP242 STEP.""" + """Export a product as XCAF-backed AP242 STEP: a validated `.scadpkg` closure (package, bytes or path), or the result of + a build (``@scad.part`` / ``@scad.assemble``) or its definition, which is packaged in memory without a scene.""" import simplecadapi._cpp as native diff --git a/src/simplecadapi/exporter/stl.py b/src/simplecadapi/exporter/stl.py index ae8fab83..f1bcca9b 100644 --- a/src/simplecadapi/exporter/stl.py +++ b/src/simplecadapi/exporter/stl.py @@ -8,7 +8,7 @@ import numpy as np -from ..translator.package_units import ProductPackageInput +from ..translator.package_units import ProductInput from ._surface_mesh import TessellatedSurfaceMesh, tessellate_product_surface @@ -51,7 +51,7 @@ def _write_binary_stl(path: Path, mesh: TessellatedSurfaceMesh) -> None: def export_product_package_to_stl( - data: ProductPackageInput, + data: ProductInput, output_path: str | Path, *, linear_deflection: float = 0.1, diff --git a/src/simplecadapi/runtime/deps.py b/src/simplecadapi/runtime/deps.py index fbf691e2..1a5a1c5d 100644 --- a/src/simplecadapi/runtime/deps.py +++ b/src/simplecadapi/runtime/deps.py @@ -99,7 +99,7 @@ def state_dir(config: NotebookConfig) -> Path: @contextmanager -def exclusive(config: NotebookConfig) -> Iterator[None]: +def exclusive(config: NotebookConfig, *, enabled: bool = True) -> Iterator[None]: """Hold the notebook's state directory against other processes. Worker processes may run the same notebook at once (two notebooks that @@ -107,9 +107,13 @@ def exclusive(config: NotebookConfig) -> Iterator[None]: lock is between processes only: a run of a notebook this process is already running (a ``use`` cycle, an editor next to a headless run) goes on without it, as it always did. Without ``fcntl`` (Windows) it is a - no-op. + no-op. With ``enabled=False`` (a run that keeps no state: nothing to + prune, nothing to protect) it is a no-op too, and creates nothing. """ + if not enabled: + yield + return directory = state_dir(config) key = str(directory) with _held_lock: diff --git a/src/simplecadapi/runtime/runner.py b/src/simplecadapi/runtime/runner.py index 5d17e614..b1007e13 100644 --- a/src/simplecadapi/runtime/runner.py +++ b/src/simplecadapi/runtime/runner.py @@ -170,7 +170,7 @@ def run() -> NotebookRun: # Errors still come through. "marimo" is the logger every marimo # module shares (lazy.py's record shows its file name, "lazy", which # is not the logger's name). - with suppress_warnings_logs("marimo"), exclusive(config): + with suppress_warnings_logs("marimo"), exclusive(config, enabled=cache): return _run(config, values, cache=cache, through=through, product=product) # marimo keeps its runtime context per thread, and a notebook run needs @@ -322,7 +322,10 @@ def _run( # Cells restored from the cache ran no ``use`` this time; the files they # depend on are among those the previous run recorded. recorded = frozenset(used) | (previous if restored else frozenset()) - write_dependencies(config, recorded) + if cache: + # The record belongs to the cache: its entries are keyed on the files it lists, so a run that keeps no entries must + # not move it (and a run without the cache must work where the source directory is read-only, issue #89). + write_dependencies(config, recorded) dependencies = module_files | recorded if cache and cell_cache is not None: # A cell that runs a child notebook depends on the record just written: diff --git a/src/simplecadapi/translator/package_units.py b/src/simplecadapi/translator/package_units.py index 445666f2..8cd131cc 100644 --- a/src/simplecadapi/translator/package_units.py +++ b/src/simplecadapi/translator/package_units.py @@ -11,6 +11,7 @@ from ..artifacts.part_definition import PartDefinition from ..product.packages import ( ProductPackage, + build_product_package, read_product_package, validate_product_package, ) @@ -18,6 +19,8 @@ Definition = PartDefinition | AssemblyDefinition ProductPackageInput = ProductPackage | bytes | bytearray | memoryview | str | Path +# What an exporter takes besides a package: the result of a build (``@scad.part`` / ``@scad.assemble``) or its definition. +ProductInput = ProductPackageInput | Definition | Any @dataclass(frozen=True, slots=True) @@ -70,15 +73,22 @@ def model_payload(self) -> dict[str, Any]: def read_product_package_translation_units( - data: ProductPackageInput, + data: ProductInput, ) -> tuple[ProductPackage, tuple[ProductPackageTranslationUnit, ...]]: - """Return a validated package and dependency-first definition units.""" + """Return a validated package and dependency-first definition units. + + *data* is a package, the bytes or path of one, or (issue #89) the result of a build or a definition: a script that + only wants a STEP file need not write a ``.scadpkg`` first. That package is built in memory without the scene + projection (tessellated meshes), which no exporter reads and which took most of the time of a default capture. + """ if isinstance(data, ProductPackage): validate_product_package(data) package = data - else: + elif isinstance(data, (bytes, bytearray, memoryview, str, Path)): package = read_product_package(data) + else: + package = build_product_package(data, include_scene=False) ordered: list[Definition] = [] visited: set[tuple[str, str]] = set() @@ -122,5 +132,6 @@ def visit(definition: Definition) -> None: __all__ = [ "ProductPackageInput", "ProductPackageTranslationUnit", + "ProductInput", "read_product_package_translation_units", ] diff --git a/test/test_notebook_runtime.py b/test/test_notebook_runtime.py index f87d21d3..755616dd 100644 --- a/test/test_notebook_runtime.py +++ b/test/test_notebook_runtime.py @@ -13,6 +13,7 @@ import importlib.metadata import io import json +import os import pickle import re import sys @@ -326,6 +327,33 @@ def test_without_the_cache_nothing_is_stored(self) -> None: self.assertFalse((state_dir(run.config) / "cache").exists()) self.assertEqual(self.statuses(run_notebook(self.bracket)), ["ran"] * 4) + def test_without_the_cache_the_source_directory_is_left_alone(self) -> None: + """Issue #89: a run that keeps no state must not create the state directory (``__marimo__``) next to the source, + so a notebook can run where the source directory is read-only (a container's ``-v src:/src:ro``).""" + + run_notebook(self.bracket, cache=False) + self.assertFalse((self.root / "__marimo__").exists()) + self.assertFalse(state_dir(read_notebook_config(self.bracket)).exists()) + + @unittest.skipUnless( + os.name == "posix" and hasattr(os, "geteuid") and os.geteuid() != 0, "needs a non-root POSIX user to make a directory read-only" + ) + def test_without_the_cache_a_read_only_source_directory_runs(self) -> None: + directories = [self.root, self.root / "lib"] + modes = [directory.stat().st_mode for directory in directories] + for directory in directories: + directory.chmod(0o555) + try: + run = run_notebook(self.bracket, cache=False) + finally: + for directory, mode in zip(directories, modes): + directory.chmod(mode) + self.assertEqual(run.definition.definition_id, "bracket") + + def test_with_the_cache_the_dependency_record_is_still_written(self) -> None: + run = run_notebook(self.bracket) + self.assertTrue((state_dir(run.config) / "deps.json").is_file()) + def test_overrides_skip_the_defining_cell(self) -> None: plain = run_notebook(self.bracket) wide = run_notebook(self.bracket, overrides={"width": 30.0}) diff --git a/test/test_product_exporter.py b/test/test_product_exporter.py index 11cd34ae..6999a9db 100644 --- a/test/test_product_exporter.py +++ b/test/test_product_exporter.py @@ -147,7 +147,7 @@ def build_root() -> scad.Assembly: return scad.build_product_package(build_root()) -def _build_cylinder_package(): +def _build_cylinder_result(): @scad.part( id="cylinder", project_root=Path(__file__).parent, @@ -156,7 +156,11 @@ def build_cylinder() -> scad.Part: body = scad.make_cylinder_rsolid(radius=4.0, height=8.0) return scad.make_part_rpart("cylinder", body) - return scad.build_product_package(build_cylinder()) + return build_cylinder() + + +def _build_cylinder_package(): + return scad.build_product_package(_build_cylinder_result()) class TestProductExporter(unittest.TestCase): @@ -182,6 +186,39 @@ def test_exporters_are_available_only_from_exporter_namespace(self): self.assertFalse(hasattr(scad, "export_obj")) self.assertFalse(hasattr(scad.translator, "ap242_translator")) + def test_a_build_result_exports_in_one_step_without_a_scene(self): + """Issue #89: a script that only wants a file need not capture a ``.scadpkg`` first, nor pay for the scene + (the tessellated projection no exporter reads).""" + + from unittest import mock + + from simplecadapi.product import packages + + result = _build_cylinder_result() + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + with mock.patch.object(packages, "_scene_projection", side_effect=AssertionError("the scene was built")): + step = scad.exporter.export_product_package_to_step(result, root / "one.step") + stl = scad.exporter.export_product_package_to_stl(result, root / "one.stl") + self.assertEqual(step.definition_ids, ("cylinder",)) + self.assertGreater((root / "one.step").stat().st_size, 0) + self.assertGreater((root / "one.stl").stat().st_size, 0) + self.assertEqual(stl.output_path, (root / "one.stl").resolve()) + + def test_a_definition_exports_like_its_build_result(self): + result = _build_cylinder_result() + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + from_result = scad.exporter.export_product_package_to_step(result, root / "a.step") + from_definition = scad.exporter.export_product_package_to_step(result.definition, root / "b.step") + self.assertEqual(from_result.definition_ids, from_definition.definition_ids) + self.assertEqual(from_result.metadata_item_count, from_definition.metadata_item_count) + + def test_a_value_that_is_no_product_is_rejected(self): + with tempfile.TemporaryDirectory() as tmp_dir: + with self.assertRaises(TypeError): + scad.exporter.export_product_package_to_step(object(), Path(tmp_dir) / "x.step") + def test_nested_package_preserves_ap242_product_structure_and_placements(self): with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir)