diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dfbbc8..cc15626 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ breaking changes to the contract were allowed where they bought correctness (D5 `docs/PRODUCTION-PLAN.md`); each is recorded here. From 1.0.0, a change to the class contract is a major version. +## 1.0.1 — unreleased + +### Fixed + +- **In Max, the object had no attributes and no messages.** 1.0.0's guard against a Python method + or field named like a message the Max object already answers (plan 8.2) asked Max's + `object_getmethod()` and took anything but null for an answer — but for a name the object does + not have, Max returns `method_false()`, a function, as the SDK documents, so every field and + method of every class was "reserved by the host" and the console said so for each; only audio + still worked. The mock kernel the unit tests run against answers null, which is why they passed. + The guard now recognizes `method_false()`, the unit test's kernel answers as Max does, and the + Mac session (8.8) is what would have caught it before the tag. + +### Changed + +- **The package has the Tap family's PythonTap icon** in place of min's template icon: the + "ground" version from TapHouse's `brand/`, rendered at 500×500. + ## 1.0.0 — 2026-10-01 The first stable release: the class contract as the ReadMe states it, the production plan's diff --git a/CLAUDE.md b/CLAUDE.md index f7fe930..7d3e505 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,10 @@ user's Python class as an audio object. `[tap.python~ name]` loads `python/name. `class name`, turns its annotated public fields into Max attributes and its public methods into Max messages, calls its `process()` on the signal, and hot-reloads on save. `ReadMe.md` is the user-facing contract; **`docs/PRODUCTION-PLAN.md` is the authoritative roadmap** — its settled decisions (D1–D6), -its phases, and the audit findings behind them. Tick its items (with the PR) as they land. +its phases, and the audit findings behind them. Tick its items (with the PR) as they land. Its +Phase 9 is a second object, **`tap.python`**, a Python class as a Max object without audio (what a +method returns is what it outputs): designed in `docs/TAP-PYTHON-PLAN.md` (decisions D7–D11), not +built yet. ## Layout (D6: a host-independent core plus a thin Max wrapper) @@ -131,6 +134,13 @@ attaches all the zips + SHA256s to a release — a pre-release for 0.x, a draft which such a call crashes — so the watcher is owned by a nobox helper with the SDK's signature (`tap.python_tilde_filewatch.h`). Check the SDK's calling convention before exposing a Max-called method as a `message<>`. + And read the SDK's return contract before testing a Max call's result: `object_getmethod()` + answers `method_false()`, a function, not null, for a name an object does not have — a null + test reserved every Python name in 1.0.0 (`found_method()` in the object; its glue test's kernel + answers as Max does). The mock kernel is thinner than Max: when a stub decides a behavior, make + the test's stub faithful to the SDK (as `attr_args_offset` and `object_getmethod` are), and give + each fake function a body of its own: MSVC's Release link folds identical functions + (`/OPT:ICF`), which once gave a fake "found" method `method_false()`'s address on Windows only. - **Say what is true of a class once, what is true of an instance per instance.** Many objects can share one class file; only the processor whose `load()` ran the file (`load_script` says so) announces the class — its `Loaded` line and its diagnostics — through `announce()` (plan 6.7). diff --git a/docs/AUDIT-TAP-PYTHON-PLAN.md b/docs/AUDIT-TAP-PYTHON-PLAN.md new file mode 100644 index 0000000..0a31b9d --- /dev/null +++ b/docs/AUDIT-TAP-PYTHON-PLAN.md @@ -0,0 +1,329 @@ +# Audit of the tap.python plan (2026-10-02) + +An adversarial read of `docs/TAP-PYTHON-PLAN.md` as committed in `18f3c10`, before any of it is +built. It asks where the plan is wrong, unverified, inconsistent with itself or the codebase, or +would fail in a real Max the way 1.0.0 did (a guard that the mock kernel passed and Max broke: PR +#37). Two reviewers read it independently, and every finding kept here was checked against the +source by hand. Each is marked **verified** (shown against the code, the SDK headers or a run) or +**unverified** (plausible, but only Max can show it). + +## Verdict + +**Do not start 9.1 as planned.** The design's user-facing contract (return values as output, +hints as outlet counts, one inlet, messages on their own thread) survives the audit. Its +architecture does not. Two findings block: + +- **The second external kills Max** (B1). Two externals each compile in their own copy of the + header-only core, and the second one's start-up aborts the process. +- **The order of work repeats 1.0.0** (B2). The plan builds five PRs before anything runs in Max, + yet most of its riskiest assumptions are behaviors only Max can show. + +Six major findings change the design: outlets, concurrency, `anything`, Scheduler in Audio +Interrupt, announce-once, and the contract. + +| | Blocker | Major | Minor | +|---|---:|---:|---:| +| Findings | 2 | 6 | 9 | + +## Blockers + +### B1. A second external aborts Max when it starts the interpreter (verified) + +**The plan says** (D7, D11): "A second external in the same package, over the same core", with +"the same loader (a file saved once is executed once however many objects of either kind share +it)", and "the core gains one option". + +**The problem.** The core is header-only, and its process-wide state lives in function-local +statics: `initialize()`'s `std::once_flag` (`runtime.h:791`), the support module's globals +(`support_globals()`), the console, the scripts folder, and `init_thread()`. Each external +compiles in its own copy of all of them, but both load the same libpython. The second external's +`call_once` runs again, and calls `PyImport_AppendInittab()` after `Py_Initialize()`. + +**Reproduced here.** Two shared objects, each built with the core and `-fvisibility=hidden`, were +loaded into one process with `dlopen(RTLD_LOCAL)` and each called `initialize()`: + +``` +./ext_a.so: initialize ok=1 +Fatal Python error: PyImport_AppendInittab: PyImport_AppendInittab() may not be called after Py_Initialize() +Aborted (exit 134) +``` + +**On each platform:** + +- **Windows.** Each `.mxe64` is a DLL with its own statics, so this happens as shown: a patch + with both objects aborts Max. +- **macOS.** It depends on whether dyld merges the two bundles' weak inline statics, and only Max + can show which. If dyld does not merge them, Max aborts as above. If it does, the two externals + share the core's state by accident, including each one's console sink and thread checks, which + is no design either. +- **Every test binary links one copy of the core**, so no existing test can see this. + +**Even without the abort**, a second support module means a second source cache. A shared file +would then be executed once per kind, and 6.7 (announce once) and 6.10 (report once) would break +across kinds. + +**It also contradicts the ReadMe**, which calls two embedded copies sharing process-wide state +"untested and unsupported". + +**Recommend.** Decide this in D11 before anything else: + +- **(a) One core, two front ends** (preferred). The core becomes one shared library in the + package (`support/` or beside the externals), which both externals link, so there is one copy of + every static. +- **(b) Attach instead of re-initialize.** `initialize()` detects `Py_IsInitialized()` and attaches + to state the first binary published in `sys.modules`: the support module, the console module and + the sink. The second binary skips `AppendInittab`, pre-initialization and stream rebinding, and + keeps its own thread-state handling. +- **(c) One external.** A single `tap.python~` binary also registers the `tap.python` class (a + second `class_new` in its `ext_main`), if Max can find a class that is not named for its file. + This is the smallest change if Max allows it; the 9.0 spike can say. + +Whichever is chosen, add a core test that starts the runtime from two shared objects in one +process, and a runtime test with both objects in one patch, created in both orders. + +### B2. The order of work repeats the 1.0.0 failure (verified, plan text) + +**The plan says:** 9.1 to 9.5 land first, and "The Mac session that runs them is the phase's last +item" (9.6). The production plan adds that Phase 9 can run "after 8.8, or beside it: nothing in it +touches what 8.8 checks." + +**The problem.** The plan marks one thing "to check in Max" (the dumpout). Yet B1, M1, M2, M3 and +M4 below are all behaviors only Max shows. And 9.2 moves `answered_by_max()`, `found_method()` and +the file watcher into shared code: exactly what 8.8, still open, has to verify. The plan's own +record of 1.0.0 says "a tag with a Mac session outstanding ships what only Max can show." + +**Recommend.** Two changes: + +- **Run 8.8 (with PR #37's fix) before 9.2** moves the guard. +- **Add 9.0, a Max spike, before 9.1.** A throwaway second external, built as B1's chosen design, + next to `tap.python~` in one patch. In Max, confirm: + - both objects start, in either creation order; + - `outlet_insert_after` places an outlet before the dumpout, and patch cords survive it; + - `get` reaches an obex-stored dumpout; + - how a class-level `anything` and instance methods and attributes take turns in dispatch (M3); + - what `object_getmethod()` answers for an unknown name on a class with `anything`; + - which thread a `metro`-driven message runs on with Overdrive on, and with Scheduler in Audio + Interrupt on. + + Write the answers into the plan before 9.1. + +## Major + +### M1. min's outlets cannot do what "Ports" describes (verified) + +**The plan says:** value outlets "plus a last `outlet<> m_dumpout{this, "dumpout"}` stored in the +obex", new outlets by `outlet_insert_after`, "min's lists following". It leaves open "whether min's +`outlet<>` named `dumpout` is enough". + +**The evidence** is in min's outlet header and the SDK: + +- **Order.** An `outlet<>` pushes itself onto the object's list as it is constructed + (`c74_min_outlet.h:312`). A member `m_dumpout` is constructed before the constructor body adds + the class's outlets (as `tap.python~`'s `describe_ports()` does). So the dumpout would be the + second outlet, not the last. +- **No names.** The string is a description; min has no outlet names. Only + `object_obex_store(x, _sym_dumpout, …)` makes `get` work, as the SDK's own example shows + (`ext_obex.h`, at `object_obex_store`). That settles the plan's open question. +- **No Max outlet behind a later `outlet<>`.** min makes the Max outlet in a private `create()`, + called only once, by `create_outlets()` at instantiation (`c74_min_outlet.h:438`). An `outlet<>` + added on a reload keeps a null instance, and sending through it calls `outlet_list(nullptr, …)`. + `tap.python~` gets away with this because min never sends through its signal outlets. A control + outlet would crash. + +**Recommend.** Specify the ports outright: + +- Make the value outlets, then the dumpout, in the constructor, in that order. +- Store the dumpout with `object_obex_store()` once the Max outlets exist (min's `setup` stage or + later). +- Send through raw outlet pointers taken from `outlet_nth()` and `outlet_insert_after()`, never + through `outlet<>::send()`. Keep min's list only for assist text, as `tap.python~` does. + +### M2. A reload can change outlets while another thread is outputting (verified gap; Max side unverified) + +**The plan says:** the `outlet_*` calls come after the GIL is released, and "a message that arrives +on the scheduler thread while a reload runs waits for the GIL". + +**The problem.** Once the GIL is released, nothing orders a message's output against a reload's +port changes. The reload runs on the main thread, without the GIL, and makes these changes: + +- `outlet_delete` and `outlet_insert_after` on Max's outlets; +- edits to min's outlet vector; +- `object_deletemethod` and `object_addmethod` for the class's messages. + +Under Overdrive, a scheduler-thread message can compute output for three outlets, release the GIL, +and index an outlet the main thread has just deleted: a use-after-free. The SDK documents nothing +about `outlet_insert_after` or `outlet_delete` (`ext_proto.h:465-469`, declarations only), let +alone their thread safety. + +**Recommend.** Add a per-object lock, held while a message maps its output onto outlets and while +a reload changes ports. Under it, a message checks that its outlet count is still current and +drops the output (once, reported) if not. Never hold it across a call into Python. Add a glue test +that races a widening reload against a second thread's messages. + +### M3. `anything` and the guard rest on Max dispatch the plan has not checked (unverified) + +**The plan says:** a min `message<> m_anything` forwards unknown selectors, and "the guard excludes +it by name". + +**What is known:** + +- min registers `anything` as a class method (`c74_min_object_wrapper.h:580`). +- The Python methods are instance methods (`object_addmethod`), and the fields are instance + attributes. The SDK says of instance methods: "these methods are private -- instance methods are + not actually fully implemented at this time" (`ext_obex.h:2296`). +- Today no class has both, because `tap.python~` reserves `anything`. + +**What is not known:** + +- whether Max tries instance methods and instance attributes (`steps 8`, `getsteps`) before a + class's `anything`; +- whether `object_getmethod()` answers an unknown name with the `anything` method on such a class. + If it does, the guard reserves every name: 1.0.0 again, and excluding `anything` by name does not + help. +- The test kernel from PR #37 answers from a fixed list, so it cannot reveal either. + +**Recommend.** + +- Do not give the class a min `anything`. Register `anything` per instance, and only when the + Python class defines it, so classes without one keep exactly `tap.python~`'s dispatch. +- Make the forwarder proof against dispatch order. Before calling the Python `anything`, it tries + the selector as a message, then as an attribute set, then as `get`. +- Make the guard ignore an answer that is the object's own forwarder. +- Settle the dispatch order in the 9.0 spike. + +### M4. D10 leaves out Scheduler in Audio Interrupt (omission verified; Max behavior unverified) + +**The plan says:** a message runs "on Max's main thread, or the scheduler thread under Overdrive", +and "worker mode (2.5) is the answer when both are in one patch and the computation is heavy". + +**The problem.** With Scheduler in Audio Interrupt on, the scheduler runs on the audio thread. A +`metro`-driven `tap.python` method then runs Python there, and waits for the GIL behind a reload +on the main thread, which holds it for milliseconds. + +- **Worker mode does not help**, because the heavy work itself is on the audio thread. +- **CLAUDE.md's guarantee breaks.** CLAUDE.md promises that in worker mode the audio thread "never + takes the GIL". That stops being true for any patch that also has a `tap.python`. +- **The plan, the production plan and the ReadMe never mention the setting.** The SDK has + `systhread_isaudiothread()` (`ext_systhread.h:180`) to detect it. + +**Recommend.** + +- **State it as an honest limit**, with its consequence: dropouts while Python runs or waits. +- **Decide what the object does about it:** warn once, or defer such messages to the main thread + (an attribute, or the default). +- **Correct D10's advice.** + +### M5. Announce-once hides diagnostics when the two objects apply different rules (verified) + +**The plan says** of announce-once only that the `Loaded` line "describes whichever ran the save". + +**The problem.** Every class diagnostic goes through `announce()`, which only the processor that +ran the file speaks (`m_announcing = executed`, `processor.h:187`). The two kinds disagree on +exactly what those diagnostics report: + +- `mode`, `latency`, `latencysamples` and `anything` are reserved only in `tap.python~`; +- methods with an unsaid-length tuple hint are dropped only in `tap.python`. + +So if a `tap.python` runs the save, a `tap.python~` on the same file silently loses a field named +`mode`, and the reverse happens too. + +**Recommend.** Key announce-once on the file and the object kind, or have each kind announce what +differs. Pin it with two processors whose reserved names and `bind_audio` differ. + +### M6. 9.1 changes `tap.python~`'s contract while saying it does not (verified) + +**The plan says** 9.1 leaves "`tap.python~`'s battery and glue test unchanged", and the CHANGELOG +should say the `list[…]` parameter "only adds". + +**The evidence:** + +- **List parameters work differently today.** `hint_kind(list[float])` is `'list'`, which + `value_type_from_hint` maps to a symbol (`value.h`). So today `foo 1` passes `""` to a parameter + hinted `list[float]`, and an `np.ndarray` parameter receives a `str`. Taking all the remaining + atoms changes what existing classes receive and how many arguments they accept. +- **The element type is missing.** `hint_kind` does not report it, so the support module must + change too. The plan does not say so. +- **The tuple rule has no gate.** Not exposing a method with an unsaid-length tuple return must be + limited to `tap.python`. Today `bind_message` ignores return hints (`processor.h:1390`), so an + ungated change removes messages from `tap.python~` classes. +- **The CHANGELOG's policy** (`CHANGELOG.md:5`): "From 1.0.0, a change to the class contract is a + major version." + +**Recommend.** + +- **Gate the tuple rule on `bind_audio`.** +- **Record the list parameter as a change, with a test pinning today's behavior first** (the + house rule). Either call the release 2.0, or argue in the plan that a hint that never worked is + not contract. Then the CHANGELOG states that, rather than "only adds". +- **Output an unsaid-length tuple as a list** from the first outlet rather than hiding the method, + which keeps D7's "a class written for one object loads in the other". + +## Minor + +- **m1. The output table contradicts itself and numpy (verified).** + - The sequence row covers "any non-str iterable", which includes the `dict` that the last row + reports. It also includes `bytes`, an unordered `set`, and one-shot generators. + - `np.bool_` has no `__index__` in numpy 2.5.3 but does convert with `float()` (checked: `1.0`). + So by the table it is output as a float, against the `bool` row and the ReadMe's rule that + numpy's bool counts as `bool`. + - **Recommend:** list the accepted sequence types, and add `np.bool_` to the bool row. +- **m2. A `str` return as a bare selector (design; Max behavior unverified).** + - `"list"`, `"int"`, `"float"` and `""` become malformed zero-argument messages. + - `"60"` becomes a selector, not a number. + - The example `note_name.py` outputs the pitch class `C` as a message named `C`, where most Max + objects that output a name send `symbol C`. + - **Recommend:** decide between `anything` and `symbol` in the 9.0 spike, against real + downstream objects (`route`, `sel`, `prepend`, a message box's `$1`), and say what happens to + the reserved selectors. +- **m3. `call()` cannot be overloaded on its return type (verified).** The new `call()` would have + the same parameters as the existing `bool call(std::string_view, std::span)` + (`processor.h:403`). It needs another name, such as `call_with_output()`. +- **m4. Outlet counts from string hints count nested commas (verified).** `return_shape` splits a + string hint on every comma (`runtime.h:559`). Checked: + - the hint `'tuple[list[int], dict[str, int]]'` gives 3; + - the same hint as an object gives 2. + + Outlet counts become patch cords, so this needs the depth-aware split that `_split_union` + already has. +- **m5. The planned mock tests cannot see what they claim (verified).** + - The glue test's `outlet_nth` stub returns a made-up pointer (`n + 1`), while the mock records + output by real outlet id. Output through such a pointer would land on another object's outlet, + or nowhere. + - `object_obex_store`, `object_obex_dumpout` and `outlet_insert_after` are not stubbed at all. + - **Recommend:** make the stubs faithful (CLAUDE.md's rule since PR #37). +- **m6. The file watcher's class name would be registered twice (unverified).** A "pure move" + keeps `class_new("tap.python~.filewatch", …)` and its `nobox` registration in both binaries. + Name it per host, or let B1's single core own it. +- **m7. The examples invite the class-body shadowing trap (verified).** Once `def list(...)` or + `def int(...)` is defined, a later annotation in the same class using `list[...]` or `int` + refers to the method. On 3.13: `TypeError: 'function' object is not subscriptable`. + `default.py` already warns about this. The ReadMe section and the new examples must too. +- **m8. D10 contradicts the Threads section, and overstates the GIL (verified).** + - D10 says a message holds the GIL "until it returns, and outputs on that thread". The Threads + section says the outlet calls come after the GIL is released. + - D10's "waits for the GIL in 0.5 ms slices" holds only for Python bytecode. A single long C + call (a numpy operation) holds the GIL throughout, as the production plan's 2.6 says itself + ("what a switch cannot interrupt is a single C call"). +- **m9. An unsourced claim about `js` (verified, plan text).** "Non-finite values pass through, as + `js` passes them" cites nothing, and `tap.python~` zeroes non-finite output. Either cite Max's + documentation for `js`, or choose on the merits: Max's own number boxes and arithmetic objects + can propagate NaN downstream. + +## What the plan should change, in order + +1. **Fix 1.0.0 and run 8.8 first:** PR #37, then the Mac session. +2. **Rewrite D11 around one core** (B1's option a, b or c), and record the decision. +3. **Add 9.0, the Max spike** (B2), and write its answers into the plan: + - two objects in either order; + - outlet insertion before a dumpout; + - `get` through the dumpout; + - `anything` dispatch and `object_getmethod()` on such a class; + - the threads under Overdrive and under Scheduler in Audio Interrupt; + - `symbol` or `anything` for a `str` return. +4. **Respecify Ports** (M1) and add the per-object output lock (M2). +5. **Redesign `anything`** as an instance method registered only when defined, with a forwarder + that does not depend on dispatch order (M3). +6. **Add Scheduler in Audio Interrupt to D10 and the ReadMe's limits** (M4). +7. **Key announce-once per object kind** (M5). +8. **Gate or record the contract changes**, with tests pinning today's behavior first (M6). +9. **Fix the minor items** in the text and the planned tests. diff --git a/docs/PRODUCTION-PLAN.md b/docs/PRODUCTION-PLAN.md index 4add8b4..aa38ef6 100644 --- a/docs/PRODUCTION-PLAN.md +++ b/docs/PRODUCTION-PLAN.md @@ -33,6 +33,7 @@ note the PR that closed them. | D4 | Python version policy | **Pin 3.13; upgrade deliberately.** One CPython minor per release; a move (e.g. to 3.14's deferred annotations) is its own PR with tests. No free-threaded or subinterpreter builds until numpy supports them. | | D5 | Compatibility before 1.0 | **Breaking changes to the class contract are allowed** where they buy correctness (reserved names, file-based loading, signature dispatch). Each is recorded in a `CHANGELOG.md`; the shipped examples are updated in the same PR. | | D6 | Architecture | **A host-independent core plus a thin Max wrapper**, the family's kernel/wrapper split (TapTools / TapTools-Max). Everything that talks to CPython — interpreter start-up, thread state, module loading, class introspection, value conversion, `process()` binding and reload, exception handling — lives in `core/` (`tap::python`, plain C++20 + CPython, no Max or min-api). The external maps the core's attribute and message descriptions onto `object_addattr`/`object_addmethod` and owns only Max concerns (package paths, the file watcher, atoms). Linux is the first test platform *for the core*: real audio/main threads and sanitizers in CI and in cloud sessions. A plugin front end (CLAP or VST3) is optional later work over the same core (Phase 7), not a test vehicle — the Max glue still needs its own tests. | +| D7–D11 | A second object, `tap.python`, without audio | **Decided 2026-10-02 in `docs/TAP-PYTHON-PLAN.md`** (Phase 9 below): the same core, folder, loader and class contract minus audio; a method's return value is the output, its return hint the outlet count; one inlet; a message runs on the thread it arrives on; the shared Max glue moves to `source/shared/`. | ## Phase 0 — the core split and a test foundation that can fail @@ -611,7 +612,15 @@ down rather than discovered again. *This plan was itself audited before being ad cord connected by `thispatcher`, with those methods in the class) — to run in the Mac session (8.8), as is Build Collective for `fileusage`. In the mock kernel `object_getmethod()` always answers null, so the guard's own effect is seen only in Max: the existing - `attributes-and-messages` runtime test is what would show it over-reserving. + `attributes-and-messages` runtime test is what would show it over-reserving. *And it did + over-reserve (found 2026-10-02 in Max, 1.0.0 released with it):* for a name the object does not + answer Max returns `method_false()`, a function — the SDK says so — not null, so the guard + reserved every field and method of every class; the object loaded, audio ran, and nothing else + worked. Fixed in 1.0.1: `found_method()` treats null and `method_false()` alike as "not found", + and the glue test's kernel now answers as Max does (`method_false()` for an unknown name, a + method for the names min registers and for one the test's class defines on purpose), so the + guard's effect is pinned without Max. The lesson for 8.8: a tag with a Mac session outstanding + ships what only Max can show. - [x] **8.3 Worker mode never hangs Max (A1, A6 — `worker.h`).** *A1:* `stop()` bounds its join. The worker records its OS thread identifier (`PyThread_get_thread_ident()`, no GIL needed) as it starts; if the thread has not finished @@ -756,6 +765,30 @@ draft until signing exists, as `release.yml` has it. parameter lists — e.g. a fixed bank of N parameters the Python class declares — and to sharing one interpreter with other plugins that embed Python in the same host process. +## Phase 9 — `tap.python`, a Python class as a Max object without audio (after 1.0) + +The design — decisions D7–D11, the class contract, the output rules, what changes in the core, the +glue and the package — is `docs/TAP-PYTHON-PLAN.md`; keep it current where a PR decides +differently. One PR per item, in this order, each against tests that fail before it: + +- [ ] **9.1 The core:** output items and their conversion in `value.h`; `message_info::return_count`, + `outlet_count()`, `call()` returning the output; the `bind_audio` option; a last parameter hinted + `list[…]` or `np.ndarray` taking the remaining atoms (both objects); the `Loaded` line per + binding. `test_output.cpp`. `tap.python~` unchanged and green. +- [ ] **9.2 The shared glue:** the attribute, message, trampoline, file-watcher and package headers + to `source/shared/tap/python_max/`, templated on the host object; the runtime CMake block to + `source/cmake/embedded-python.cmake`; `style.yml` follows. A pure move. +- [ ] **9.3 The object:** `source/projects/tap.python/` — ports with a dumpout, the output mapping, + `anything` forwarding, dynamic outlets on reload, reserved names, `MIN_DESCRIPTION`; the glue + test; the examples (`euclid.py`, `scale.py`, `note_name.py`, `default.py`'s `bang`); CI checks and + packaging over both externals; CHANGELOG 1.1.0 started. +- [ ] **9.4 Documentation:** the ReadMe section and output table, CLAUDE.md, the help patcher, the + reference page. +- [ ] **9.5 Runtime tests:** the `tap.python.*` patchers in `make_patchers.py`; `run.py` aware of + both externals. +- [ ] **9.6 The Mac session:** the whole suite for both objects, the help patcher checked and + re-saved, Max's pages committed, the hand checks, then tag `v1.1.0`. + ## Sequencing (one PR each) 1. Phase 0.1–0.2 — the core split and the Linux battery; then 0.3–0.6. @@ -771,6 +804,9 @@ draft until signing exists, as `release.yml` has it. 11. Phase 8 — the audit's findings, in the order above: 8.1 (docs and hardening, first, so the ReadMe stops overclaiming while the fixes land), 8.2, 8.3, 8.4, 8.5, 8.6, 8.7 (#35), then the Mac session 8.8 — decided on 2026-10-01 to follow 1.0.0 rather than gate it. +12. Phase 9 — `tap.python`: 9.1 (the core, no Max change), 9.2 (the shared glue, a pure move), + 9.3 (the object), 9.4 and 9.5 (docs and runtime tests, independent of each other), then the + Mac session 9.6 and `v1.1.0`. After 8.8, or beside it: nothing in it touches what 8.8 checks. ## External prerequisites diff --git a/docs/TAP-PYTHON-PLAN.md b/docs/TAP-PYTHON-PLAN.md new file mode 100644 index 0000000..319e94c --- /dev/null +++ b/docs/TAP-PYTHON-PLAN.md @@ -0,0 +1,284 @@ +# tap.python — a Python class as a Max object without audio: design and plan + +`tap.python~` runs a Python class as an audio object. `tap.python` runs one as an ordinary Max +object: messages in, messages out, no signal. This is its design — the decisions (D7–D11, +continuing `PRODUCTION-PLAN.md`'s D1–D6), what a class looks like, how the core and the package +change — and the plan to build it (Phase 9 of the production plan, which points here). Drafted +2026-10-02 against 1.0.0; nothing in it is built yet. Tick the items below (with the PR) as they +land, and keep the design current where a PR decides differently. + +## What it is for + +In Max, most objects are not audio objects: they transform messages — a list comes in, a list goes +out; a bang asks for the next value; an int becomes a name. Max gives three ways to write such an +object in a language: `js`, `node.script`, and a C external. `tap.python` is a fourth, with what the +`tap.python~` contract already gives — Python's numeric stack (numpy, and whatever is installed into +the runtime), attributes and messages from type hints, hot reload on save, tracebacks in the +console, and no way for the class to take Max down — and one thing more that an audio object does +not need: **what a method returns is what the object outputs.** + +```python +from attrs import define, field + +@define +class euclid: + steps: int = field(default = 8) + pulses: int = field(default = 3) + + def bang(self) -> list[int]: + return [int((i * self.pulses) % self.steps < self.pulses) for i in range(self.steps)] +``` + +``` +[tap.python euclid] ← loads python/euclid.py; bang outputs a list of 0s and 1s; @steps and @pulses +``` + +Two things it is not. It is not a replacement for `js`: no access to the patcher, the Max API or +UI; it is for computing. And it is not an async object like `node.script`: a message runs to +completion, on the thread that sent it, and its result is output before the message returns — the +ordinary Max object model, which is what makes it composable with `trigger`, `metro` and the rest. + +## Decisions + +| # | Decision | Choice | +|---|---|---| +| D7 | What `tap.python` is | **A second external in the same package, over the same core, with the same class contract minus audio, plus output.** Same `python/` folder, same loader (a file saved once is executed once however many objects of either kind share it), same attributes, messages, hot reload, console and error guards. A class written for one object loads in the other: in `tap.python`, `process()` and `prepare()` are ordinary methods (sending `process 0.5` calls it and outputs the result — a way to test a filter sample by sample); in `tap.python~`, a method's return value is dropped, as now. | +| D8 | How output works | **A method's return value is output from the object's outlets, by its type; its return *hint* fixes how many outlets, at bind time.** No injected outlet API in 1.x (nothing for the class to import, so it still runs in a notebook): `None` outputs nothing; a number or a string one atom; a sequence a list; a method hinted `-> tuple[A, B]` outputs one value per outlet, right to left. The object has as many outlets as the widest return hint among its methods (at least one), plus a dumpout outlet at the right, as Max objects with attributes do; a save that changes the count changes the outlets in place, as 2.4 does for `tap.python~`. The rules are in [Output](#output-what-a-method-returns). | +| D9 | Inlets | **One inlet in the first release.** Messages are the methods; state is the attributes (`steps 8`, `@pulses 3`, attrui), which is what a right inlet is for in most Max objects. More inlets are a later option (how, in [Later](#later-not-planned)), not a 1.x promise. | +| D10 | Threads | **A message runs on the thread it arrives on — Max's main thread, or the scheduler thread under Overdrive — holding the GIL until it returns, and outputs on that thread.** As every ordinary Max object does, and as `tap.python~`'s messages already do. No worker. The honest limits: a long computation in a message from a `metro` holds Max's scheduler for its duration (send it through `deferlow` to run on the main thread instead); and while a message runs, a `tap.python~` in direct mode waits for the GIL in 0.5 ms slices (2.6) — worker mode (2.5) is the answer when both are in one patch and the computation is heavy. | +| D11 | Code layout | **The Max glue the two objects share moves out of `tap.python_tilde/` into `source/shared/`, templated on the host object; the core gains one option.** The dynamic attribute and message registration, the C trampolines, the file watcher and the package paths are the same for both; min's `SUBDIRLIST` makes a target per folder of `source/projects/` only, so a shared folder beside it is safe. The core's `processor` takes `bind_audio` (true for `tap.python~`): with it off, `process` and `prepare` are plain methods and nothing is prepared. Behavior-preserving for `tap.python~`: its tests do not change. | + +## The class contract + +Everything the ReadMe's *Writing a class* says holds, less the *Audio*, *Worker mode* and *Audio +settings* paragraphs, plus output: + +- **Loading.** `[tap.python name]` loads `python/name.py` and instantiates `class name`; no + argument loads `default`. File-based loading (D2), helpers (8.5), UTF-8 (6.6), one execution per + save shared by every object of either kind (3.6, 6.7), one report per broken save (6.10). +- **Attributes** — as `tap.python~` (3.2, 8.6). With a dumpout outlet, `getsteps` outputs + `steps 8` from it, as a Max object with attributes does. +- **Messages** — public methods, called by signature (3.1). `int`, `float`, `symbol`, `bang` and + `list` answer those standard messages; a method named `anything` answers every message the class + has no method for, with the selector as its first argument: `def anything(self, selector: str, + *args)`. (In `tap.python~`, `anything` is reserved; here the object forwards it.) A parameter + hinted `list[float]`, `list[int]`, `list[str]` or `np.ndarray`, last in the signature, takes all + the remaining atoms as one list (or a float64 array) — `def list(self, values: np.ndarray) -> + np.ndarray` is a list in and a list out. This last mapping lands in the core, so `tap.python~`'s + messages get it too. +- **Output** — below. +- **Hot reload, errors, console** — as `tap.python~`: a save reloads keeping attribute values; a + broken save is reported once and the object outputs nothing until a save fixes it; an exception + in a method prints its traceback and the message outputs nothing; `sys.exit()` is reported, not + honored; what is below Python (`os._exit()`, a crashing extension, an endless loop — which + freezes the thread the message came on) is outside the guard (8.1). +- **Reserved names** — Max's and min's own (8.2's list and the `answered_by_max()` guard), without + the audio ones: `dsp`, `dsp64`, `dspsetup`, `dspstate`, `inputchanged`, `multichanneloutputs`, + `signal`, `mode`, `latency` and `latencysamples` are free; `anything` is taken by the forwarder + above, as a method a class may define. + +### Output: what a method returns + +A method's return value goes out of the object, converted by the rules below, before the message +returns. The rules reproduce how Max objects and `js` behave, so a patch sees what it expects. + +| The method returns | The object outputs | +|---|---| +| `None` | nothing | +| `bool` | an int, 0 or 1 | +| an `int` (anything with `__index__`: `np.int64`, an `IntEnum`) | an int (`t_atom_long`, 64-bit; a value past it is reported) | +| a `float` (anything with `__float__` and no `__index__`: `np.float32`) | a float (non-finite values pass through, as `js` passes them) | +| a `str` | the message named by it, with no arguments (`"hello world"` is one symbol, as in `js`) | +| a sequence (`list`, a `tuple` with no `tuple[…]` hint, a 1-D `np.ndarray`, any non-`str` iterable) | a list, each element an atom by the rules above; if the first element is a `str`, the message it names with the rest as arguments (Max's own rule: `["note", 60, 100]` outputs `note 60 100`); an empty sequence outputs nothing; an element that is not an atom (`None`, a nested sequence) is reported and the list is not output | +| a sequence of *n* values, from a method hinted `-> tuple[…]` with *n* members | one value per outlet, outlets *n*…1, right to left, each by these rules (`None` in a slot outputs nothing from that outlet); a result that is not a sequence of exactly *n* values is reported | +| anything else (a `dict`, an object) | reported, with its type; nothing output (a `dict` as a Max dictionary is a later item) | + +The *hint* decides the outlet count when the class loads; the *value* fills them when the method +runs. A method hinted `-> tuple[int, str]` has two outlets, and so does the object if no method is +hinted wider; a method hinted `-> list[float]`, `-> np.ndarray`, `-> float` or not at all has one, +and an unhinted method that happens to return a tuple outputs it as a list from the first outlet. +An unhinted tuple return is a list, a hinted one is several outlets: this is the one place where +the hint changes what a value does, so the ReadMe says it in those words. A `tuple[…]` hint of +unsaid length (`tuple`, `tuple[int, ...]`) cannot name an outlet count: the method is not exposed, +and the console says why (as 2.4 does for `process()`). + +A `print()` in a method posts to the console at once from the main thread and a moment later from +the scheduler thread (8.4); it is not output. + +### Examples + +`default.py` stays shared by both objects and gains a `bang` that returns the gain, so +`[tap.python]` with no argument outputs something. New examples, each a thing Max users write by +hand today: + +- `euclid.py` (above): a Euclidean rhythm from two attributes, a list out of a bang. +- `scale.py`: a list in, scaled and offset by attributes with numpy, a list out — `def list(self, + values: np.ndarray) -> np.ndarray`. +- `note_name.py`: an int in, two outlets out — `def int(self, note: int) -> tuple[str, int]` + returns the pitch class and the octave, so `60` outputs `C` and `4`. + +## Threads and the GIL + +Nothing new is load-bearing, and that is the point of D10. A message takes a `gil_lock` on the +thread it arrives on (`processor::call()` already does), converts the arguments, calls the method, and +converts the result to atoms, all under the GIL; the `outlet_*` calls come after the GIL is +released. Not for reentrancy — `gil_lock` is `PyGILState_Ensure`, which nests, so a downstream +object sending a message back into this one (a loop through `trigger`) simply takes it again on the +same thread — but because an outlet call runs the whole downstream chain before it returns, and +holding the GIL across it would keep every other Python thread (a `tap.python~`'s audio thread in +direct mode) waiting for a cascade of Max objects that has nothing to do with Python. Reloads stay +on the main thread; a message that arrives on the scheduler thread while a reload runs waits for +the GIL, as a `tap.python~` message does now. +Output from a thread Python started (`threading.Thread` in the class) is not supported: a method +runs and returns on Max's thread, and there is no outlet to reach from anywhere else — stated in +the ReadMe as an honest limit, with the later item that would change it. + +## The core + +`core/include/tap/python/` grows, host-independent and tested on Linux first, as everything else +did (D6): + +- **`value.h`** — beside `value` (one atom): `output_item`, a message to output — a selector or + none, and its atoms (what `outlet_anything` / `outlet_list` / `outlet_int` take) — and + `output`, the per-outlet items of one call. The conversion from a Python object to an + `output_item` lives in the core (the rules above), with its errors as diagnostics through the + processor's log, so the Max side only maps items onto `outlet_*` calls. +- **`processor.h`** — `message_info` gains `return_count` (from `describe()`'s `return_shape`, + which the signature already carries); `outlet_count()` is the widest among the messages (at least + one); `call()` returns the converted `output` (empty for `None` or a failure) instead of a bool, + with a `call()` overload keeping the old shape for `tap.python~`; a `processor_options` (or a + constructor flag) `bind_audio` — off, `process`/`prepare` are bound as messages, `has_process()` + is false, `prepare()` is a no-op; argument conversion for a last parameter hinted `list[…]` or + `np.ndarray`. The `Loaded` line (6.7) says what the loading object bound — "4 messages, 2 + outlets" for a `tap.python`, "process() bound, one call per vector" for a `tap.python~` — so + with a file shared by both kinds it describes whichever ran the save; an honest limit of 6.7's + rule, written down. +- **Tests** — `test_output.cpp`: every row of the table above, from a fixture class whose methods + return each kind; the outlet count from the hints; an unhinted tuple as a list against a hinted + one as outlets; an unsaid-length tuple hint not exposed; the `list[…]`/`np.ndarray` parameter; + `bind_audio` off making `process` a message; a method raising outputs nothing and reports; a + call from a second thread racing a reload on the first (as `test_threads.cpp` does for audio). + +## The Max object + +`source/projects/tap.python/` — `tap.python.h`, `tap.python.cpp`, `tap.python_test.cpp` — a min +`object<>` without `vector_operator<>`: + +- **Ports.** One `inlet<>`; `outlet<>`s for the class's `outlet_count()` plus a last `outlet<> + m_dumpout{this, "dumpout"}` stored in the obex (`object_obex_store(maxobj(), gensym("dumpout"), + …)`) so that `get` outputs from it. On a reload that changes the count, dynamic outlets as + 2.4 does, without `dsp_resize`: between the box's `dynlet_begin`/`dynlet_end`, `outlet_delete` + for the surplus and `outlet_insert_after` the last value outlet for the new ones (never + `outlet_append`, which would land them after the dumpout), min's lists following; without a box, + the object keeps its outlets and says so once. *To check in Max:* that min's `outlet<>` named + `dumpout` is enough for `get` or whether the obex store is needed (min stores one for jit + objects only, `max_jit_class_wrap_standard`). +- **Messages.** The shared `python_message` registration; `message_gimme()` calls the processor and + maps each `output_item` onto `outlet_int`/`outlet_float`/`outlet_anything`/`outlet_list` on the + right outlet, right to left. A min `message<> m_anything{this, "anything", …}` forwards an + unknown selector with its atoms to the class's `anything` method when it has one, and otherwise + posts that the object does not understand it (Max's own wording). +- **Reserved names.** 8.2's list less the audio names, and the `answered_by_max()` guard; `anything` + is answered by the object (the forwarder), so it must not be reserved by the guard — the guard + excludes it by name, and a glue test asserts a class's `anything` is exposed. +- **The rest** is the shared glue: the file watcher (`filechanged`, 6.1's nobox helper), the console + (8.4's qelem), the package paths, attributes (3.4's reconciliation), `reserved_messages()` and + `answered_by_max()`. `MIN_DESCRIPTION` is the contract (5.1): min writes + `docs/tap.python.maxref.xml` from it. +- **Glue test** (mock kernel): `[tap.python euclid]` has one inlet, two outlets (one plus dumpout); + a bang's list is in `object_getoutput(maxobj, 0)` (the mock records outlet sequences); a + `note_name` int fills outlet 1 then outlet 0; a str return arrives as an `anything`; `getsteps` + reaches the dumpout (if the mock routes it; else a runtime test); a `tuple[…]` save that widens + the class records the dynlet calls as 2.4's test does. + +## The package + +- **Build.** The runtime discovery block of `tap.python_tilde/CMakeLists.txt` (support/, weak link, + delay-load, rpaths per slice, bundle identifier, the `.mxo` touch) moves to + `source/cmake/embedded-python.cmake`, included by both objects' `CMakeLists.txt`; each object's + stays a page. Both externals build and test on Linux (the mock kernel), macOS and Windows. +- **CI.** `build.yml`: the data-import check (4.2), the bundle identifier, `lipo`/`otool` and the + rpath checks, and the Windows delay-load check run over both externals (a loop over + `externals/`, so a third would need nothing). `style.yml`: both objects' TUs in the clang-tidy + list and its header filter, and `source/shared/` added to clang-format's file list (today it + lists `source/projects/` and `core/` only). `scripts/tidy.sh` is TapHouse's and takes a repo's + own TUs — unchanged. +- **Packaging.** `assemble-package.py`'s `EXTERNALS` becomes a list per platform; `--merge` already + copies every external it finds. `package-info.json.in`'s description names both objects. The + release zips carry both; nothing else in `release.yml` names an external. +- **Docs.** The ReadMe: a `tap.python` section after the audio one — the loading line, the output + table, the honest limits (D10, threads, no output from Python's own threads, no dictionaries + yet) — and its performance note is one sentence: the cost is the method's Python, plus the + `call()` bridge measured once by `core/bench`. `help/tap.python.maxhelp`, by hand in Max, with + the three examples. `docs/tap.python.maxref.xml` from min (6.8's rule: commit Max's). CLAUDE.md: + the second object in the layout and the shared glue. `CHANGELOG.md`: 1.1.0 — a new object, no + change to `tap.python~`'s contract beyond the `list[…]` parameter, which only adds. +- **Runtime tests in Max** (`runtime-tests/`): `make_patchers.py` gains a non-signal `python()` + box; new patchers `tap.python.*.maxtest.maxpat`: load (no argument, each example); every output + row through `[print]`-free checks (a list into `test.assert`, an `anything` through `route`, two + outlets in order through `trigger`); `getsteps` from the dumpout; a save that widens the return + hint changes the outlets and the new one's cord carries (2.4's `channels` test, for control + outlets); one file shared by a `tap.python~` and a `tap.python`, saved: one `Loaded` line, both + reload; a message from a `metro` under Overdrive (the scheduler thread) outputs correctly and in + order; `sys.exit()` and an exception reported, the object alive; `anything` forwarded. The Mac + session that runs them is the phase's last item, with the help patcher. + +## Plan — Phase 9 of the production plan + +One PR each, in this order; each lands against tests that fail before it. + +- [ ] **9.1 The core: output, outlets and the audio option.** `value.h`'s `output_item`/`output` + and the Python-to-output conversion; `message_info::return_count`, `outlet_count()`, `call()` + returning the output; `bind_audio`; the `list[…]`/`np.ndarray` final parameter (for both + objects); the `Loaded` line per binding. `test_output.cpp` and fixtures. No Max code changes; + `tap.python~`'s battery and glue test unchanged and green, the bench numbers unchanged (the + audio path does not touch the new code). +- [ ] **9.2 The shared glue.** `tap.python_tilde_{attribute,message,cglue,filewatch,package}.h` + move to `source/shared/tap/python_max/` as `python_glue` (the trampolines instantiated in + each object's `.cpp` through `wrapper_find_self`); the runtime CMake block to + `source/cmake/embedded-python.cmake`; `style.yml`'s list and header filter follow. Pure move: + `tap.python~`'s behavior, tests and the data-import check unchanged. *Decide in the PR:* whether + `reserved_messages()` and `answered_by_max()` move too, parameterized by the audio names, or each + object keeps its list (the audio names are the only difference). +- [ ] **9.3 The object.** `source/projects/tap.python/`: ports with the dumpout, output mapping, + `anything` forwarding, dynamic outlets on reload, the reserved names, `MIN_DESCRIPTION`; the + glue test; the examples (`euclid.py`, `scale.py`, `note_name.py`, `default.py`'s `bang`); the + CI checks over both externals; `assemble-package.py` and `package-info.json.in`. CHANGELOG 1.1.0 + started. +- [ ] **9.4 Documentation.** The ReadMe section and the output table; CLAUDE.md; the help patcher + (JSON by hand, as 5.2 was, checked in Max in 9.6); the reference page from min against the mock + kernel (5.1's way), to be replaced by Max's in 9.6. +- [ ] **9.5 Runtime tests.** The patchers above, in `make_patchers.py`, and `run.py` aware of the + second external (its `EXTERNAL` check, the `--package` mode, the reference-page rule for both + pages). +- [ ] **9.6 The Mac session.** Build, run the whole runtime suite (both objects), check the help + patcher and re-save it, commit the pages Max writes, the hand checks the tests cannot make + (`get` through the dumpout in a patcher, attrui on a `tap.python`, a `tap.python~` and a + `tap.python` on one file saved while audio runs), then tag `v1.1.0`. + +## Later (not planned) + +Written down so they are decided rather than rediscovered; none is promised by 1.1. + +- **Dictionaries.** A `dict` return as a Max dictionary out (`dictionary ` through a + `t_dictionary` the object owns), and a `dictionary` message in as a `dict` argument. The natural + next step, and a real design: ownership of the named dictionary, nested values, and `jit`-style + `dictobj` registration. +- **Timers.** A class that wants to run on its own — a sequencer — needs a clock. Two shapes: an + attribute `@interval` ms calling a method `tick()` on the scheduler thread (nothing to import, + consistent with the rest), or a `self`-side API (a `schedule(ms, method)` injected at + construction, which breaks "runs in a notebook"). The first fits; measure a Python call per tick + against Max's own `metro` before promising timing. +- **More inlets.** min's `inlet<>` list makes proxies, and `proxy_getinlet()` says which one a + message came in; a mapping would be `inlets: ClassVar[int]` plus the inlet number as a first + argument to `anything`, or a method per inlet. Not until a use needs it: attributes cover the + cold-inlet idiom. +- **Output from Python's own threads.** A queue the class could post to from a `threading.Thread`, + drained by a qelem on the main thread — `node.script`'s shape. It needs the injected API the + timers item weighs. +- **`buffer~` and `jit.matrix` as numpy arrays.** Attractive and Max-specific: a `buffer~` name as + an attribute, its samples as an `np.ndarray` view under `buffer_locksamples()`. The locking rules + make it its own design. +- **Deferring to the main thread.** An `@defer` attribute running every message on the main + thread (`defer_low`), as `js` effectively does. `deferlow` in the patch does the same today; add + it only if users ask. diff --git a/icon.png b/icon.png index c2ab314..f59bcc2 100644 Binary files a/icon.png and b/icon.png differ diff --git a/scripts/assemble-package.py b/scripts/assemble-package.py index 13eae98..6d7cd9e 100644 --- a/scripts/assemble-package.py +++ b/scripts/assemble-package.py @@ -56,9 +56,10 @@ # Never shipped from the copied folders. # maxtest_*.py: the runtime tests' fixtures, copied into python/ while runtime-tests/run.py runs -# PRODUCTION-PLAN.md, AUDIT-*.md: the development roadmap and audits in docs/, beside the reference page Max reads +# PRODUCTION-PLAN.md, *-PLAN.md, AUDIT-*.md: the development roadmap, plans and audits in docs/, beside the +# reference pages Max reads IGNORED = shutil.ignore_patterns("__pycache__", "*.pyc", ".ipynb_checkpoints", ".DS_Store", "maxtest_*", - "PRODUCTION-PLAN.md", "AUDIT-*.md") + "PRODUCTION-PLAN.md", "*-PLAN.md", "AUDIT-*.md") # Each platform's folder for its runtime in a package for every platform (plan 4.8); the external diff --git a/source/projects/tap.python_tilde/tap.python_tilde.h b/source/projects/tap.python_tilde/tap.python_tilde.h index 8f6ade6..85b3cde 100644 --- a/source/projects/tap.python_tilde/tap.python_tilde.h +++ b/source/projects/tap.python_tilde/tap.python_tilde.h @@ -386,6 +386,14 @@ class python : public object, public vector_operator<> { return names; } + /// Whether object_getmethod() found a method. For a name the object does not answer, Max + /// returns method_false() — a function, as the SDK documents — not null; the mock kernel + /// returns null. 1.0.0 tested for null alone, so in Max every field and method of a class was + /// "answered by Max", and the object had no attributes and no messages (1.0.1). + static bool found_method(const c74::max::method found) { + return found != nullptr && found != reinterpret_cast(c74::max::method_false); + } + private: string m_python_source{}; std::filesystem::path m_scripts_dir{}; @@ -477,7 +485,7 @@ class python : public object, public vector_operator<> { if (m_python_messages.find(name) != m_python_messages.end()) { return false; } - return c74::max::object_getmethod(maxobj(), c74::max::gensym(name.c_str())) != nullptr; + return found_method(c74::max::object_getmethod(maxobj(), c74::max::gensym(name.c_str()))); } /// The worker thread's scheduling (plan 2.5): the real-time class an audio thread has, so that a diff --git a/source/projects/tap.python_tilde/tap.python_tilde_test.cpp b/source/projects/tap.python_tilde/tap.python_tilde_test.cpp index 12cf7ea..d515df3 100644 --- a/source/projects/tap.python_tilde/tap.python_tilde_test.cpp +++ b/source/projects/tap.python_tilde/tap.python_tilde_test.cpp @@ -51,7 +51,31 @@ namespace c74 { } return ac; } - void filewatcher_start(void*) {} + void filewatcher_start(void*) {} + // What Max's own does, which the mock's (always null) does not: method_false() for a name + // the object does not answer. 1.0.0 took that for "found" and reserved every Python name + // (plan 8.2's guard, found broken in Max; fixed in 1.0.1). The pretend class answers the + // names min registers for this object, plus one a test's class defines on purpose. + t_atom_long method_false(void*) { + return 0; + } + // The method the pretend class answers with. Its body must be its own: a linker that folds + // identical functions (MSVC's /OPT:ICF, on by default in Release; lld's --icf=all) gave a + // `return 0` here method_false's address, so every name read as unanswered on Windows. + void* answered_method(void*) { + static int s_marker; + return &s_marker; + } + method object_getmethod(void*, t_symbol* s) { + static const char* const k_answered[] = {"dsp64", "assist", "notify", + "filechanged", "dspsetup", "maxtest_host_answers"}; + for (const auto* name : k_answered) { + if (s == gensym(name)) { + return reinterpret_cast(answered_method); + } + } + return reinterpret_cast(method_false); + } void* qelem_new(void*, method) { static int s_qelem; return &s_qelem; @@ -417,13 +441,21 @@ SCENARIO("A save that changes process()'s inputs or outputs changes the object's SCENARIO("Methods named like messages Max sends with C arguments are not exposed, and the promised ones are " "(plan 8.2)") { ext_main(nullptr); + // the test's kernel must tell its two answers apart, or what follows tests the linker + REQUIRE(c74::max::object_getmethod(nullptr, c74::max::gensym("maxtest_host_answers")) + != reinterpret_cast(c74::max::method_false)); const auto file = tap::python::package_root() / "python" / "maxtest_mock_reserved.py"; write_file(file, "class maxtest_mock_reserved:\n" + // a field, and a method, which must be exposed whatever the guard answers + " gain: float = 0.75\n" // what Max calls with C arguments: a Python method of the name would crash Max " def dspstate(self, on: int) -> None:\n pass\n" " def fileusage(self) -> None:\n pass\n" " def patchlineupdate(self) -> None:\n pass\n" " def inputchanged(self) -> None:\n pass\n" + // a name only the guard reserves: the test's object_getmethod answers it as + // Max's would for a method the class registered + " def maxtest_host_answers(self) -> None:\n pass\n" // what the ReadMe promises stays a message, whatever the guard answers " def int(self, n: int) -> None:\n pass\n" " def float(self, x: float) -> None:\n pass\n" @@ -441,6 +473,16 @@ SCENARIO("Methods named like messages Max sends with C arguments are not exposed CHECK(my_object.python_message_names() == std::vector{"bang", "float", "greet", "int", "list", "symbol"}); } + THEN("the field is an attribute: a name Max does not answer is not reserved by the guard (1.0.1)") { + const auto got = get(my_object, "gain"); + CHECK(c74::max::atom_gettype(&got) == c74::max::A_FLOAT); + CHECK(c74::max::atom_getfloat(&got) == 0.75); + } + THEN("the method's name decides nothing by itself: what object_getmethod() answers does") { + CHECK(python::found_method(nullptr) == false); + CHECK(python::found_method(reinterpret_cast(c74::max::method_false)) == false); + CHECK(python::found_method(reinterpret_cast(c74::max::object_getmethod)) == true); + } THEN("audio is bound regardless") { CHECK(all_equal(render(my_object, 0.5), 0.5)); }