From bb083ada3196612f3007232f0d28ba6172f5bf19 Mon Sep 17 00:00:00 2001 From: Timothy Place Date: Wed, 30 Sep 2026 17:36:53 -0500 Subject: [PATCH 1/2] Package fixes from the first release (6.5); plan 4.7 (uv) and 4.8 (one package for every platform) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The external's bundle identifier shipped as max-sdk-base's unexpanded Xcode template, com.74objects.${PRODUCT_NAME:rfc1034identifier}: only Xcode expands it. The object's CMakeLists now expands it from the project name with the same rule — com.74objects.tap.python-tilde — and the macOS CI job checks it. assemble-package.py leaves docs/PRODUCTION-PLAN.md out of the package (docs/ still ships, for the reference page Max reads). The plan records the uv discussion as 4.7 and the decision to attach one all-platform package to every tag, v0.x published as pre-releases automatically, as 4.8. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_019rVV9SkjkWmiBXFMT4whkz --- .github/workflows/build.yml | 8 ++++++ docs/PRODUCTION-PLAN.md | 26 +++++++++++++++++++ scripts/assemble-package.py | 4 ++- .../projects/tap.python_tilde/CMakeLists.txt | 11 ++++++++ 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f895356..9a728c4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -155,6 +155,14 @@ jobs: || { echo "ERROR: $bin is not universal"; exit 1; } done + # max-sdk-base leaves the identifier for Xcode to expand; the object's CMakeLists expands it + - name: Verify the bundle identifier is expanded + run: | + id=$(plutil -extract CFBundleIdentifier raw "externals/tap.python~.mxo/Contents/Info.plist") + echo "CFBundleIdentifier: $id" + [ "$id" = "com.74objects.tap.python-tilde" ] \ + || { echo "ERROR: expected com.74objects.tap.python-tilde"; exit 1; } + - name: Verify libpython resolves via a relative rpath only run: | bin="externals/tap.python~.mxo/Contents/MacOS/tap.python~" diff --git a/docs/PRODUCTION-PLAN.md b/docs/PRODUCTION-PLAN.md index cd2745d..c199088 100644 --- a/docs/PRODUCTION-PLAN.md +++ b/docs/PRODUCTION-PLAN.md @@ -238,6 +238,28 @@ while audio ran segfaulted in 5 of 5 runs. ships — Min-API, the Max SDK, CPython's, and each installed package's own (PEP 639 `dist-info/licenses/`, where numpy lists what it bundles) — with an index, collected from the package's actual contents; CI runs the collection on Linux. +- [ ] **4.7 uv for development and release tooling** — *Discussed (2026-09-30):* use uv where it + replaces work we do by hand; keep the shipped runtime a python-build-standalone archive pinned + by SHA256 in `runtime.lock`. For uv: `uv pip compile --universal --generate-hashes` in place of + most of `update-locks.py`'s PyPI handling; `uv pip install --python-platform --target …` + to install any platform's wheels from any machine (what 4.8 needs); `uv python install 3.13` for + the Linux fast loop's CPython with headers; `uv run` with inline script metadata for the + scripts. Against using it for the runtime: `uv python install` pins only through the uv + version, installs in its own layout, and may mark the interpreter externally managed (to + check) — and the work that matters (the `@rpath` install name, re-signing, the universal + libpython) is ours either way. Users never need uv; the ReadMe may mention + `uv pip install --python support/bin/python3 …` beside pip. +- [ ] **4.8 One package for every platform on each tag** — *Decided (2026-09-30):* a tag also + attaches a single `PythonTap-.zip` holding every platform's external and runtime, and + v0.x tags publish as pre-releases automatically (1.0 and later stay drafts until signing + exists). Needs one runtime per platform side by side — `support/macos-arm64/`, + `support/macos-x86_64/`, `support/windows-x64/` (one folder cannot hold both: Windows' `Lib/` + and the Mac's `lib/` collide on a case-insensitive disk) — with the macOS external choosing by + the architecture it runs as (each slice of the universal binary can carry its own rpath) and the + Windows external loading `python313.dll` from its folder by full path before the first + delay-loaded call (Max adds only `support/` itself to the DLL search path); and a last + `release.yml` job that merges the platform builds into one `PythonTap/` and attaches it with + its checksum (about 130 MB, against 37–55 MB per platform zip today). ## Phase 5 — documentation and examples @@ -367,6 +389,9 @@ passed; a third ran the soak (6.2) and measured performance (6.3). To continue: ReadMe promises (`release.yml` fixed; the draft's added by hand); the external's bundle identifier is min's template, unexpanded — `com.74objects.${PRODUCT_NAME:rfc1034identifier}` — to fix before signing and notarizing; and `docs/PRODUCTION-PLAN.md` ships inside the package. + *Both fixed since:* max-sdk-base leaves the identifier for Xcode to expand, which no other + generator does (every sibling Max package ships it the same way), so the object's CMakeLists + expands it — `com.74objects.tap.python-tilde`; `assemble-package.py` leaves the plan out. *Observed, not explained:* with the release installed, Max's file database took 8–11 minutes to report ready at each launch (seconds with the linked checkout; Max had also just been updated to 9.1.5 and rebuilt its database) — the runner no longer waits for it, as tests opened by name do @@ -431,6 +456,7 @@ passed; a third ran the soak (6.2) and measured performance (6.3). To continue: 7. Phase 4.5–4.6 — release packaging and licenses. 8. Phase 2.4–2.6 — multichannel, worker mode, reload stalls (features; may follow 1.0). 9. Phase 6 — in-Max validation before tagging 1.0. +10. Phase 4.7–4.8 — uv for the tooling, then one package for every platform on each tag. ## External prerequisites diff --git a/scripts/assemble-package.py b/scripts/assemble-package.py index eccb306..c0f7f36 100644 --- a/scripts/assemble-package.py +++ b/scripts/assemble-package.py @@ -44,7 +44,9 @@ # Never shipped from the copied folders. # maxtest_*.py: the runtime tests' fixtures, copied into python/ while runtime-tests/run.py runs -IGNORED = shutil.ignore_patterns("__pycache__", "*.pyc", ".ipynb_checkpoints", ".DS_Store", "maxtest_*") +# PRODUCTION-PLAN.md: the development roadmap in docs/, beside the reference page Max reads +IGNORED = shutil.ignore_patterns("__pycache__", "*.pyc", ".ipynb_checkpoints", ".DS_Store", "maxtest_*", + "PRODUCTION-PLAN.md") class AssemblyError(Exception): diff --git a/source/projects/tap.python_tilde/CMakeLists.txt b/source/projects/tap.python_tilde/CMakeLists.txt index d65081c..6147d7a 100644 --- a/source/projects/tap.python_tilde/CMakeLists.txt +++ b/source/projects/tap.python_tilde/CMakeLists.txt @@ -111,6 +111,17 @@ endif () include(${C74_MIN_API_DIR}/script/min-posttarget.cmake) # (C++20 is forced on this target and its test by the root CMakeLists.txt.) +if (APPLE) + # The bundle identifier: max-sdk-base leaves it as Xcode's ${PRODUCT_NAME:rfc1034identifier}, + # which only Xcode expands, so every other generator shipped it literally. Expand it here from + # the project name with the same rule — anything but letters, digits, '.' and '-' becomes '-': + # com.74objects.tap.python-tilde — for the Info.plist (configured from this variable when the + # build is generated) and for Xcode alike. + string(REGEX REPLACE "[^A-Za-z0-9.-]" "-" BUNDLE_IDENTIFIER "${PROJECT_NAME}") + set_target_properties(${PROJECT_NAME} PROPERTIES + XCODE_ATTRIBUTE_PRODUCT_BUNDLE_IDENTIFIER "${AUTHOR_DOMAIN}.${BUNDLE_IDENTIFIER}") +endif () + ############################################################# # UNIT TEST From eb046e28f4d4d0f4e9d7a31ab7fa89ed6580a40c Mon Sep 17 00:00:00 2001 From: Timothy Place Date: Wed, 30 Sep 2026 17:42:12 -0500 Subject: [PATCH 2/2] The reference page from Max: date the .mxo on each build (6.8) min rewrites docs/tap.python~.maxref.xml when Max loads an external newer than the page, but it dates the external by its .mxo folder, which a rebuild leaves alone: in the checkout the folder dated from its first build, older than the page, so the page was never rewritten. (A freshly unzipped release has a new folder, which is why Max rewrote the installed package's page.) The macOS build now touches the folder after each link, and run.py says when Max has rewritten the page, so it gets committed. The page Max writes matches the committed one but for the description, which predated 2.4's text; this commits Max's. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_019rVV9SkjkWmiBXFMT4whkz --- CLAUDE.md | 5 +++-- docs/PRODUCTION-PLAN.md | 15 +++++++++++---- docs/tap.python~.maxref.xml | 2 +- runtime-tests/run.py | 6 ++++++ source/projects/tap.python_tilde/CMakeLists.txt | 6 ++++++ 5 files changed, 27 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f686ca0..4f1032a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -144,8 +144,9 @@ the secrets exist, and attaches zips + SHA256s to a draft release. C++17), as TapTools-Max does. - **Keep in sync when behavior changes:** `ReadMe.md`, the object's min metadata (`MIN_DESCRIPTION`, argument and message descriptions — min regenerates - `docs/tap.python~.maxref.xml` from them whenever the external is newer, so never hand-edit the - page; commit the regenerated one), `help/tap.python~.maxhelp`, the examples and their notebook + `docs/tap.python~.maxref.xml` from them when Max loads an external newer than the page, so never + hand-edit the page: rebuild, run Max once — `runtime-tests/run.py` says when the page was + rewritten — and commit it), `help/tap.python~.maxhelp`, the examples and their notebook (committed executed), and the plan. - **Implement from documentation and published sources only** — the CPython C-API docs, the Max SDK docs — never by reverse-engineering another product. diff --git a/docs/PRODUCTION-PLAN.md b/docs/PRODUCTION-PLAN.md index c199088..094cca6 100644 --- a/docs/PRODUCTION-PLAN.md +++ b/docs/PRODUCTION-PLAN.md @@ -300,8 +300,8 @@ passed; a third ran the soak (6.2) and measured performance (6.3). To continue: only to check a universal build), then `cmake -S . -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build && ctest --test-dir build`. The external lands in `externals/`. When Max loads an external newer than `docs/tap.python~.maxref.xml`, min rewrites the page from the - object's metadata; the committed one was generated against the mock kernel, so if Max's - differs, commit Max's. (A symlinked package works for externals, but Max loads a package's + object's metadata (6.8: the build dates the `.mxo` for it, and `run.py` says when it happened); + commit Max's page when it differs. (A symlinked package works for externals, but Max loads a package's *extensions* only from a real folder — why `run.py` installs the harness as a copy.) 2. *5.2 — the help patcher.* Open `help/tap.python~.maxhelp`: its new boxes were added by hand (as JSON, in Max's layout), so check they sit sensibly and every message box works, then @@ -419,12 +419,19 @@ passed; a third ran the soak (6.2) and measured performance (6.3). To continue: nothing, and the external's own reload line is gone. Errors particular to an instance are unchanged. Pinned by a core test (two processors, a change, an unchanged reload) and a runtime test (five objects in Max: the class's diagnostic once per run of the file). -- [ ] **6.8 The reference page from Max** — runbook step 1 expects min to rewrite +- [x] **6.8 The reference page from Max** — runbook step 1 expects min to rewrite `docs/tap.python~.maxref.xml` when Max loads an external newer than it; in the Mac sessions it did not. Max's standard output had "file not found" and "failed to get date modified" lines at start-up — probably min's `doc_update` failing to resolve a path, not yet shown to come from this object. Find out why, and whether the committed page (generated against the mock kernel) is the - one Max would write. + one Max would write. *Done:* min's `doc_update` dates the external by its `.mxo` folder, and a + rebuild changes only the files inside it — the checkout's folder dated from its first build + (2026-08-05), older than the page, so the page never looked stale; a freshly unzipped release + has a new folder, which is why Max rewrote the installed package's page. The macOS build now + touches the folder after each link (a Windows `.mxe64` is one file, dated by its link already), + and `run.py` says when Max has rewritten the page, to commit it. Max's page matches the committed + one but for the description, which predated 2.4's text — now committed. The start-up lines are + not this object's: they still appear while its page is written. - [ ] **6.9 The help patcher and `numpy_allpass.py`** — the help patcher points to the `numpy_gain` and `allpass` examples but not to `numpy_allpass`, the one that shows what the block path is for; add it in Max (and re-save), perhaps with the measured comparison. diff --git a/docs/tap.python~.maxref.xml b/docs/tap.python~.maxref.xml index ab99833..6cc5050 100644 --- a/docs/tap.python~.maxref.xml +++ b/docs/tap.python~.maxref.xml @@ -7,7 +7,7 @@ Process audio with a Python class - Process audio with a Python class. The argument names a file in the package's python folder, which must define a class of the same name. The class's type-annotated public fields become attributes (int, float, bool and str; Optional allowed), and its public methods become messages called according to their signatures, with methods named int, float, symbol and bang answering those standard messages. Its process() method runs on the signal: once per signal vector when annotated with numpy arrays, or once per sample when annotated with float. An optional prepare(sample_rate, vector_size) method receives the audio settings before audio starts and whenever they change. The file is watched and reloaded when saved, keeping attribute values; errors are printed to the Max console and never take Max down. + Process audio with a Python class. The argument names a file in the package's python folder, which must define a class of the same name. The class's type-annotated public fields become attributes (int, float, bool and str; Optional allowed), and its public methods become messages called according to their signatures, with methods named int, float, symbol and bang answering those standard messages. Its process() method runs on the signal: once per signal vector when annotated with numpy arrays, or once per sample when annotated with float; its parameters are the object's signal inlets, and its return hint its outlets (a tuple of values for more than one). An optional prepare(sample_rate, vector_size) method receives the audio settings before audio starts and whenever they change. The file is watched and reloaded when saved, keeping attribute values; a save that changes process()'s inputs or outputs changes the inlets and outlets to match, keeping the patch cords of those that stay. Errors are printed to the Max console and never take Max down. diff --git a/runtime-tests/run.py b/runtime-tests/run.py index 5578a7c..e3ded73 100644 --- a/runtime-tests/run.py +++ b/runtime-tests/run.py @@ -58,6 +58,8 @@ SUPPORT = PACKAGE / "support" SUPPORT_ASIDE = PACKAGE / "support.maxtest-aside" EXTERNAL = PACKAGE / "externals" / "tap.python~.mxo" +# The reference page min rewrites from the object's descriptions (plan 6.8) +REFPAGE = ROOT / "docs" / "tap.python~.maxref.xml" OSCAR = HARNESS / "extensions" / "oscar.mxo" # Must match misc/max-test-config.json: Max listens on one port and sends to the other. @@ -556,6 +558,10 @@ def main() -> int: link.unlink(missing_ok=True) print() + if PACKAGE == ROOT and REFPAGE.exists() and subprocess.run( + ["git", "-C", str(ROOT), "diff", "--quiet", "--", str(REFPAGE)]).returncode == 1: + # min rewrites it when Max loads an external newer than it (plan 6.8) + print(f"Max rewrote {REFPAGE.relative_to(ROOT)} from the object's descriptions: review and commit it.") if failures: print(f"{len(failures)} failure(s):") for failure in failures: diff --git a/source/projects/tap.python_tilde/CMakeLists.txt b/source/projects/tap.python_tilde/CMakeLists.txt index 6147d7a..b35e712 100644 --- a/source/projects/tap.python_tilde/CMakeLists.txt +++ b/source/projects/tap.python_tilde/CMakeLists.txt @@ -120,6 +120,12 @@ if (APPLE) string(REGEX REPLACE "[^A-Za-z0-9.-]" "-" BUNDLE_IDENTIFIER "${PROJECT_NAME}") set_target_properties(${PROJECT_NAME} PROPERTIES XCODE_ATTRIBUTE_PRODUCT_BUNDLE_IDENTIFIER "${AUTHOR_DOMAIN}.${BUNDLE_IDENTIFIER}") + + # min rewrites docs/tap.python~.maxref.xml when Max loads an external newer than the page, and + # dates the external by its .mxo folder — which a rebuild leaves alone (only files inside it + # change), so the page was never rewritten from a checkout (plan 6.8). Date the folder with + # each link. + add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD COMMAND touch "$") endif ()