diff --git a/CHANGELOG.md b/CHANGELOG.md index 45904ea..28fd320 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,26 @@ user-facing notes live in [`docs/updates/`](docs/updates/) (English and ## Unreleased +## 2.2.6 — 2026-10-19 + +### Added + +- Editor: `sca cell open NOTEBOOK` switches the running editor to another notebook of the + workspace and waits for its kernel, so the following `sca cell` writes go through instead + of refusing. The write commands take `--open` to switch and write in one step + (`--wait`, or `SCA_EDITOR_OPEN_WAIT`, raises the boot wait; default 120s). `/api/cells` + reports `kernel` so a caller can tell a booted notebook from one still opening. +- Editor: `sca viewport [WORKSPACE] [--out PATH]` reports where the viewport image lives + (path, age, size) and copies it out, instead of reading `.sca-editor/viewport.png` by hand. + +### Changed + +- Cache: `cryptography` ships as a dependency, so the notebook cache keeps marimo's + manifest signing and verification on instead of silently degrading to unsigned, + unverified entries (the "LazyLoader verification='on' degraded to 'off'" warning). +- Editor: a refused write now names `sca cell open NOTEBOOK` (or `--open`) as the way out, + and the bundled skill's editor-collaboration reference says so. + ## 2.2.5 — 2026-10-15 ### Fixed diff --git a/README.md b/README.md index 35adeac..ca41bcb 100644 --- a/README.md +++ b/README.md @@ -149,21 +149,20 @@ the full crank cycle sweeps clean in the same sim · --- -## Update Notes (2.2.5) +## Update Notes (2.2.6) > Validate generated definitions, assembly constraints, and manufacturing > geometry before production use. -- **`sca cell` works behind container proxies.** The editor's discovery record is loopback and - proxy-proof, an unreachable record is reported instead of silently ignored, and file - fallbacks say so on stderr. -- **Cell feedback carries the chain.** put/insert/run return console output and every cell - left unhealthy, stale dependents are run, and a failing cell names its upstream. -- **Validate a stage headlessly.** `sca run --through N / --no-product / --timeout`. -- **Quieter output, shorter prints.** Cache and VTK chatter is gone; a printed - `BRepInspection` is a one-line summary. +- **Switch notebooks without leaving the CLI.** `sca cell open NOTEBOOK` moves the editor + and waits for its kernel; the write commands take `--open` to switch and write in one + step, and a refused write names the way out. +- **`sca viewport` answers what the person sees.** The image's path, age and size, copied + out with `--out`, instead of reading `.sca-editor/viewport.png` by hand. +- **Cache entries are signed and verified again.** `cryptography` ships as a dependency, + so marimo's cache verification no longer silently degrades to off. -Full notes for this version: [`docs/updates/2.2.5.md`](docs/updates/2.2.5.md). +Full notes for this version: [`docs/updates/2.2.6.md`](docs/updates/2.2.6.md). Earlier versions: the [changelog](CHANGELOG.md) and [`docs/updates/`](docs/updates/). --- @@ -184,7 +183,7 @@ in a compact public API for creating solids, applying features, tagging semantic intent, querying topology, exporting manufacturing files, and translating recorded models into FreeCAD workflows. -Current release: `simplecadapi==2.2.5`. +Current release: `simplecadapi==2.2.6`. ## What It Provides @@ -699,6 +698,7 @@ and the [STEP reconstruction workflow](docs/skill/references/workflows/step-reco ## Documentation +- 2.2.6 update notes: [`docs/updates/2.2.6.md`](docs/updates/2.2.6.md) - 2.2.5 update notes: [`docs/updates/2.2.5.md`](docs/updates/2.2.5.md) - 2.2.4 update notes: [`docs/updates/2.2.4.md`](docs/updates/2.2.4.md) - 2.2.3 update notes: [`docs/updates/2.2.3.md`](docs/updates/2.2.3.md) diff --git a/README.zh-CN.md b/README.zh-CN.md index 1c76f52..d3c4a00 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -133,18 +133,17 @@ CalculiX 静力 FEM(边界面按保真的 `interface.*` 标签选取,仿真 --- -## 更新日志(2.2.5) +## 更新日志(2.2.6) > 投入生产前请验证生成的定义、装配约束和制造几何。 -- **`sca cell` 在容器代理后面也能用。** 编辑器发现记录钉在回环地址并绕过代理,应答不了的记录会报出来 - 而不是被静默忽略,降级读文件会在 stderr 上说明。 -- **cell 反馈带着整条链。** put/insert/run 返回 console 输出和每个不健康的 cell,stale 依赖会被运行, - 出错 cell 会列出上游。 -- **按阶段做无头校验。** `sca run --through N / --no-product / --timeout`。 -- **更安静的输出、更短的打印。** 缓存与 VTK 噪声消失,打印 `BRepInspection` 是一行摘要。 +- **不离开 CLI 就能切换 notebook。** `sca cell open NOTEBOOK` 切换编辑器并等 kernel 就绪; + 写命令加 `--open` 切换 + 写入一步完成,被拒绝的写入会直接指路。 +- **`sca viewport` 直接回答"用户看到什么"。** 图片的路径、age、大小,`--out` 复制出去, + 不用手读 `.sca-editor/viewport.png`。 +- **缓存条目重新有了签名验证。** `cryptography` 随包安装,marimo 缓存验证不再静默降级。 -本版本的完整说明:[`docs/updates/2.2.5.zh-CN.md`](docs/updates/2.2.5.zh-CN.md)。 +本版本的完整说明:[`docs/updates/2.2.6.zh-CN.md`](docs/updates/2.2.6.zh-CN.md)。 更早的版本:见[更新日志](CHANGELOG.md)和 [`docs/updates/`](docs/updates/)。 --- @@ -162,7 +161,7 @@ CalculiX 静力 FEM(边界面按保真的 `interface.*` 标签选取,仿真 SimpleCADAPI 是一个基于 OCP 的 Python CAD SDK,提供清晰的函数式建模操作和可重放的模型图。它在 OpenCascade 几何内核之上提供精简的公共 API,可用于创建实体、应用特征、添加语义标签、查询拓扑、导出制造文件,以及将记录的模型转换为 FreeCAD 工作流。 -当前版本:`simplecadapi==2.2.5`。 +当前版本:`simplecadapi==2.2.6`。 ## 核心能力 @@ -588,6 +587,7 @@ uv run --extra fem python examples/ap242_gmsh_volume_mesh/run_calculix.py ## 文档 +- 2.2.6 更新说明:[`docs/updates/2.2.6.zh-CN.md`](docs/updates/2.2.6.zh-CN.md) - 2.2.5 更新说明:[`docs/updates/2.2.5.zh-CN.md`](docs/updates/2.2.5.zh-CN.md) - 2.2.4 更新说明:[`docs/updates/2.2.4.zh-CN.md`](docs/updates/2.2.4.zh-CN.md) - 2.2.3 更新说明:[`docs/updates/2.2.3.zh-CN.md`](docs/updates/2.2.3.zh-CN.md) diff --git a/docs/skill/SKILL.md b/docs/skill/SKILL.md index b1798fe..e120c54 100644 --- a/docs/skill/SKILL.md +++ b/docs/skill/SKILL.md @@ -4,9 +4,9 @@ description: Build, assemble, inspect, reconstruct, and export parametric CAD mo license: Apache-2.0 metadata: project: simplecadapi - version: 2.2.5 + version: 2.2.6 package-name: simplecadapi - package-version: 2.2.5 + package-version: 2.2.6 --- # SimpleCAD SDK Skill diff --git a/docs/skill/references/domains/editor-collaboration.md b/docs/skill/references/domains/editor-collaboration.md index fc32156..841de78 100644 --- a/docs/skill/references/domains/editor-collaboration.md +++ b/docs/skill/references/domains/editor-collaboration.md @@ -63,6 +63,7 @@ echo 'L = 90' | sca cell put 3 # replace the code of cell 3 and run it (-- sca cell insert --after 3 -f new.py # add a cell (--after start|end|CELL, --name fn_name) sca cell rm 4 sca cell run [CELL ...] # run cells (default all) and what they feed +sca cell open other.py # have the editor open another notebook (waits for its kernel) sca new bracket --kind part # a new notebook; the open editor lists it, the person's view stays ``` @@ -74,7 +75,8 @@ sca new bracket --kind part # a new notebook; the open editor lists it, replaces the cell, so `get` first when the person may have changed it. - The commands find the running editor through `/.sca-editor/server.json`. With no editor running, or with another notebook open, `ls` and `get` read the file and the write - commands refuse: then edit the file directly, nothing else holds it. + commands refuse: run `sca cell open NOTEBOOK` (or pass `--open` to switch and write in one + step), or edit the file directly, nothing else holds it. - `@app.function` cells cannot be changed this way; edit them in the file. ## Read what the person is doing @@ -83,7 +85,7 @@ sca new bracket --kind part # a new notebook; the open editor lists it, | --- | --- | | What is selected | the cell headed `# ---- selection ----` in the notebook file | | Everything at once (selection, cell states, errors, parameter values) | `sca snapshot [WORKSPACE]`, or `/.sca-editor/snapshot.json` | -| What the viewport looks like | `/.sca-editor/viewport.png` (a PNG; look at it) | +| What the viewport looks like | `sca viewport [WORKSPACE] [--out PATH]` — the PNG's path, age and size, copied out with `--out` (the file is `/.sca-editor/viewport.png`) | | Which notebook is open | `notebook` in the snapshot | `sca snapshot` only has an answer while the editor runs; it reports the last diff --git a/docs/updates/2.2.6.md b/docs/updates/2.2.6.md new file mode 100644 index 0000000..63eb985 --- /dev/null +++ b/docs/updates/2.2.6.md @@ -0,0 +1,44 @@ +# 2.2.6 Update Notes + +From the same field report as 2.2.5, the follow-up: an agent working a +multi-notebook workspace no longer has to leave the CLI to keep working — and the +cache it relies on is verified again. + +## Switch notebooks without leaving the CLI + +An agent editing `l_link.py`, `l_link_shell.py`, and an assembly had exactly one way +to move the editor between them: a raw `curl` to `/api/workspace/open`, an endpoint +found by reading the installed server's source — because a write refuses while +another notebook is open, and no command could switch. Now: + +```bash +sca cell open other.py # the editor switches; waits for the kernel to boot +echo '...' | sca cell put 3 --open # switch and write in one step +``` + +`sca cell open` waits until the switched notebook's kernel is up +(`SCA_EDITOR_OPEN_WAIT` or `--wait`, default 120s), so the following write does not +race the boot. Re-asking for the notebook that is already open and ready does not +restart the kernel. A refused write names the command as the remedy, and +`/api/cells` reports `kernel` so a caller can tell a booted notebook from one whose +name has flipped while the kernel still boots. + +## See the viewport as a command + +```bash +sca viewport --out /tmp/seen.png # path, age in seconds, size; copied out +``` + +The image of what the person sees is now `sca viewport`'s answer instead of a +`.sca-editor/viewport.png` file agents had to know about and read by hand. The age +says how fresh the view is; there is no image until the page showed a model at +least once. + +## Cache entries are signed and verified again + +The notebook cache is marimo's `LazyLoader`, whose verification is on by default: +without the `cryptography` package it degraded to off and every run warned that +cache entries were neither signed nor verified. `cryptography` ships as a +dependency now, so verification stays on everywhere the cache works. + +See the [changelog](../../CHANGELOG.md) for the complete list. diff --git a/docs/updates/2.2.6.zh-CN.md b/docs/updates/2.2.6.zh-CN.md new file mode 100644 index 0000000..a2fad8b --- /dev/null +++ b/docs/updates/2.2.6.zh-CN.md @@ -0,0 +1,39 @@ +# 2.2.6 更新说明 + +和 2.2.5 来自同一份实地报告的续篇:agent 在多 notebook 工作区里干活,不用再绕开 CLI +才能继续——它依赖的缓存也重新有了签名验证。 + +## 不离开 CLI 就能切换 notebook + +一个 agent 要同时编辑 `l_link.py`、`l_link_shell.py` 和一个装配体,把编辑器在它们之间 +切换的办法只有一个:对着 `/api/workspace/open` 发裸 `curl`——这个端点是它读了安装包里 +server 的源码才找到的,因为编辑器开着别的 notebook 时写入会被拒绝,而没有任何命令能切换。 +现在: + +```bash +sca cell open other.py # 编辑器切换过去;等 kernel 启动完成 +echo '...' | sca cell put 3 --open # 切换 + 写入一步完成 +``` + +`sca cell open` 会等到切换后的 notebook kernel 就绪(`SCA_EDITOR_OPEN_WAIT` 或 +`--wait`,默认 120s),后续写入不会和启动赛跑。对已经打开且就绪的 notebook 再次执行 +不会重启 kernel。写入被拒绝时报错会直接给出这条命令,`/api/cells` 也会报告 `kernel`, +让调用方分得清"名字已切换"和"kernel 还在启动"。 + +## 像命令一样拿视口图 + +```bash +sca viewport --out /tmp/seen.png # 路径、age(秒)、大小;复制出来 +``` + +"用户现在看到什么"的图,现在是 `sca viewport` 的输出,而不是一个要 agent 自己知道、 +自己读的 `.sca-editor/viewport.png` 文件。age 说明画面有多新鲜;页面从未显示过模型时 +没有图。 + +## 缓存条目重新有了签名验证 + +notebook 缓存是 marimo 的 `LazyLoader`,默认开着验证:缺 `cryptography` 包时会降级关 +闭,每次运行都警告"cache entries are neither signed nor verified"。现在 `cryptography` +作为依赖随包安装,缓存能工作的地方验证就开着。 + +完整清单见[更新日志](../../CHANGELOG.md)。 diff --git a/pyproject.toml b/pyproject.toml index 54c071b..719f257 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "simplecadapi" -version = "2.2.5" +version = "2.2.6" description = "A simplified OCP-native CAD modeling Python API" readme = "README.md" license = { file = "LICENSE" } @@ -35,6 +35,7 @@ dependencies = [ "ezdxf>=1.4.4", "marimo>=0.25.0", "plotly>=7.1.0", + "cryptography>=42.0.0", ] [project.optional-dependencies] diff --git a/src/simplecadapi/cli.py b/src/simplecadapi/cli.py index 900b6d8..9b5f564 100644 --- a/src/simplecadapi/cli.py +++ b/src/simplecadapi/cli.py @@ -12,6 +12,7 @@ sca edit [WORKSPACE] # the web editor (needs the 'editor' extra) sca example [NAME] [DIR] # copy a bundled example workspace out sca snapshot [WORKSPACE] # what the editor user has selected / sees + sca viewport [WORKSPACE] # the image of what the editor user sees Shared contract: group handlers return ``(report, exit_code)`` and are followed by the group's own printer (human-readable for init/addon, JSON diff --git a/src/simplecadapi/editor/cli.py b/src/simplecadapi/editor/cli.py index 22acf6e..a199c58 100644 --- a/src/simplecadapi/editor/cli.py +++ b/src/simplecadapi/editor/cli.py @@ -6,7 +6,9 @@ sca edit path/to/model.py # ... starting at one notebook sca edit --port 7180 --no-browser sca snapshot [WORKSPACE] # what the person has selected / is looking at + sca viewport [WORKSPACE] [--out PATH] # the image of what the person sees sca cell ls|get|put|insert|rm|run # edit the cells of the notebook the editor has open + sca cell open NOTEBOOK [--wait S] # have the editor open another notebook sca new NAME [--kind part|assembly] # a new notebook; the editor lists it at once """ @@ -45,6 +47,19 @@ def configure(subparsers: argparse._SubParsersAction) -> None: snapshot.add_argument("workspace", type=Path, nargs="?", default=Path.cwd()) snapshot.set_defaults(handler=_snapshot, printer=_print_snapshot) + viewport = subparsers.add_parser( + "viewport", + help="the image of what the editor user sees, to look at or copy out", + description="Locate the editor's viewport image (the PNG of what the person sees, " + "kept in WORKSPACE/.sca-editor/ while the editor runs), tell its path, age and " + "size, and with --out copy it somewhere an agent's image reader reaches. A stale " + "age means the page has not redrawn since then; there is no image until the page " + "showed a model at least once.", + ) + viewport.add_argument("workspace", type=Path, nargs="?", default=Path.cwd()) + viewport.add_argument("--out", type=Path, default=None, help="copy the PNG to this path") + viewport.set_defaults(handler=_viewport, printer=_print_json) + cell = subparsers.add_parser( "cell", help="read and edit the cells of a notebook through the running editor", @@ -52,7 +67,8 @@ def configure(subparsers: argparse._SubParsersAction) -> None: "shows the change live (no reload) and the editor, the one writer of the file, saves " "it, so nothing is overwritten. Cells are named by number (see `sca cell ls`), by key " "or by id. When the editor is not running, or has another notebook open, `ls` and " - "`get` read the file and the write commands refuse: edit the file directly then, " + "`get` read the file and the write commands refuse: `sca cell open NOTEBOOK` switches " + "(or pass --open to switch and write in one step), or edit the file directly then — " "nothing else holds it.", ) cell_sub = cell.add_subparsers(dest="cell_command", required=True) @@ -61,6 +77,19 @@ def common(parser: argparse.ArgumentParser) -> None: parser.add_argument("--notebook", "-n", type=Path, default=None, help="the notebook (default: the one the editor has open)") parser.add_argument("--workspace", "-w", type=Path, default=None, help="the workspace the editor serves (default: found from the current directory)") + def writable(parser: argparse.ArgumentParser) -> None: + common(parser) + parser.add_argument( + "--open", action="store_true", + help="when the editor has another notebook open, open this one first " + "(the editor switches and the notebook's kernel restarts)", + ) + parser.add_argument( + "--wait", type=float, default=None, + help="seconds to wait for a notebook opened by --open to be ready " + "(default: SCA_EDITOR_OPEN_WAIT or 120)", + ) + ls = cell_sub.add_parser("ls", help="list the cells with their state") common(ls) ls.add_argument("--code", action="store_true", help="include each cell's code") @@ -70,26 +99,37 @@ def common(parser: argparse.ArgumentParser) -> None: get.add_argument("cell") get.set_defaults(handler=_cell_get, printer=_print_json) put = cell_sub.add_parser("put", help="replace a cell's code (the code comes from --file or stdin) and run it") - common(put) + writable(put) put.add_argument("cell") put.add_argument("--file", "-f", type=Path, default=None, help="read the code from this file (default: stdin)") put.add_argument("--no-run", action="store_true", help="save the code without running the cell") put.set_defaults(handler=_cell_put, printer=_print_json) insert = cell_sub.add_parser("insert", help="add a cell (the code comes from --file or stdin)") - common(insert) + writable(insert) insert.add_argument("--after", default="end", help="a cell, or `start` / `end` (default)") insert.add_argument("--name", default=None, help="the cell's function name") insert.add_argument("--file", "-f", type=Path, default=None) insert.add_argument("--no-run", action="store_true") insert.set_defaults(handler=_cell_insert, printer=_print_json) rm = cell_sub.add_parser("rm", help="delete a cell") - common(rm) + writable(rm) rm.add_argument("cell") rm.set_defaults(handler=_cell_rm, printer=_print_json) run = cell_sub.add_parser("run", help="run cells (default: all) and what they feed") - common(run) + writable(run) run.add_argument("cells", nargs="*") run.set_defaults(handler=_cell_run, printer=_print_json) + open_ = cell_sub.add_parser( + "open", + help="have the running editor open another notebook of this workspace", + description="Switch the editor to NOTEBOOK: the page shows it at once and its " + "kernel restarts, so `sca cell` writes go to it. Waits until the kernel is " + "up so a following write does not race the boot.", + ) + open_.add_argument("notebook", type=Path, help="the notebook file (workspace-relative, or absolute inside the workspace)") + open_.add_argument("--workspace", "-w", type=Path, default=None, help="the workspace the editor serves (default: found from the current directory)") + open_.add_argument("--wait", type=float, default=None, help="seconds to wait for the notebook's kernel (default: SCA_EDITOR_OPEN_WAIT or 120)") + open_.set_defaults(handler=_cell_open, printer=_print_json) new = subparsers.add_parser( "new", @@ -145,6 +185,37 @@ def _print_snapshot(report: dict[str, Any]) -> None: print(json.dumps(report, indent=2, ensure_ascii=False)) +def _viewport(args: argparse.Namespace) -> tuple[dict[str, Any], int]: + import time + + root = args.workspace.expanduser().resolve() + if root.is_file(): + root = root.parent + png = root / ".sca-editor" / "viewport.png" + if not png.is_file(): + # The record knows the editor's workspace even when this directory is + # one of its children; the image the person sees lives next to it. + found = _find_editor(root) + if found is not None: + png = found[0] / ".sca-editor" / "viewport.png" + if not png.is_file(): + raise ValueError( + f"{root}: no viewport image; is `sca edit` running with the page showing a model?" + ) + stat = png.stat() + report: dict[str, Any] = { + "path": str(png), + "bytes": stat.st_size, + "age_seconds": round(time.time() - stat.st_mtime, 1), + } + if args.out is not None: + target = args.out.expanduser().resolve() + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(png.read_bytes()) + report["copied_to"] = str(target) + return report, 0 + + # -- sca cell / sca new --------------------------------------------------------- @@ -247,6 +318,63 @@ def _call(url: str, path: str, body: dict | None = None, timeout: float | None = raise TimeoutError(f"the editor did not answer {path} within {seconds:g}s") from error +def _open_wait_seconds(wait: float | None) -> float: + """How long to wait for a switched notebook's kernel; ``--wait`` or + ``SCA_EDITOR_OPEN_WAIT`` seconds (a marimo boot takes a few).""" + import os + + return float(os.environ.get("SCA_EDITOR_OPEN_WAIT", "120")) if wait is None else float(wait) + + +def _wait_open(url: str, relative: str, wait: float | None = None) -> dict[str, Any]: + """Poll until the editor has *relative* open and its kernel is up. + + The switch itself lands in milliseconds; the kernel boot is the slow part, + and a write sent into it reads as "no kernel yet". Waiting here is what + makes `sca cell open` a safe step before a write instead of a race. + """ + import time + + seconds = _open_wait_seconds(wait) + deadline = time.monotonic() + seconds + while True: + report = _call(url, "/api/cells") + if report.get("notebook") == relative and report.get("kernel"): + return {"ok": True, "open": relative} + if time.monotonic() >= deadline: + return { + "ok": False, + "error": f"the editor is still opening {relative} after {seconds:g}s " + "(the switch was asked for; check `sca cell ls`; " + "SCA_EDITOR_OPEN_WAIT or --wait raises the time)", + } + time.sleep(0.25) + + +def _open_notebook(url: str, relative: str, wait: float | None = None) -> dict[str, Any]: + """Ask the editor to open *relative* and wait until it is writable.""" + current = _call(url, "/api/cells") + if current.get("notebook") == relative and current.get("kernel"): + return {"ok": True, "open": relative, "already": True} + report = _call(url, "/api/workspace/open", {"path": relative}) + if not report.get("ok"): + return report + return _wait_open(url, relative, wait) + + +def _cell_open(args: argparse.Namespace) -> tuple[dict[str, Any], int]: + start = (args.workspace or Path.cwd()).expanduser().resolve() + found = _find_editor(start) + if found is None: + raise ValueError("no editor is running on this workspace (start `sca edit`)") + workspace, url = found + notebook = args.notebook.expanduser().resolve() + if not notebook.is_relative_to(workspace): + raise ValueError(f"{args.notebook} is not in the editor's workspace ({workspace})") + report = _open_notebook(url, notebook.relative_to(workspace).as_posix(), wait=args.wait) + return report, 0 if report.get("ok") else 1 + + def _session(args: argparse.Namespace, *, write: bool) -> tuple[str | None, Path | None, str]: """``(editor url or None, notebook path or None, relative notebook name)`` for a cell command.""" start = args.workspace or (args.notebook.parent if args.notebook else Path.cwd()) @@ -278,9 +406,15 @@ def _session(args: argparse.Namespace, *, write: bool) -> tuple[str | None, Path if relative == open_path: return url, notebook, relative if write: + if getattr(args, "open", False): + report = _open_notebook(url, relative, wait=getattr(args, "wait", None)) + if report.get("ok"): + return url, workspace / relative, relative + raise ValueError(report.get("error") or f"could not open {relative} in the editor") raise ValueError( - f"the editor has {open_path} open, not {relative}: edit {relative} directly " - "(the editor does not hold it), or open it in the editor first" + f"the editor has {open_path} open, not {relative}: run `sca cell open {relative}` " + f"(or pass --open to switch and write in one step), or edit {relative} directly " + "— nothing else holds it" ) _warn(f"the editor has {open_path} open; reading {relative} from the file") return None, notebook, relative diff --git a/src/simplecadapi/editor/server.py b/src/simplecadapi/editor/server.py index 1015b14..f5efdd9 100644 --- a/src/simplecadapi/editor/server.py +++ b/src/simplecadapi/editor/server.py @@ -643,6 +643,10 @@ def cells_report(self, *, refresh: bool = True) -> dict: briefs.append(brief) return { "notebook": self.notebook.relative_to(self.workspace).as_posix() if self.notebook else None, + # Agents poll this after a switch: the name flips at once, the + # kernel is what makes a write go through instead of reading + # "no kernel yet". + "kernel": self.kernel is not None, "cells": briefs, } diff --git a/test/test_editor.py b/test/test_editor.py index d320189..12b3380 100644 --- a/test/test_editor.py +++ b/test/test_editor.py @@ -1260,3 +1260,171 @@ def test_a_failing_cell_names_its_upstream_and_the_failing_ancestor(self) -> Non self.assertEqual([row["failing"] for row in upstream], [False, False, True]) self.assertEqual([row["index"] for row in by_index[3]["upstream"]], [1, 2]) self.assertEqual([row["failing"] for row in by_index[3]["upstream"]], [False, False]) + + +class CellOpenAndViewportTest(unittest.TestCase): + """`sca cell open` / `--open`, and `sca viewport`: how an agent moves the + editor between notebooks and reaches what the person sees without reading + the server's source or calling its http api by hand.""" + + def setUp(self) -> None: + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.root = Path(tmp.name).resolve() + self.other = create_notebook(self.root, "other", "part") + + @staticmethod + def _fake_call(responses: list[dict]) -> object: + """A `_call` that answers from a list of canned bodies in order.""" + from unittest import mock + + return mock.patch( + "simplecadapi.editor.cli._call", side_effect=[dict(body) for body in responses] + ) + + def test_cell_open_switches_and_waits_for_the_kernel(self) -> None: + import argparse + from unittest import mock + + from simplecadapi.editor import cli + + args = argparse.Namespace(notebook=self.other, workspace=self.root, wait=5) + responses = [ + {"notebook": "gripper.py", "kernel": True}, # the editor has another notebook + {"ok": True, "opening": "other.py"}, # the switch was accepted + {"notebook": "other.py", "kernel": False}, # flipping, kernel still booting + {"notebook": "other.py", "kernel": True}, # ready + ] + with mock.patch("simplecadapi.editor.cli._find_editor", return_value=(self.root, "http://127.0.0.1:1")): + with self._fake_call(responses): + report, code = cli._cell_open(args) + self.assertEqual(code, 0) + self.assertEqual(report, {"ok": True, "open": "other.py"}) + + def test_cell_open_names_a_notebook_outside_the_workspace(self) -> None: + import argparse + import tempfile + from unittest import mock + + from simplecadapi.editor import cli + + with tempfile.TemporaryDirectory() as elsewhere: + args = argparse.Namespace(notebook=Path(elsewhere) / "elsewhere.py", workspace=self.root, wait=5) + with mock.patch("simplecadapi.editor.cli._find_editor", return_value=(self.root, "http://127.0.0.1:1")): + with self.assertRaisesRegex(ValueError, "not in the editor's workspace"): + cli._cell_open(args) + + def test_cell_open_without_an_editor_says_so(self) -> None: + import argparse + + from simplecadapi.editor import cli + + args = argparse.Namespace(notebook=self.other, workspace=self.root, wait=5) + with self.assertRaisesRegex(ValueError, "no editor is running"): + cli._cell_open(args) + + def test_a_write_with_open_switches_notebooks_in_one_step(self) -> None: + import argparse + from unittest import mock + + from simplecadapi.editor import cli + + def session(**kwargs: object) -> tuple: + args = argparse.Namespace( + notebook=self.other, workspace=self.root, open=kwargs.get("open", False), wait=5 + ) + with mock.patch("simplecadapi.editor.cli._find_editor", return_value=(self.root, "http://127.0.0.1:1")): + with self._fake_call(kwargs["responses"]): + return cli._session(args, write=True) + + # Without --open the write refuses, and the refusal names the way out. + with self.assertRaisesRegex(ValueError, r"sca cell open other\.py"): + session( + responses=[ + {"notebook": "gripper.py", "kernel": True}, + ] + ) + # With --open the editor switches first, then the write goes through. + url, notebook, name = session( + open=True, + responses=[ + {"notebook": "gripper.py", "kernel": True}, # refusal would fire here + {"notebook": "gripper.py", "kernel": True}, # the open's already-open check + {"ok": True, "opening": "other.py"}, + {"notebook": "other.py", "kernel": True}, + ] + ) + self.assertEqual(url, "http://127.0.0.1:1") + self.assertEqual(notebook, self.root / "other.py") + self.assertEqual(name, "other.py") + + def test_a_switch_that_never_settles_is_reported_not_swallowed(self) -> None: + import argparse + from unittest import mock + + from simplecadapi.editor import cli + + args = argparse.Namespace(notebook=self.other, workspace=self.root, wait=0.3) + responses = [ + {"notebook": "gripper.py", "kernel": True}, + {"ok": True, "opening": "other.py"}, + *[{"notebook": "gripper.py", "kernel": True}] * 10, # the switch never lands + ] + with mock.patch("simplecadapi.editor.cli._find_editor", return_value=(self.root, "http://127.0.0.1:1")): + with self._fake_call(responses): + report, code = cli._cell_open(args) + self.assertEqual(code, 1) + self.assertIn("still opening other.py", report["error"]) + + def test_the_cells_report_says_whether_the_kernel_is_up(self) -> None: + state = WorkspaceState(self.root) + state.notebook = self.other + self.assertFalse(state.cells_report(refresh=False)["kernel"]) + state.kernel = object() # any live kernel + self.assertTrue(state.cells_report(refresh=False)["kernel"]) + + def test_viewport_tells_the_image_and_copies_it_out(self) -> None: + import argparse + + from simplecadapi.editor import cli + + png = b"\x89PNG\r\n\x1a\nnot really a png body" + directory = self.root / ".sca-editor" + directory.mkdir() + (directory / "viewport.png").write_bytes(png) + out = self.root / "out" / "seen.png" + args = argparse.Namespace(workspace=self.root, out=out) + report, code = cli._viewport(args) + self.assertEqual(code, 0) + self.assertEqual(report["path"], str(directory / "viewport.png")) + self.assertEqual(report["bytes"], len(png)) + self.assertLess(report["age_seconds"], 10) + self.assertEqual(report["copied_to"], str(out)) + self.assertEqual(out.read_bytes(), png) + + def test_viewport_without_an_image_says_what_is_missing(self) -> None: + import argparse + + from simplecadapi.editor import cli + + args = argparse.Namespace(workspace=self.root, out=None) + with self.assertRaisesRegex(ValueError, "no viewport image"): + cli._viewport(args) + + def test_viewport_finds_the_image_from_a_child_directory(self) -> None: + import argparse + from unittest import mock + + from simplecadapi.editor import cli + + png = b"\x89PNG child" + directory = self.root / ".sca-editor" + directory.mkdir() + (directory / "viewport.png").write_bytes(png) + child = self.root / "sub" + child.mkdir() + args = argparse.Namespace(workspace=child, out=None) + with mock.patch("simplecadapi.editor.cli._find_editor", return_value=(self.root, "http://127.0.0.1:1")): + report, code = cli._viewport(args) + self.assertEqual(code, 0) + self.assertEqual(report["path"], str(directory / "viewport.png")) diff --git a/uv.lock b/uv.lock index 8243691..5634b28 100644 --- a/uv.lock +++ b/uv.lock @@ -640,6 +640,7 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/95/1e/49527ac611af559665f71cbb8f92b332b5ec9c6fbc4e88b0f8e92f5e85df/cryptography-45.0.5.tar.gz", hash = "sha256:72e76caa004ab63accdf26023fccd1d087f6d90ec6048ff33ad0445abf7f605a", size = 744903, upload-time = "2025-07-02T13:06:25.941Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/fb/09e28bc0c46d2c547085e60897fea96310574c70fb21cd58a730a45f3403/cryptography-45.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:101ee65078f6dd3e5a028d4f19c07ffa4dd22cce6a20eaa160f8b5219911e7d8", size = 7043092, upload-time = "2025-07-02T13:05:01.514Z" }, { url = "https://files.pythonhosted.org/packages/b1/05/2194432935e29b91fb649f6149c1a4f9e6d3d9fc880919f4ad1bcc22641e/cryptography-45.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3a264aae5f7fbb089dbc01e0242d3b67dffe3e6292e1f5182122bdf58e65215d", size = 4205926, upload-time = "2025-07-02T13:05:04.741Z" }, { url = "https://files.pythonhosted.org/packages/07/8b/9ef5da82350175e32de245646b1884fc01124f53eb31164c77f95a08d682/cryptography-45.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e74d30ec9c7cb2f404af331d5b4099a9b322a8a6b25c4632755c8757345baac5", size = 4429235, upload-time = "2025-07-02T13:05:07.084Z" }, { url = "https://files.pythonhosted.org/packages/7c/e1/c809f398adde1994ee53438912192d92a1d0fc0f2d7582659d9ef4c28b0c/cryptography-45.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3af26738f2db354aafe492fb3869e955b12b2ef2e16908c8b9cb928128d42c57", size = 4209785, upload-time = "2025-07-02T13:05:09.321Z" }, @@ -649,6 +650,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c9/d8/0749f7d39f53f8258e5c18a93131919ac465ee1f9dccaf1b3f420235e0b5/cryptography-45.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ad0caded895a00261a5b4aa9af828baede54638754b51955a0ac75576b831b27", size = 4456087, upload-time = "2025-07-02T13:05:16.945Z" }, { url = "https://files.pythonhosted.org/packages/09/d7/92acac187387bf08902b0bf0699816f08553927bdd6ba3654da0010289b4/cryptography-45.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9024beb59aca9d31d36fcdc1604dd9bbeed0a55bface9f1908df19178e2f116e", size = 4332873, upload-time = "2025-07-02T13:05:18.743Z" }, { url = "https://files.pythonhosted.org/packages/03/c2/840e0710da5106a7c3d4153c7215b2736151bba60bf4491bdb421df5056d/cryptography-45.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91098f02ca81579c85f66df8a588c78f331ca19089763d733e34ad359f474174", size = 4564651, upload-time = "2025-07-02T13:05:21.382Z" }, + { url = "https://files.pythonhosted.org/packages/2e/92/cc723dd6d71e9747a887b94eb3827825c6c24b9e6ce2bb33b847d31d5eaa/cryptography-45.0.5-cp311-abi3-win32.whl", hash = "sha256:926c3ea71a6043921050eaa639137e13dbe7b4ab25800932a8498364fc1abec9", size = 2929050, upload-time = "2025-07-02T13:05:23.39Z" }, + { url = "https://files.pythonhosted.org/packages/1f/10/197da38a5911a48dd5389c043de4aec4b3c94cb836299b01253940788d78/cryptography-45.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:b85980d1e345fe769cfc57c57db2b59cff5464ee0c045d52c0df087e926fbe63", size = 3403224, upload-time = "2025-07-02T13:05:25.202Z" }, + { url = "https://files.pythonhosted.org/packages/fe/2b/160ce8c2765e7a481ce57d55eba1546148583e7b6f85514472b1d151711d/cryptography-45.0.5-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:f3562c2f23c612f2e4a6964a61d942f891d29ee320edb62ff48ffb99f3de9ae8", size = 7017143, upload-time = "2025-07-02T13:05:27.229Z" }, { url = "https://files.pythonhosted.org/packages/c2/e7/2187be2f871c0221a81f55ee3105d3cf3e273c0a0853651d7011eada0d7e/cryptography-45.0.5-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3fcfbefc4a7f332dece7272a88e410f611e79458fab97b5efe14e54fe476f4fd", size = 4197780, upload-time = "2025-07-02T13:05:29.299Z" }, { url = "https://files.pythonhosted.org/packages/b9/cf/84210c447c06104e6be9122661159ad4ce7a8190011669afceeaea150524/cryptography-45.0.5-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:460f8c39ba66af7db0545a8c6f2eabcbc5a5528fc1cf6c3fa9a1e44cec33385e", size = 4420091, upload-time = "2025-07-02T13:05:31.221Z" }, { url = "https://files.pythonhosted.org/packages/3e/6a/cb8b5c8bb82fafffa23aeff8d3a39822593cee6e2f16c5ca5c2ecca344f7/cryptography-45.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9b4cf6318915dccfe218e69bbec417fdd7c7185aa7aab139a2c0beb7468c89f0", size = 4198711, upload-time = "2025-07-02T13:05:33.062Z" }, @@ -658,14 +662,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/05/2b/aaf0adb845d5dabb43480f18f7ca72e94f92c280aa983ddbd0bcd6ecd037/cryptography-45.0.5-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:7760c1c2e1a7084153a0f68fab76e754083b126a47d0117c9ed15e69e2103492", size = 4449759, upload-time = "2025-07-02T13:05:41.398Z" }, { url = "https://files.pythonhosted.org/packages/91/e4/f17e02066de63e0100a3a01b56f8f1016973a1d67551beaf585157a86b3f/cryptography-45.0.5-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6ff8728d8d890b3dda5765276d1bc6fb099252915a2cd3aff960c4c195745dd0", size = 4319991, upload-time = "2025-07-02T13:05:43.64Z" }, { url = "https://files.pythonhosted.org/packages/f2/2e/e2dbd629481b499b14516eed933f3276eb3239f7cee2dcfa4ee6b44d4711/cryptography-45.0.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7259038202a47fdecee7e62e0fd0b0738b6daa335354396c6ddebdbe1206af2a", size = 4554189, upload-time = "2025-07-02T13:05:46.045Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ea/a78a0c38f4c8736287b71c2ea3799d173d5ce778c7d6e3c163a95a05ad2a/cryptography-45.0.5-cp37-abi3-win32.whl", hash = "sha256:1e1da5accc0c750056c556a93c3e9cb828970206c68867712ca5805e46dc806f", size = 2911769, upload-time = "2025-07-02T13:05:48.329Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/28ac139109d9005ad3f6b6f8976ffede6706a6478e21c889ce36c840918e/cryptography-45.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:90cb0a7bb35959f37e23303b7eed0a32280510030daba3f7fdfbb65defde6a97", size = 3390016, upload-time = "2025-07-02T13:05:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8b/34394337abe4566848a2bd49b26bcd4b07fd466afd3e8cce4cb79a390869/cryptography-45.0.5-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:206210d03c1193f4e1ff681d22885181d47efa1ab3018766a7b32a7b3d6e6afd", size = 3575762, upload-time = "2025-07-02T13:05:53.166Z" }, { url = "https://files.pythonhosted.org/packages/8b/5d/a19441c1e89afb0f173ac13178606ca6fab0d3bd3ebc29e9ed1318b507fc/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c648025b6840fe62e57107e0a25f604db740e728bd67da4f6f060f03017d5097", size = 4140906, upload-time = "2025-07-02T13:05:55.914Z" }, { url = "https://files.pythonhosted.org/packages/4b/db/daceb259982a3c2da4e619f45b5bfdec0e922a23de213b2636e78ef0919b/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b8fa8b0a35a9982a3c60ec79905ba5bb090fc0b9addcfd3dc2dd04267e45f25e", size = 4374411, upload-time = "2025-07-02T13:05:57.814Z" }, { url = "https://files.pythonhosted.org/packages/6a/35/5d06ad06402fc522c8bf7eab73422d05e789b4e38fe3206a85e3d6966c11/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:14d96584701a887763384f3c47f0ca7c1cce322aa1c31172680eb596b890ec30", size = 4140942, upload-time = "2025-07-02T13:06:00.137Z" }, { url = "https://files.pythonhosted.org/packages/65/79/020a5413347e44c382ef1f7f7e7a66817cd6273e3e6b5a72d18177b08b2f/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:57c816dfbd1659a367831baca4b775b2a5b43c003daf52e9d57e1d30bc2e1b0e", size = 4374079, upload-time = "2025-07-02T13:06:02.043Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c5/c0e07d84a9a2a8a0ed4f865e58f37c71af3eab7d5e094ff1b21f3f3af3bc/cryptography-45.0.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b9e38e0a83cd51e07f5a48ff9691cae95a79bea28fe4ded168a8e5c6c77e819d", size = 3321362, upload-time = "2025-07-02T13:06:04.463Z" }, + { url = "https://files.pythonhosted.org/packages/c0/71/9bdbcfd58d6ff5084687fe722c58ac718ebedbc98b9f8f93781354e6d286/cryptography-45.0.5-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8c4a6ff8a30e9e3d38ac0539e9a9e02540ab3f827a3394f8852432f6b0ea152e", size = 3587878, upload-time = "2025-07-02T13:06:06.339Z" }, { url = "https://files.pythonhosted.org/packages/f0/63/83516cfb87f4a8756eaa4203f93b283fda23d210fc14e1e594bd5f20edb6/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bd4c45986472694e5121084c6ebbd112aa919a25e783b87eb95953c9573906d6", size = 4152447, upload-time = "2025-07-02T13:06:08.345Z" }, { url = "https://files.pythonhosted.org/packages/22/11/d2823d2a5a0bd5802b3565437add16f5c8ce1f0778bf3822f89ad2740a38/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:982518cd64c54fcada9d7e5cf28eabd3ee76bd03ab18e08a48cad7e8b6f31b18", size = 4386778, upload-time = "2025-07-02T13:06:10.263Z" }, { url = "https://files.pythonhosted.org/packages/5f/38/6bf177ca6bce4fe14704ab3e93627c5b0ca05242261a2e43ef3168472540/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:12e55281d993a793b0e883066f590c1ae1e802e3acb67f8b442e721e475e6463", size = 4151627, upload-time = "2025-07-02T13:06:13.097Z" }, { url = "https://files.pythonhosted.org/packages/38/6a/69fc67e5266bff68a91bcb81dff8fb0aba4d79a78521a08812048913e16f/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:5aa1e32983d4443e310f726ee4b071ab7569f58eedfdd65e9675484a4eb67bd1", size = 4385593, upload-time = "2025-07-02T13:06:15.689Z" }, + { url = "https://files.pythonhosted.org/packages/f6/34/31a1604c9a9ade0fdab61eb48570e09a796f4d9836121266447b0eaf7feb/cryptography-45.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:e357286c1b76403dd384d938f93c46b2b058ed4dfcdce64a770f0537ed3feb6f", size = 3331106, upload-time = "2025-07-02T13:06:18.058Z" }, ] [[package]] @@ -3227,10 +3237,11 @@ wheels = [ [[package]] name = "simplecadapi" -version = "2.2.5" +version = "2.2.6" source = { editable = "." } dependencies = [ { name = "cadquery-ocp" }, + { name = "cryptography" }, { name = "ezdxf" }, { name = "jsonschema" }, { name = "marimo" }, @@ -3300,6 +3311,7 @@ requires-dist = [ { name = "black", marker = "extra == 'dev'", specifier = ">=23.0" }, { name = "cadquery-ocp", specifier = ">=7.9.3.1" }, { name = "ccx2paraview", extras = ["vtk"], marker = "extra == 'fem'", specifier = ">=3.2,<4" }, + { name = "cryptography", specifier = ">=42.0.0" }, { name = "ezdxf", specifier = ">=1.4.4" }, { name = "gmsh", marker = "extra == 'fem'", specifier = ">=4.15,<5" }, { name = "gmsh", marker = "extra == 'gmsh'", specifier = ">=4.15,<5" },