From 8afe253b088bc1ff3e011efc4f190c32ffe6c4ba Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Thu, 12 Nov 2026 00:04:36 +0100 Subject: [PATCH 1/2] Own types of a project (#66 of the GUI): constructor_code(obj), the code of the constructor of an object without its pose (nothing without a method), by which the window saves changes of the shape of an own type to its line; src/gui.jl is the window's part (card_rows of own types): load_project never includes it, its packages are not under [packages], it counts for the stamp; the AGENTS.md template, the skill and the docs say so Co-Authored-By: Claude Opus 5.5 --- docs/src/api.md | 11 +++ skills/beamletopticsprojects/API.md | 1 + skills/beamletopticsprojects/FORMAT.md | 2 + skills/beamletopticsprojects/SKILL.md | 42 ++++++++++ src/BeamletOpticsProjects.jl | 3 +- src/Code.jl | 26 +++++++ src/New.jl | 7 +- src/Write.jl | 16 +++- test/TestCode.jl | 103 +++++++++++++++++++++++++ test/TestNew.jl | 6 ++ test/runtests.jl | 1 + 11 files changed, 213 insertions(+), 5 deletions(-) create mode 100644 src/Code.jl create mode 100644 test/TestCode.jl diff --git a/docs/src/api.md b/docs/src/api.md index cbe1317..6e16f31 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -61,6 +61,17 @@ pose moved ``` +## Own types + +A type of the project's own code (`src/.jl`) tells its constructor, so that the window can +save changes of its shape to its line. `src/gui.jl` holds the project's code for the window (e.g. +`card_rows` of its own types): only the GUI includes it, [`load_project`](@ref) never does, and +its packages are not the project's `[packages]`. + +```@docs +constructor_code +``` + ## Versions of the packages ```@docs diff --git a/skills/beamletopticsprojects/API.md b/skills/beamletopticsprojects/API.md index effc10b..67cea41 100644 --- a/skills/beamletopticsprojects/API.md +++ b/skills/beamletopticsprojects/API.md @@ -26,6 +26,7 @@ but not exported are qualified: `BeamletOpticsProjects.install_agent_skill()`. | The form of `system.jl` | `@component`, `@source`, `@group`, `@system`, `id_prefix`, `next_id`, `apply_pose!`, `rotation_matrix` | | Change lines (checked, one write) | `add!`, `set!`, `move!`, `duplicate!`, `remove!` | | Read lines | `members`, `sources`, `pose`, `moved` | +| Own types | `constructor_code` | | Versions of the packages | `project_versions`, `check_versions`, `VersionCheck` | | Trace and read | `trace`, `look` | | Versions and changes | `save_version`, `versions`, `version_summary`, `diff_projects`, `EntryChange`, `SystemChange`, `GroupChange`, `copy_code` | diff --git a/skills/beamletopticsprojects/FORMAT.md b/skills/beamletopticsprojects/FORMAT.md index 4d312ce..35c6743 100644 --- a/skills/beamletopticsprojects/FORMAT.md +++ b/skills/beamletopticsprojects/FORMAT.md @@ -18,6 +18,8 @@ One line per entry; components first, then sources, then groups, then systems: copy it; a new line has no `uid`, it gets one when the project is saved. - The right-hand side is any expression that builds a BeamletOptics component, group or source, also a call of a function of `src/.jl` (its exported names). SI units: m, wavelengths in m. + For an own type with a method of `constructor_code`, the window writes this expression anew + from that method when a person changes the shape on its card (see "Own code" in `SKILL.md`). - `pos` is the absolute position (m), `rot` the rotation since construction in degrees: first about the global x-axis, then y, then z. Both are optional. - `catalog = (...)` belongs to the GUI (its page "Edit"); delete it when you change the constructor. diff --git a/skills/beamletopticsprojects/SKILL.md b/skills/beamletopticsprojects/SKILL.md index 7b0b641..87a7ccd 100644 --- a/skills/beamletopticsprojects/SKILL.md +++ b/skills/beamletopticsprojects/SKILL.md @@ -61,6 +61,7 @@ Bench/ │ with ([versions]) and the packages that src/ uses ([packages]). Do not edit it. ├── system.jl the setup: one line per component, source, group and system (FORMAT.md) ├── src/Bench.jl own code (module Bench): functions that build components, own types. The GUI never writes it. +├── src/gui.jl optional: the window's part (card_rows of own types). Only the GUI loads it (see "Own code"). ├── view.toml the view of the GUI (camera, colors, clip planes). Do not edit it. ├── AGENTS.md what is specific to this project (CLAUDE.md points to it) ├── .claude/skills/ the skills, installed from the packages (not versioned) @@ -120,6 +121,47 @@ Bench/ 6) `release!` your claims and `leave` when you are done. Versions are the person's (see "Versions"). +## Own code + +`src/.jl` holds the module `` of the project: functions that build components or +groups and own types. Its exported names can be used in `system.jl`. It uses BeamletOptics and the +packages under `[packages]`, never the GUI. + +- **An own type tells its constructor** with a method of `constructor_code`, the code of the + right-hand side of its line without the pose. The window builds the line from it when a person + changes the shape of such an object on its card (e.g. a slider of a width); without a method such + a change lives only in the window and is not saved. The string must parse and build the object as + it is now, in the module of the project; `import ..BeamletOpticsProjects` reaches the package + from the module in any environment: + + ```julia + module Bench + using BeamletOptics + import ..BeamletOpticsProjects + export Block + + struct Block{T} <: BeamletOptics.AbstractObject{T} + shape::BeamletOptics.PlanoSurfaceSDF{T} + width::T + thickness::T + end + Block(width, thickness) = Block(BeamletOptics.PlanoSurfaceSDF(thickness, width), width, thickness) + BeamletOptics.interact3d(::BeamletOptics.AbstractSystem, ::Block, ::Beam, ::Ray) = nothing # absorbs + + BeamletOpticsProjects.constructor_code(b::Block) = "Block($(b.width), $(b.thickness))" + end + ``` + + `constructor_code(obj)` is `nothing` for every type without a method (the components of + BeamletOptics too: their lines are written from their code). After `load_project` in the same + script, call it as `Base.invokelatest(constructor_code, p[:K1])`: the method is newer than the + script. +- **`src/gui.jl` is the window's part** (optional): `card_rows` and other code of BeamletOpticsGUI + for the own types (the skill `beamletopticsgui`). Only the window includes it, into the module + of the project after the rest. `load_project` and `trace` never include it, so you neither load + nor need it, and its packages (the GUI, Makie) are not under `[packages]`. Write it only when the + person asks for a card of an own type; it changes the project like any file of `src/`. + ## The open windows Several people may have the project open in their own windows while you work on it. diff --git a/src/BeamletOpticsProjects.jl b/src/BeamletOpticsProjects.jl index a6904e5..801874a 100644 --- a/src/BeamletOpticsProjects.jl +++ b/src/BeamletOpticsProjects.jl @@ -27,7 +27,7 @@ export Project, Entry, SystemEntry, GroupEntry, new_project, load_project, save_ EntryChange, SystemChange, GroupChange, copy_code, save_version, version_summary, versions, trace, look, announce, leave, fork_team, teams, post!, board, claim!, release!, claims, wait_for, merge_draft!, evaluate, move!, add!, set!, duplicate!, remove!, members, sources, pose, moved, - project_versions, check_versions, VersionCheck + project_versions, check_versions, VersionCheck, constructor_code public project_name, module_name, normalize_code, EXTENSION, same_entry, same_system, same_group, new_uid, match_entries, install_agent_skill, person, presence_file, session_file, write_presence, read_presences, alive, merge_plan, compose @@ -209,5 +209,6 @@ include("Guards.jl") include("Move.jl") include("Query.jl") include("Edit.jl") +include("Code.jl") end diff --git a/src/Code.jl b/src/Code.jl new file mode 100644 index 0000000..2019398 --- /dev/null +++ b/src/Code.jl @@ -0,0 +1,26 @@ +#= +Own types of a project: the constructor code of an object, by which a change of its shape (e.g. on +a card of the GUI) is written to its line of `system.jl` +=# + +""" + constructor_code(obj) -> Union{Nothing, String} + +The code of the constructor that builds `obj` as it is now, without its pose: the right-hand side +of its line in `system.jl` (the line keeps `pos` and `rot`). `nothing` (the default) if the type of +`obj` does not tell it. + +An own type in the code of a project (`src/.jl`) adds a method, so that a change of its shape +(e.g. by a slider on its card in the GUI) can be saved to its line: + +```julia +# src/Bench.jl: an own type Porro, built by Porro(leg, height) and keeping both +BeamletOpticsProjects.constructor_code(p::Porro) = "Porro(\$(p.leg), \$(p.height))" +``` + +The string must parse and, evaluated in the module of the project (where the exported names of +`src/.jl` are known), build an object equal in shape to `obj`. Print numbers with `repr` or +string interpolation of `Float64`, which round-trip exactly. Without a method, a change of the +shape lives only in the window and is lost when the project is loaded again. +""" +constructor_code(@nospecialize(obj)) = nothing diff --git a/src/New.jl b/src/New.jl index b491412..5ad93a2 100644 --- a/src/New.jl +++ b/src/New.jl @@ -158,7 +158,12 @@ _agents_template(name, marker) = """ - `system.jl`: the setup, one line per component, source and system. - `src/$name.jl`: own code (module `$name`): functions that build components or groups, own - types. Its exported names can be used in `system.jl`. The GUI never writes it. + types. Its exported names can be used in `system.jl`. The GUI never writes it. An own type + tells its constructor with a method of `BeamletOpticsProjects.constructor_code`, so that the + window can save changes of its shape (e.g. on its card) to its line. + - `src/gui.jl` (optional): the code for the window (`card_rows` etc. of the own types). Only + the GUI includes it, into the module of the project; agents neither load nor need it, and + `load_project` and `trace` work without it. Its packages are not under `[packages]`. - `$marker`: the project file that people open. It records the module name, the newest versions the project was saved with (`[versions]`) and the packages that `src/` uses (`[packages]`); the saves write it, do not edit it. diff --git a/src/Write.jl b/src/Write.jl index a032268..d686491 100644 --- a/src/Write.jl +++ b/src/Write.jl @@ -477,12 +477,21 @@ end # Names in `using` and `import` that are no packages of `[packages]` const _NOT_PACKAGES = ("Base", "Core", "Main") +""" +The file of `src/` that holds the project's code for the window (e.g. `card_rows` of its own +types): the GUI includes it into the module of the project after the rest, [`load_project`](@ref) +never does, and the packages it uses (the GUI, Makie) are not the project's `[packages]`. It counts +for [`project_stamp`](@ref) like any file of `src/`. +""" +const _GUI_FILE = "gui.jl" + """ The names of the packages that the own code of the project in the folder `dir` uses: the first name of every `using` and `import` in the `.jl` files of `src/` (without files and folders whose name -starts with `.`, as for [`project_stamp`](@ref)), without relative ones (`using .X`), `Base`, -`Core`, `Main`, BeamletOptics, BeamletOpticsProjects and the names `own` (the project's own -module). A file that does not parse is skipped. +starts with `.`, as for [`project_stamp`](@ref), and without `src/gui.jl`, the window's part, see +`_GUI_FILE`), without relative ones (`using .X`), `Base`, `Core`, `Main`, BeamletOptics, +BeamletOpticsProjects and the names `own` (the project's own module). A file that does not parse is +skipped. """ function _used_packages(dir, own) names = Set{String}() @@ -492,6 +501,7 @@ function _used_packages(dir, own) filter!(d -> !startswith(d, '.'), dirs) for f in files (startswith(f, '.') || !endswith(f, ".jl")) && continue + (root == src && f == _GUI_FILE) && continue ex = try Meta.parseall(read(joinpath(root, f), String); filename = f) catch e diff --git a/test/TestCode.jl b/test/TestCode.jl new file mode 100644 index 0000000..5d673ef --- /dev/null +++ b/test/TestCode.jl @@ -0,0 +1,103 @@ +module TestCode + +using BeamletOptics, BeamletOpticsProjects +using Test + +const P = BeamletOpticsProjects + +# An own type of a project with two shape parameters, which tells its constructor +const MODULE = """ +module Bench +using BeamletOptics +import ..BeamletOpticsProjects +export Block + +struct Block{T} <: BeamletOptics.AbstractObject{T} + shape::BeamletOptics.PlanoSurfaceSDF{T} + width::T + thickness::T +end + +Block(width, thickness) = Block(BeamletOptics.PlanoSurfaceSDF(thickness, width), width, thickness) + +# Absorbs the rays that hit it +BeamletOptics.interact3d(::BeamletOptics.AbstractSystem, ::Block, ::Beam, ::Ray) = nothing + +BeamletOpticsProjects.constructor_code(b::Block) = "Block(\$(b.width), \$(b.thickness))" +end +""" + +const SYSTEM = """ +@component K1 = Block(0.02, 0.005) pos = (0.0, 0.1, 0.0) rot = (0.0, 0.0, 10.0) +@component M1 = RoundPlanoMirror(0.0254, 0.006) pos = (0.0, 0.3, 0.0) +@source B1 = Beam([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], 1.064e-6) +@system S1 = [K1, M1] sources = [B1] +""" + +function bench(; gui = nothing) + dir = mktempdir() + write(joinpath(dir, "Bench.bmo"), "name = \"Bench\"\n") + mkpath(joinpath(dir, "src")) + write(joinpath(dir, "src", "Bench.jl"), MODULE) + isnothing(gui) || write(joinpath(dir, "src", "gui.jl"), gui) + write(joinpath(dir, "system.jl"), SYSTEM) + return dir +end + +@testset "constructor_code: nothing without a method" begin + for x in (RoundPlanoMirror(0.0254, 0.006), Beam([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], 1.064e-6), + Detector(0.02), 1, "x", nothing, [1, 2]) + @test constructor_code(x) === nothing + end +end + +@testset "constructor_code: the method of an own type of a project" begin + dir = bench() + p = load_project(dir) + k = p[:K1] + # The method was defined by load_project, in a newer world + code = Base.invokelatest(constructor_code, k) + @test code == "Block(0.02, 0.005)" + # The objects of BeamletOptics in the same project have none + @test Base.invokelatest(constructor_code, p[:M1]) === nothing + # The code builds the object as it is, in the module of the project + b = Base.invokelatest(Core.eval, p.mod, Meta.parse(code)) + @test typeof(b) == typeof(k) && b.width == k.width && b.thickness == k.thickness + # A change of the shape goes to the line; the pose stays + set!(dir, :K1; code = "Block(0.03, 0.004)") + q = load_project(dir) + @test Base.invokelatest(constructor_code, q[:K1]) == "Block(0.03, 0.004)" + @test pose(q, :K1).pos == pose(p, :K1).pos + @test pose(q, :K1).rot ≈ pose(p, :K1).rot + # The project traces with its own type + r = trace(q, :S1) + @test :K1 in r.ids +end + +@testset "src/gui.jl: the window's part" begin + gui = """ + using BeamletOpticsGUI + using NotInstalledXyz + BeamletOpticsGUI.card_rows(b::Bench.Block) = () + """ + dir = bench(; gui) + # load_project never includes it: a package that is not installed does not matter + p = load_project(dir) + @test Base.invokelatest(constructor_code, p[:K1]) == "Block(0.02, 0.005)" + @test !Base.invokelatest(isdefined, p.mod, :NotInstalledXyz) + # Its packages are not the project's + @test isempty(P._used_packages(dir, ("Bench",))) + save_project(p) + @test isempty(project_versions(dir).packages) + # A file gui.jl elsewhere in src/ is own code like any other + mkpath(joinpath(dir, "src", "parts")) + write(joinpath(dir, "src", "parts", "gui.jl"), "using TOML\n") + @test P._used_packages(dir, ("Bench",)) == Set(["TOML"]) + rm(joinpath(dir, "src", "parts"); recursive = true) + # A change of it is a change of the project + s = project_stamp(dir) + write(joinpath(dir, "src", "gui.jl"), gui * "# changed\n") + @test project_stamp(dir) != s +end + +end diff --git a/test/TestNew.jl b/test/TestNew.jl index 779f993..c100063 100644 --- a/test/TestNew.jl +++ b/test/TestNew.jl @@ -73,6 +73,12 @@ end @test !occursin("environment of the person", agents) # the own code of the project has no hint at code for the GUI @test !occursin("card_rows", read(joinpath(dir, "src", "Example.jl"), String)) + # that is src/gui.jl, the window's part, which agents do not load; own types tell their + # constructor for the saves of the window + files = match(r"## Files\n(.*?)(\n## |\z)"s, agents).captures[1] + @test occursin("`src/gui.jl`", files) && occursin("card_rows", files) + @test occursin("agents neither load nor need it", files) + @test occursin("constructor_code", files) @test occursin("`[packages]` of `$(basename(dir)).bmo`", env) @test occursin("Never make this folder", env) @test !occursin("Project.toml", agents) && !occursin(".envs", agents) diff --git a/test/runtests.jl b/test/runtests.jl index bb47ca4..a4a7445 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -25,6 +25,7 @@ using Test include(joinpath(@__DIR__, "TestMove.jl")) include(joinpath(@__DIR__, "TestQuery.jl")) include(joinpath(@__DIR__, "TestEdit.jl")) + include(joinpath(@__DIR__, "TestCode.jl")) include(joinpath(@__DIR__, "TestProjectVersions.jl")) include(joinpath(@__DIR__, "TestAqua.jl")) end From 63dc7b97d888054d14253ad2b1797103c73e6d6d Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Thu, 12 Nov 2026 05:03:05 +0100 Subject: [PATCH 2/2] save_version tries the commit again while a shared drive refuses the ref for a moment: the rename of refs/heads/.lock is refused ("access denied") while another computer reads the ref, and the lock of another save is waited for; the parent is the head at each try Found by two machines saving versions one after the other on a network drive: the second version was lost with a GitError of the operating system. Co-Authored-By: Claude Opus 5.5 --- src/Versions.jl | 25 ++++++++++++++++++++++++- test/TestVersions.jl | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/Versions.jl b/src/Versions.jl index a19f09c..347ee38 100644 --- a/src/Versions.jl +++ b/src/Versions.jl @@ -60,13 +60,36 @@ function save_version(dir::AbstractString; message::Union{Nothing, AbstractStrin end isempty(strip(msg)) && (msg = "Changed " * join(_changed_files(repo, head, rel), ", ")) sig = _signature(repo) - id = LibGit2.commit(repo, msg; author = sig, committer = sig, tree_id = tree) + # The parent is the head at each try, also when a version of another person came first + id = _retry_refused() do + LibGit2.commit(repo, msg; author = sig, committer = sig, tree_id = tree) + end return string(id) finally close(repo) end end +""" +Runs `f()`, a write to a repository, again while the file system refuses a file of it for a moment +or another save holds its lock: on a shared drive the rename of `refs/heads/.lock` to the +ref is refused while another computer reads the ref, and libgit2 then reports an error of the +operating system ("access denied"). Tries up to `tries` times `wait` seconds apart, then throws the +last error. Other errors are thrown at once. +""" +function _retry_refused(f; tries::Int = 40, wait::Real = 0.05) + for attempt in 1:tries + try + return f() + catch e + refused = e isa LibGit2.GitError && + (e.class == LibGit2.Error.OS || occursin("lock", lowercase(e.msg))) + (refused && attempt < tries) || rethrow() + end + sleep(wait) + end +end + """ Runs `f()` while the index of `repo` is not locked (`index.lock`, e.g. by a save of another person on a shared drive), trying again up to `tries` times `wait` seconds apart, also when libgit2 diff --git a/test/TestVersions.jl b/test/TestVersions.jl index b03c03a..ef0de18 100644 --- a/test/TestVersions.jl +++ b/test/TestVersions.jl @@ -302,6 +302,47 @@ end @test err isa ArgumentError end +@testset "a ref that the file system refuses for a moment" begin + P = BeamletOpticsProjects + refused = LibGit2.GitError(LibGit2.Error.OS, LibGit2.Error.ERROR, + "failed to rename lockfile to 'refs/heads/main': Access denied") + # refused twice, then written + calls = 0 + @test P._retry_refused(; wait = 0.01) do + calls += 1 + calls <= 2 && throw(refused) + return :written + end === :written + @test calls == 3 + # refused for good: the last error after the tries + calls = 0 + err = try + P._retry_refused(() -> (calls += 1; throw(refused)); tries = 3, wait = 0.01) + catch e + e + end + @test err === refused && calls == 3 + # another error at once + calls = 0 + @test_throws ArgumentError P._retry_refused(() -> (calls += 1; throw(ArgumentError("x"))); wait = 0.01) + @test calls == 1 + # the lock of the ref of another save, released meanwhile: the version is saved on the head + dir = joinpath(mktempdir(), "Ref") + new_project(dir) + before = length(versions(dir)) + ref = LibGit2.with(LibGit2.GitRepo(dir)) do repo + LibGit2.with(r -> LibGit2.name(r), LibGit2.head(repo)) + end + lock = joinpath(dir, ".git", ref * ".lock") + write(lock, "") + write(joinpath(dir, "src", "Extra.jl"), "f() = 1\n") + t = @async (sleep(0.3); rm(lock)) + @test save_version(dir; message = "after the lock") isa String + wait(t) + @test length(versions(dir)) == before + 1 + @test first(versions(dir)).message == "after the lock" +end + @testset "a project in a worktree or submodule" begin # The git directory lies apart from the working directory, as in a worktree or submodule root = mktempdir()