From 174d3287709d5770b00b871f029b48c6c27b9155 Mon Sep 17 00:00:00 2001 From: Chris Hyorok Lee Date: Tue, 24 Mar 2026 17:56:38 -0400 Subject: [PATCH 1/7] Add native Max for Live (.amxd) device support Integrate .amxd binary save/load directly into the maxpylang package so users can create M4L devices with a single explicit flag: patch.save("synth.amxd", device_type="instrument") - Add maxpylang/amxd.py with save_amxd/load_amxd functions - Extend patch.save() with device_type parameter (instrument/audio_effect/midi_effect) - Auto-detect .amxd from extension, require device_type, force extension when set - Support loading .amxd files via MaxPatch(load_file="device.amxd") - Export save_amxd, load_amxd, DEVICE_TYPES from package - Add M4L instrument and audio effect examples - Add class_transition.md and edge_cases.md planning docs Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/m4l_audio_effect/main.py | 66 ++ examples/m4l_instrument/main.py | 57 ++ maxpylang/__init__.py | 4 +- maxpylang/amxd.py | 55 ++ maxpylang/tools/patchfuncs/instantiation.py | 17 +- maxpylang/tools/patchfuncs/saving.py | 57 +- tasks/class_transition.md | 789 ++++++++++++++++++++ tasks/edge_cases.md | 353 +++++++++ 8 files changed, 1374 insertions(+), 24 deletions(-) create mode 100644 examples/m4l_audio_effect/main.py create mode 100644 examples/m4l_instrument/main.py create mode 100644 maxpylang/amxd.py create mode 100644 tasks/class_transition.md create mode 100644 tasks/edge_cases.md diff --git a/examples/m4l_audio_effect/main.py b/examples/m4l_audio_effect/main.py new file mode 100644 index 0000000..29b0fa3 --- /dev/null +++ b/examples/m4l_audio_effect/main.py @@ -0,0 +1,66 @@ +""" +Max for Live Audio Effect Example +=================================== +A simple lowpass filter that runs as a Max for Live audio effect in Ableton. + +Signal chain: + plugin~ (stereo from track) → lores~ (lowpass filter) → clip~ → plugout~ (stereo) + +Usage: + python main.py + → Generates m4l_audio_effect.amxd + → Drag onto an audio track (or after an instrument) in Ableton Live +""" + +import maxpylang as mp + +patch = mp.MaxPatch() + +# === AUDIO INPUT === +patch.set_position(30, 30) +patch.place("comment === AUDIO INPUT ===")[0] + +patch.set_position(30, 60) +plugin = patch.place("plugin~")[0] # stereo audio from track + +# === FILTER === +patch.set_position(30, 120) +patch.place("comment === FILTER ===")[0] + +# Left channel filter +patch.set_position(30, 150) +filt_l = patch.place("lores~ 2000 0.5")[0] + +# Right channel filter +patch.set_position(200, 150) +filt_r = patch.place("lores~ 2000 0.5")[0] + +# === OUTPUT === +patch.set_position(30, 220) +patch.place("comment === OUTPUT ===")[0] + +patch.set_position(30, 250) +clip_l = patch.place("clip~ -1. 1.")[0] + +patch.set_position(200, 250) +clip_r = patch.place("clip~ -1. 1.")[0] + +patch.set_position(30, 290) +plugout = patch.place("plugout~")[0] + +# === CONNECTIONS === +patch.connect( + # plugin~ stereo → filters + [plugin.outs[0], filt_l.ins[0]], # left in → left filter + [plugin.outs[1], filt_r.ins[0]], # right in → right filter + # filters → safety limiters + [filt_l.outs[0], clip_l.ins[0]], + [filt_r.outs[0], clip_r.ins[0]], + # limiters → plugout~ stereo + [clip_l.outs[0], plugout.ins[0]], # left → Ableton + [clip_r.outs[0], plugout.ins[1]], # right → Ableton +) + +# === SAVE === +# device_type="audio_effect" is the explicit flag for M4L audio effects +patch.save("m4l_audio_effect.amxd", device_type="audio_effect") diff --git a/examples/m4l_instrument/main.py b/examples/m4l_instrument/main.py new file mode 100644 index 0000000..7ac7cc9 --- /dev/null +++ b/examples/m4l_instrument/main.py @@ -0,0 +1,57 @@ +""" +Max for Live Instrument Example +================================ +A minimal sine synth that runs as a Max for Live MIDI instrument in Ableton. + +Signal chain: + notein → mtof → cycle~ → clip~ -1. 1. → plugout~ (stereo) + +Usage: + python main.py + → Generates m4l_instrument.amxd + → Drag onto a MIDI track in Ableton Live +""" + +import maxpylang as mp + +patch = mp.MaxPatch() + +# === MIDI INPUT === +patch.set_position(30, 30) +patch.place("comment === MIDI INPUT ===")[0] + +patch.set_position(30, 60) +notein = patch.place("notein")[0] + +patch.set_position(30, 100) +mtof = patch.place("mtof")[0] + +# === OSCILLATOR === +patch.set_position(30, 160) +patch.place("comment === OSCILLATOR ===")[0] + +patch.set_position(30, 190) +osc = patch.place("cycle~")[0] + +# === OUTPUT === +patch.set_position(30, 250) +patch.place("comment === OUTPUT ===")[0] + +patch.set_position(30, 280) +clip = patch.place("clip~ -1. 1.")[0] + +patch.set_position(30, 320) +plugout = patch.place("plugout~")[0] + +# === CONNECTIONS === +patch.connect( + [notein.outs[0], mtof.ins[0]], # note number → mtof + [mtof.outs[0], osc.ins[0]], # frequency → oscillator + [osc.outs[0], clip.ins[0]], # audio → safety limiter + [clip.outs[0], plugout.ins[0]], # left channel → Ableton + [clip.outs[0], plugout.ins[1]], # right channel → Ableton +) + +# === SAVE === +# device_type="instrument" is the explicit flag for M4L instruments +patch.save("m4l_instrument.amxd", device_type="instrument") diff --git a/maxpylang/__init__.py b/maxpylang/__init__.py index 99a2854..516b3d6 100644 --- a/maxpylang/__init__.py +++ b/maxpylang/__init__.py @@ -21,12 +21,13 @@ patch.place(*objs, num_objs=1, spacing_type="grid", spacing=[80,80], starting_pos=None, verbose=False) -> list[MaxObject] patch.connect(*connections, verbose=True) - patch.save(filename="default.maxpat", verbose=True, check=True) + patch.save(filename="default.maxpat", device_type=None, verbose=True, check=True) patch.set_position(new_x, new_y) - ``place()`` **always returns a list** — use ``[0]`` for a single object. - Each connection is ``[outlet, inlet]``: ``patch.connect([obj1.outs[0], obj2.ins[0]])``. - ``save()`` auto-appends ``.maxpat`` if missing. +- For Max for Live devices: ``patch.save("device.amxd", device_type="instrument")``. Properties: ``patch.objs`` (dict), ``patch.num_objs`` (int), ``patch.curr_position`` (list). @@ -185,6 +186,7 @@ from .maxpatch import MaxPatch from .importobjs import import_objs from .xlet import Inlet, Outlet +from .amxd import save_amxd, load_amxd, DEVICE_TYPES try: from . import objects diff --git a/maxpylang/amxd.py b/maxpylang/amxd.py new file mode 100644 index 0000000..c80aba1 --- /dev/null +++ b/maxpylang/amxd.py @@ -0,0 +1,55 @@ +""" +amxd.py — Save and load Max for Live .amxd files. + +The .amxd format is a binary wrapper around the same JSON that .maxpat uses. +Three chunks: ampf (device type), meta (reserved), ptch (patcher JSON + null). +""" + +import struct +import json + +DEVICE_TYPES = { + "audio_effect": b"aaaa", + "midi_effect": b"mmmm", + "instrument": b"iiii", +} + + +def save_amxd(patcher_json, filename, device_type="instrument"): + """Wrap a patcher JSON dict in .amxd binary format and write to file.""" + if device_type not in DEVICE_TYPES: + raise ValueError(f"Unknown device_type {device_type!r}. " + f"Choose from: {', '.join(DEVICE_TYPES)}") + + json_bytes = json.dumps(patcher_json, indent=2).encode("utf-8") + b"\x00" + + with open(filename, "wb") as f: + # ampf chunk — device type identifier + f.write(b"ampf") + f.write(struct.pack(" create patch from template - load_file() --> create patch from existing .maxpat file - load_objs_from_dict() --> create objects from existing .maxpat file dict - load_patchcords_from_dict() --> create patchcords from existing .maxpat file dict + load_file() --> create patch from existing .maxpat or .amxd file + load_objs_from_dict() --> create objects from existing file dict + load_patchcords_from_dict() --> create patchcords from existing file dict clean_patcher_dict() --> get cleaned patcher dict """ import os import json +from pathlib import Path from maxpylang.maxobject import MaxObject @@ -59,9 +60,13 @@ def load_file(self, f, reorder=True, verbose=True): if verbose: print("Patcher: loading patch from existing file:", os.path.split(f)[-1]) - #read .maxpat file into dict - with open(f, 'r') as file: - patch_dict = json.loads(file.read()) + #read .maxpat or .amxd file into dict + if Path(f).suffix == ".amxd": + from ...amxd import load_amxd + patch_dict = load_amxd(f) + else: + with open(f, 'r') as file: + patch_dict = json.loads(file.read()) #load in objs diff --git a/maxpylang/tools/patchfuncs/saving.py b/maxpylang/tools/patchfuncs/saving.py index 8d9d736..63299d2 100644 --- a/maxpylang/tools/patchfuncs/saving.py +++ b/maxpylang/tools/patchfuncs/saving.py @@ -3,7 +3,7 @@ Methods related to saving MaxPatches to files. - save() --> save MaxPatch to file + save() --> save MaxPatch to file (.maxpat or .amxd) get_json() --> get json representation of MaxPatch """ @@ -12,40 +12,63 @@ import copy #save patch to file -def save(self, filename="default.maxpat", verbose=True, check=True): +def save(self, filename="default.maxpat", device_type=None, verbose=True, check=True): """ - Save to .maxpat file. + Save to .maxpat or .amxd file. Usage: - filename --> savefile name + filename --> savefile name (.maxpat or .amxd) + device_type --> for Max for Live .amxd files: "instrument", "audio_effect", or "midi_effect" + required when saving as .amxd; when set, forces .amxd extension verbose --> print log message to console check --> run check_patch before saving """ - #check proper extension - if ".maxpat" not in Path(filename).suffixes: - filename += ".maxpat" + ext = Path(filename).suffix - #get json rep - json_dict = self.get_json() + if ext == ".amxd" or device_type is not None: + # Max for Live save path + from ...amxd import save_amxd + + if device_type is None: + raise ValueError( + "device_type is required for .amxd files. " + "Choose from: 'instrument', 'audio_effect', 'midi_effect'" + ) + + if ".amxd" not in Path(filename).suffixes: + # strip .maxpat if present, add .amxd + filename = str(Path(filename).with_suffix(".amxd")) + + json_dict = self.get_json() + save_amxd(json_dict, filename, device_type=device_type) + + else: + # Standard .maxpat save path + if ".maxpat" not in Path(filename).suffixes: + filename += ".maxpat" + + json_dict = self.get_json() + + with open(filename, 'w') as f: + json.dump(json_dict, f, indent=2) - #write json to file - with open(filename, 'w') as f: - json.dump(json_dict, f, indent=2) - #save filepath for later saving self._filename = filename - + #log unknown objs and unlinked js objs #(abstractions only get marked as abstractions if the file is found) - #also log linked abstractions and linked js files + #also log linked abstractions and linked js files if check: self.check('unknown', 'js', 'abstractions') - + #log messages if verbose: - print("maxpatch saved to", filename) + if device_type: + print(f"maxpatch saved to {filename} (M4L {device_type})") + else: + print("maxpatch saved to", filename) return diff --git a/tasks/class_transition.md b/tasks/class_transition.md new file mode 100644 index 0000000..d1516f3 --- /dev/null +++ b/tasks/class_transition.md @@ -0,0 +1,789 @@ +# Migration Plan: String Syntax → Class-Based Object API + +## Table of Contents +1. [Problem Statement](#1-problem-statement) +2. [Current Architecture](#2-current-architecture) +3. [Target Architecture](#3-target-architecture) +4. [Design Decisions](#4-design-decisions) +5. [Implementation Phases](#5-implementation-phases) +6. [Edge Cases & Challenges](#6-edge-cases--challenges) +7. [File Change Summary](#7-file-change-summary) +8. [Verification Plan](#8-verification-plan) + +--- + +## 1. Problem Statement + +MaxPyLang currently has two incompatible ways to create objects: + +| Approach | Example | Pros | Cons | +|----------|---------|------|------| +| **String syntax** | `patch.place("cycle~ 440")` | Supports args, attributes | No autocomplete, fragile string concat, Max-specific `@` syntax | +| **Stub instances** | `patch.place(cycle_tilde)` | IDE autocomplete for object names | Cannot pass arguments, must use `edit()` after | + +Neither approach offers the full Python developer experience. The goal is a **single, unified class-based API** that combines the discoverability of stubs with the expressiveness of string syntax. + +### What's Wrong Today + +```python +# String concatenation for dynamic args — fragile, no type safety +freq = 440 +patch.place("cycle~ " + str(freq)) + +# Stubs can't take args — requires awkward two-step +osc = patch.place(cycle_tilde)[0] +osc.edit(text="440") + +# Attributes use Max-specific @ syntax inside strings +patch.place("metro 500 @active 1") + +# No IDE help for which args/attribs an object accepts +patch.place("cycle~ ???") # what arguments does cycle~ take? +``` + +### What We Want + +```python +# Direct Python arguments — type safe, clean +osc = patch.place(cycle_tilde(440))[0] + +# Attributes as keyword arguments — Pythonic +m = patch.place(metro(500, active=1))[0] + +# Dynamic I/O objects work naturally +p = patch.place(pack(0, 0, 0))[0] # → 3 inlets + +# IDE autocomplete for object names + docstrings for args +cycle_tilde( # IDE shows: frequency(number), buffer_name(symbol), ... +``` + +--- + +## 2. Current Architecture + +### Object Creation Flow + +``` +User Code Internal Pipeline +───────── ───────────────── +"cycle~ 440" ──┐ + ├──→ MaxObject.__init__(text) +cycle_tilde ──┘ │ + ├──→ parse_text() → name="cycle~", args=[440], text_attribs={} + ├──→ get_ref("cycle~") → finds cycle~.json in data/OBJ_INFO/msp/ + ├──→ get_info() → loads default dict, arg specs, attrib specs, I/O rules + ├──→ args_valid() → validates arg types against spec + ├──→ make_xlets_from_self_dict() → creates Inlet/Outlet objects + ├──→ update_ins_outs() → adjusts I/O for dynamic objects + └──→ update_text() → rebuilds text field in dict +``` + +### Key Internal State (MaxObject instance) + +| Field | Type | Description | +|-------|------|-------------| +| `_name` | `str` | Object class name (e.g. `"cycle~"`) | +| `_args` | `list` | Positional args (e.g. `[440.0]`) | +| `_text_attribs` | `dict` | In-box @-attributes (e.g. `{"active": ["1"]}`) | +| `_dict` | `dict` | Full JSON representation for .maxpat serialization | +| `_ref_file` | `str\|None` | Path to JSON ref file, `"abstraction"`, or `None` | +| `_ins` / `_outs` | `list` | Inlet/Outlet objects | +| `_ext_file` | `str\|None` | External file path (for js/abstractions) | + +### Stub Files (Current) + +`maxpylang/objects/msp.py` (auto-generated, ~10k lines, ~460 objects): +```python +from maxpylang.maxobject import MaxObject + +__all__ = ['cycle_tilde', 'ezdac_tilde', ...] +_NAMES = {'cycle_tilde': 'cycle~', 'ezdac_tilde': 'ezdac~', ...} + +# stdout suppressed during instantiation (each creates a full MaxObject) +_devnull = open(_os.devnull, 'w') +_sys.stdout = _devnull + +cycle_tilde = MaxObject('cycle~') # reads JSON, parses text, creates xlets +ezdac_tilde = MaxObject('ezdac~') # same for every object... +# ... ~460 more + +_sys.stdout = _old_stdout +``` + +**Problems:** Slow import (each stub reads a JSON file + full instantiation), stdout suppression hack, stubs are instances not factories. + +### Reference Data (JSON per object) + +Example `data/OBJ_INFO/msp/cycle~.json`: +```json +{ + "default": { + "box": { + "maxclass": "newobj", + "numinlets": 2, "numoutlets": 1, + "outlettype": ["signal"], + "text": "cycle~" + } + }, + "args": { + "required": [], + "optional": [ + {"name": "frequency", "units": "hz", "type": ["number"]}, + {"name": "buffer-name", "type": ["symbol"]}, + {"name": "sample-offset", "type": ["int"]} + ] + }, + "attribs": [ + {"name": "COMMON"}, + {"name": "buffer", "type": "symbol", "size": "1"}, + {"name": "frequency", "type": "float", "size": "1"}, + {"name": "phase", "type": "float", "size": "1"} + ], + "in/out": {} +} +``` + +Example `data/OBJ_INFO/max/pack.json` (dynamic I/O): +```json +{ + "args": { + "required": [], + "optional": [{"name": "list-elements", "type": ["any"]}] + }, + "in/out": { + "numinlets": [{"argtype": "a", "index": "all", "type": null}] + } +} +``` + +--- + +## 3. Target Architecture + +### New Class: `MaxObjectSpec` + +A **lightweight callable factory** that stores object metadata and produces `MaxObject` instances when invoked. + +``` +User Code Internal Pipeline (unchanged) +───────── ──────────────────────────── +cycle_tilde(440) + │ + ├──→ MaxObjectSpec.__call__() + │ ├──→ builds text: "cycle~ 440" + │ ├──→ partitions kwargs → text_attribs vs extra_attribs + │ └──→ MaxObject(text, **extra_attribs) ──→ existing pipeline + │ + └──→ Returns MaxObject instance (same as before) +``` + +Key insight: **`MaxObjectSpec` is a thin wrapper that delegates to the existing `MaxObject` pipeline.** No changes needed to serialization, connection logic, or patch saving. + +### API Comparison (Before → After) + +```python +# ── Basic object with args ── +# Before: +osc = patch.place("cycle~ 440")[0] +# After: +osc = patch.place(cycle_tilde(440))[0] + +# ── Object with @-attributes ── +# Before: +m = patch.place("metro 500 @active 1")[0] +# After: +m = patch.place(metro(500, active=1))[0] + +# ── Dynamic I/O ── +# Before: +p = patch.place("pack 0 0 0")[0] # 3 inlets +t = patch.place("trigger b i f")[0] # 3 outlets +# After: +p = patch.place(pack(0, 0, 0))[0] # 3 inlets +t = patch.place(trigger("b", "i", "f"))[0] # 3 outlets + +# ── No-arg objects ── +# Before: +dac = patch.place(ezdac_tilde)[0] # stub instance +# After: +dac = patch.place(ezdac_tilde())[0] # factory call with no args + +# ── Dynamic args in loops ── +# Before: +for freq in [220, 440, 880]: + patch.place(f"cycle~ {freq}") # f-string construction +# After: +for freq in [220, 440, 880]: + patch.place(cycle_tilde(freq)) # native Python args + +# ── Editing after placement ── +# Before: +osc.edit(text="880") +# After (Phase 3): +osc.edit(880) + +# ── Abstractions (unchanged) ── +synth = mp.MaxObject("my_synth", abstraction=True, inlets=2, outlets=2) +placed = patch.place(synth)[0] +``` + +--- + +## 4. Design Decisions + +### Why callable factories, not subclasses? + +**Option A — One class per object** (`class Cycle(MaxObject): ...`): +- Would need ~1140 class definitions +- Inheritance hierarchy unclear (Cycle inherits from MaxObject?) +- `place()` returns `MaxObject`, not `Cycle` — confusing +- Class identity checks (`isinstance(obj, Cycle)`) not useful since all objects serialize the same way + +**Option B — Enhanced `MaxObject` constructor** (`MaxObject("cycle~", freq=440)`): +- Still requires string name — no autocomplete benefit +- Mixes factory pattern into the data class + +**Option C — Callable factories (chosen)** (`cycle_tilde(440) → MaxObject`): +- Stubs already exist with correct names — just make them callable +- Returns `MaxObject` (consistent with current API) +- Metadata stored once, objects created on demand +- Massive import speedup (no more eager instantiation) +- Generation pipeline stays simple + +### How are kwargs partitioned? + +When a user writes `metro(500, active=1)`, the `active` kwarg needs to become `@active 1` in the Max text string (it's an object-specific attribute). But `fontsize=12` would be a common box attribute passed as `**extra_attribs` to `MaxObject`. + +`MaxObjectSpec._partition_attribs()` uses the embedded `attrib_spec` to decide: +- If the kwarg name matches an entry in `attrib_spec` (excluding `COMMON`) → text attribute (`@name val`) +- Otherwise → extra attribute (passed as `**kwargs` to `MaxObject` constructor) + +### What about no-arg calls? + +`ezdac_tilde()` with no arguments produces `MaxObject('ezdac~')` — identical to the current stub behavior. The `()` is required because `ezdac_tilde` is now a factory, not an instance. + +**Breaking change:** `patch.place(ezdac_tilde)` (without parens) will still work during the transition because `place()` will detect `MaxObjectSpec` and call it with no args. But this will emit a deprecation warning. + +### What about `place()` accepting `MaxObjectSpec` directly? + +For convenience during transition, `place()` will detect if an argument is a `MaxObjectSpec` (not yet called) and call it with no args. This means `patch.place(ezdac_tilde)` and `patch.place(ezdac_tilde())` both work, but the former emits a deprecation warning nudging toward the latter. + +--- + +## 5. Implementation Phases + +### Phase 0: `MaxObjectSpec` Foundation + +**Goal:** Add the new factory class, wire it into `place()`, validate with tests. No changes to existing behavior. + +#### 5.0.1 — Create `maxpylang/maxobjectspec.py` + +```python +""" +Callable factory for Max objects. + +MaxObjectSpec stores object metadata (arg specs, attribute specs) and +produces MaxObject instances when called with Python arguments. +""" +import warnings + + +class MaxObjectSpec: + """ + A callable factory that creates MaxObject instances. + + Instead of constructing MaxObjects from text strings, use a + MaxObjectSpec to pass arguments as native Python values: + + cycle_tilde(440) → MaxObject('cycle~ 440') + metro(500, active=1) → MaxObject('metro 500 @active 1') + pack(0, 0, 0) → MaxObject('pack 0 0 0') + """ + + def __init__(self, max_name, arg_spec=None, attrib_spec=None, docstring=""): + self._max_name = max_name + self._arg_spec = arg_spec or {"required": [], "optional": []} + self._attrib_spec = attrib_spec or [] + if docstring: + self.__doc__ = docstring + + def __call__(self, *args, **attribs): + from .maxobject import MaxObject # deferred to avoid circular import + + # Build text: "cycle~ 440 @phase 0.5" + parts = [self._max_name] + for arg in args: + parts.append(str(arg)) + + # Partition kwargs into text attribs vs extra (box) attribs + text_attribs, extra_attribs = self._partition_attribs(attribs) + for attr_name, attr_val in text_attribs.items(): + parts.append(f"@{attr_name}") + if isinstance(attr_val, (list, tuple)): + parts.extend(str(v) for v in attr_val) + else: + parts.append(str(attr_val)) + + text = " ".join(parts) + return MaxObject(text, **extra_attribs) + + def _partition_attribs(self, attribs): + obj_attrib_names = { + a['name'] for a in self._attrib_spec + if a.get('name') and a.get('name') != 'COMMON' + } + text_attribs = {} + extra_attribs = {} + for key, val in attribs.items(): + if key in obj_attrib_names: + text_attribs[key] = val + else: + extra_attribs[key] = val + return text_attribs, extra_attribs + + @property + def max_name(self): + """The Max object class name (e.g. 'cycle~').""" + return self._max_name + + @property + def arg_spec(self): + """Argument specification from the object's reference file.""" + return self._arg_spec + + @property + def attrib_spec(self): + """Attribute specification from the object's reference file.""" + return self._attrib_spec + + def __repr__(self): + return f"MaxObjectSpec('{self._max_name}')" +``` + +#### 5.0.2 — Modify `maxpylang/tools/patchfuncs/placing.py` + +**`get_obj_from_spec()` (line 383):** Add `MaxObjectSpec` handling. + +```python +def get_obj_from_spec(self, obj_spec): + from maxpylang.maxobjectspec import MaxObjectSpec + + if isinstance(obj_spec, str): + obj = MaxObject(obj_spec) + elif isinstance(obj_spec, MaxObjectSpec): + obj = obj_spec() # call factory with no args + else: + assert isinstance(obj_spec, MaxObject), \ + f"object must be specified as a string, MaxObject, or MaxObjectSpec" + obj = obj_spec + + return obj +``` + +**`place_check_args()` (line 110-113):** Add `MaxObjectSpec` to isinstance check. + +```python +from maxpylang.maxobjectspec import MaxObjectSpec + +for obj in objs: + assert isinstance(obj, (MaxObject, MaxObjectSpec, str, list)), \ + f"objs list must be strings, MaxObjects, or MaxObjectSpecs" +``` + +#### 5.0.3 — Modify `maxpylang/__init__.py` + +Add `MaxObjectSpec` to exports. + +#### 5.0.4 — Create `tests/test_maxobjectspec.py` + +Test cases: +- `MaxObjectSpec('cycle~', arg_spec=...)(440)` → MaxObject with `name == "cycle~"`, `_args == [440.0]` +- `MaxObjectSpec('metro', ...)(500, active=1)` → text contains `"metro 500 @active 1"` +- `MaxObjectSpec('pack', ...)(0, 0, 0)` → MaxObject with 3 inlets (dynamic I/O) +- `MaxObjectSpec('trigger', ...)("b", "i", "f")` → MaxObject with 3 outlets +- `MaxObjectSpec('ezdac~', ...)()` → MaxObject with name `"ezdac~"` and no args +- `patch.place(MaxObjectSpec('cycle~', ...)())` → works, returns list of MaxObject +- `patch.place(MaxObjectSpec('cycle~', ...))` → works (auto-called with no args) +- Unknown attrib kwarg → emits warning + +--- + +### Phase 1: Update Auto-Generation Pipeline + +**Goal:** `importobjs.py` generates `MaxObjectSpec` instances instead of `MaxObject` instances. Regenerate all stub files. + +#### 5.1.1 — Modify `maxpylang/importobjs.py` → `generate_stubs()` (line 693) + +**Before** (current generation per object): +```python +stub_lines.append(f"{py_name} = MaxObject('{max_name}')") +``` + +**After:** +```python +import json as _json + +# Embed specs directly from the JSON reference file +arg_spec_str = _json.dumps(obj_info.get('args', {}), indent=None) +attrib_spec_str = _json.dumps(obj_info.get('attribs', []), indent=None) + +stub_lines.append(f"{py_name} = MaxObjectSpec(") +stub_lines.append(f" '{max_name}',") +stub_lines.append(f" arg_spec={arg_spec_str},") +stub_lines.append(f" attrib_spec={attrib_spec_str},") +stub_lines.append(f" docstring={repr(docstring)},") +stub_lines.append(f")") +``` + +**Other changes to `generate_stubs()`:** +- Line 731: Change import from `MaxObject` to `MaxObjectSpec` +- Lines 750-753: **Remove** stdout suppression hack (`_devnull`, `_old_stdout`) +- Lines 770-773: **Remove** stdout restoration +- Keep `_NAMES` dict for now (backward compat); mark for removal in Phase 5 + +**Generated output changes (example):** + +```python +# Before +""" +cycle~ - Sinusoidal oscillator +... +""" +cycle_tilde = MaxObject('cycle~') + +# After +""" +cycle~ - Sinusoidal oscillator +... +""" +cycle_tilde = MaxObjectSpec( + 'cycle~', + arg_spec={"required": [], "optional": [{"name": "frequency", "units": "hz", "type": ["number"]}, ...]}, + attrib_spec=[{"name": "COMMON"}, {"name": "buffer", "type": "symbol", "size": "1"}, ...], + docstring="cycle~ - Sinusoidal oscillator\n\nArgs:\n frequency (number, optional)\n...", +) +``` + +#### 5.1.2 — Modify `maxpylang/objects/__init__.py` + +Remove the `UnknownObjectWarning` suppression — no longer needed since `MaxObjectSpec.__init__` doesn't instantiate `MaxObject`. + +```python +# Before +import warnings +from maxpylang.exceptions import UnknownObjectWarning + +with warnings.catch_warnings(): + warnings.simplefilter("ignore", UnknownObjectWarning) + try: + from .jit import * + except ImportError: + pass + ... + +# After +try: + from .jit import * +except ImportError: + pass +try: + from .max import * +except ImportError: + pass +try: + from .msp import * +except ImportError: + pass +``` + +#### 5.1.3 — Regenerate stub files + +Run `import_objs('vanilla', overwrite=True)` (requires Max open) to regenerate: +- `maxpylang/objects/max.py` (~470 objects) +- `maxpylang/objects/msp.py` (~460 objects) +- `maxpylang/objects/jit.py` (~210 objects) + +**Performance impact:** Import time for `maxpylang.objects` drops from ~1140 JSON reads + MaxObject instantiations to ~1140 dict literal constructions. Expected speedup: 10-50x. + +--- + +### Phase 2: Deprecation Warnings + +**Goal:** Nudge users toward the new API. Old code still works but emits warnings. + +#### 5.2.1 — Modify `maxpylang/maxobject.py` (line 32) + +Add private `_from_spec` parameter: + +```python +def __init__(self, text, from_dict=False, abstraction=False, + inlets=None, outlets=None, _from_spec=False, **extra_attribs): + ... + if not from_dict and not abstraction and not _from_spec and isinstance(text, str): + warnings.warn( + f"String-based MaxObject construction is deprecated. " + f"Use class-based stubs from maxpylang.objects instead.\n" + f" Example: cycle_tilde(440) instead of MaxObject('cycle~ 440')", + DeprecationWarning, stacklevel=2 + ) + ... +``` + +#### 5.2.2 — Modify `maxpylang/maxobjectspec.py` → `__call__` + +Pass `_from_spec=True` so the warning doesn't fire for factory-created objects: + +```python +return MaxObject(text, _from_spec=True, **extra_attribs) +``` + +#### 5.2.3 — Modify `maxpylang/tools/patchfuncs/placing.py` → `get_obj_from_spec()` + +Add deprecation warning for string args in `place()`: + +```python +if isinstance(obj_spec, str): + warnings.warn( + f"Passing strings to place() is deprecated. " + f"Use class-based stubs: place(cycle_tilde(440)) instead of place('cycle~ 440')", + DeprecationWarning, stacklevel=3 + ) + obj = MaxObject(obj_spec, _from_spec=True) # suppress double warning +``` + +Add deprecation warning for uncalled `MaxObjectSpec` in `place()`: + +```python +elif isinstance(obj_spec, MaxObjectSpec): + warnings.warn( + f"Passing uncalled MaxObjectSpec to place() is deprecated. " + f"Call it with parentheses: place({obj_spec.max_name}()) instead of place({obj_spec.max_name})", + DeprecationWarning, stacklevel=3 + ) + obj = obj_spec() +``` + +--- + +### Phase 3: Enhanced `edit()` API + +**Goal:** Make `edit()` accept native Python arguments, matching the `MaxObjectSpec` pattern. + +#### 5.3.1 — Modify `maxpylang/tools/objfuncs/exposed.py` (line 24) + +```python +# Before +def edit(self, text_add="append", text=None, **extra_attribs): + +# After +def edit(self, *args, text_add="append", text=None, **attribs): + """ + Edit an object by adding/replacing arguments and attributes. + + New API (class-based): + obj.edit(440) # set/append positional arg + obj.edit(440, phase=0.5) # arg + attribute + obj.edit(text_add="replace", 880) # replace all args + + Legacy API (still supported): + obj.edit(text="440") # text-based append + obj.edit(text="440", text_add="replace") + """ + + if text is not None: + # Legacy path — existing behavior unchanged + ... (current implementation) + elif args or attribs: + # New path — convert Python args to text, partition attribs + new_args = list(args) + # Partition attribs into text_attribs and extra_attribs + # using object's reference info (same logic as MaxObjectSpec) + ... + else: + return # nothing to edit +``` + +Full backward compatibility: any code using `edit(text="...")` or `edit(**extra_attribs)` works unchanged. + +--- + +### Phase 4: Documentation & Examples + +**Goal:** Update all documentation and examples to use the new class-based API. + +#### Files to update: + +| File | Key Changes | +|------|-------------| +| `CLAUDE.md` | All code examples: string syntax → class-based calls | +| `examples/hello_world/main.py` | `"cycle~ 440"` → `cycle_tilde(440)` | +| `examples/attributes/main.py` | `"metro 500 @active 1"` → `metro(500, active=1)` | +| `examples/random_pitch_generator/tester3.py` | f-string args → Python args in loops | +| `examples/selective-midi-note-generator/tester2.py` | Same pattern | +| `examples/chess-paper-example/...` | Migrate | +| `examples/stocksonification_v1/...` | Migrate | +| `examples/basic-sonification-using-abstracted-csvReader/...` | Migrate | + +#### Documentation pattern: + +```python +# Before (CLAUDE.md) +osc = patch.place("cycle~ 440")[0] +gain = patch.place(gain_tilde)[0] +dac = patch.place(ezdac_tilde)[0] + +# After (CLAUDE.md) +from maxpylang.objects import cycle_tilde, gain_tilde, ezdac_tilde + +osc = patch.place(cycle_tilde(440))[0] +gain = patch.place(gain_tilde())[0] +dac = patch.place(ezdac_tilde())[0] +``` + +--- + +### Phase 5 (Future): Remove String Syntax + +**Goal:** Clean removal after deprecation period. + +| Removal | Location | +|---------|----------| +| String branch in `get_obj_from_spec()` | `placing.py` line 389 | +| Direct string `MaxObject.__init__` (public) | `maxobject.py` line 32 (keep `_from_spec` path) | +| `_NAMES` dict | `objects/max.py`, `msp.py`, `jit.py` | +| `text` parameter in `edit()` | `exposed.py` line 24 | +| `_from_spec` parameter | `maxobject.py` (no longer needed) | + +**What stays:** `MaxObject(text, _from_spec=True)` — used internally by `MaxObjectSpec.__call__()`. The text-based pipeline is preserved as an internal mechanism; only the public string API is removed. + +--- + +## 6. Edge Cases & Challenges + +### 6.1 — Backward Compatibility: `place(stub)` Without Parens + +Currently: `patch.place(cycle_tilde)` works because `cycle_tilde` is a `MaxObject`. +After Phase 1: `cycle_tilde` is a `MaxObjectSpec`, not a `MaxObject`. + +**Solution:** `get_obj_from_spec()` detects `MaxObjectSpec` and calls it with no args. Phase 2 adds a deprecation warning encouraging `place(cycle_tilde())`. + +### 6.2 — Attribute Name Collisions + +`cycle~` has both an arg named `frequency` and an attribute named `frequency`. In `MaxObjectSpec.__call__`, positional args go to `*args` and attributes go to `**kwargs` — no collision: + +```python +cycle_tilde(440, frequency=220) +# → *args=(440,), **attribs={"frequency": 220} +# → text: "cycle~ 440 @frequency 220" +``` + +The positional `440` becomes the in-box argument; the keyword `frequency=220` becomes the `@frequency` attribute. This matches how Max itself distinguishes between typed-in arguments and explicitly set attributes. + +### 6.3 — Dynamic Argument Building in Loops + +Current pattern in examples: +```python +for i in range(voices): + freqs.append(f"cycle~ {220 * (i+1)}") + patch.place(freqs[-1]) +``` + +New pattern: +```python +for i in range(voices): + patch.place(cycle_tilde(220 * (i+1))) +``` + +### 6.4 — Objects With No Stubs (User Abstractions) + +Abstractions are not in the stub files. They continue using `MaxObject` directly: +```python +synth = mp.MaxObject("my_synth 440", abstraction=True, inlets=2, outlets=2) +``` +This path is unaffected — `abstraction=True` bypasses the deprecation warning. + +### 6.5 — Import Performance + +Current: ~1140 `MaxObject` instantiations at import (each reads JSON, creates xlets). +After: ~1140 `MaxObjectSpec` constructions (dict literal storage only). + +The JSON data is embedded directly in the generated stub files, so no file I/O happens at import. Expected: 10-50x faster import. + +Trade-off: Stub files will be larger (embedded JSON dicts). Currently `msp.py` is ~296KB with ~460 `MaxObject('name')` lines. After embedding specs, each object adds ~200-500 bytes of dict literals. File sizes may roughly double but this is acceptable — they're auto-generated and not meant for human reading. + +### 6.6 — Circular Import + +`MaxObjectSpec` imports `MaxObject` in `__call__` (deferred import). No circular dependency because: +- `maxobjectspec.py` does NOT import `maxobject` at module level +- `maxobject.py` does NOT import `maxobjectspec` at all +- `objects/*.py` imports `MaxObjectSpec` (one direction only) + +--- + +## 7. File Change Summary + +| Phase | File | Action | Lines Affected | +|-------|------|--------|----------------| +| 0 | `maxpylang/maxobjectspec.py` | **CREATE** | ~80 lines | +| 0 | `maxpylang/__init__.py` | Modify | Add 1 import | +| 0 | `maxpylang/tools/patchfuncs/placing.py` | Modify | Lines 110-113, 383-399 | +| 0 | `tests/test_maxobjectspec.py` | **CREATE** | ~60 lines | +| 1 | `maxpylang/importobjs.py` | Modify | Lines 693-796 (`generate_stubs`) | +| 1 | `maxpylang/objects/__init__.py` | Modify | Remove warning suppression | +| 1 | `maxpylang/objects/max.py` | **REGENERATE** | Auto-generated | +| 1 | `maxpylang/objects/msp.py` | **REGENERATE** | Auto-generated | +| 1 | `maxpylang/objects/jit.py` | **REGENERATE** | Auto-generated | +| 2 | `maxpylang/maxobject.py` | Modify | Line 32 (add `_from_spec`) | +| 2 | `maxpylang/maxobjectspec.py` | Modify | `__call__` (pass `_from_spec`) | +| 2 | `maxpylang/tools/patchfuncs/placing.py` | Modify | `get_obj_from_spec` (warnings) | +| 3 | `maxpylang/tools/objfuncs/exposed.py` | Modify | Line 24 (`edit` signature) | +| 4 | `CLAUDE.md` | Modify | All code examples | +| 4 | `examples/*` | Modify | All example scripts | + +--- + +## 8. Verification Plan + +### Per-Phase Verification + +| Phase | Verification Steps | +|-------|-------------------| +| 0 | Create `MaxObjectSpec` manually, call with args, verify `MaxObject` output matches string-created equivalent. Place in patch, save, open in Max. | +| 1 | `import maxpylang.objects` succeeds. `cycle_tilde` is a `MaxObjectSpec`. `cycle_tilde(440)` produces correct `MaxObject`. Time import (should be much faster). | +| 2 | String syntax still works but prints `DeprecationWarning`. New syntax produces no warnings. | +| 3 | `obj.edit(880)` works. `obj.edit(text="880")` still works. Verify both produce same result. | +| 4 | All examples run successfully with new syntax. Generated patches open correctly in Max. | + +### End-to-End Test + +```python +import maxpylang as mp +from maxpylang.objects import cycle_tilde, gain_tilde, ezdac_tilde, metro, toggle, pack + +patch = mp.MaxPatch() + +# Test basic args +osc = patch.place(cycle_tilde(440))[0] +assert osc.name == "cycle~" +assert len(osc.ins) == 2 +assert len(osc.outs) == 1 + +# Test attributes +m = patch.place(metro(500, active=1))[0] +assert "metro 500 @active 1" in m._dict['box']['text'] + +# Test dynamic I/O +p = patch.place(pack(0, 0, 0))[0] +assert len(p.ins) == 3 + +# Test no-arg +dac = patch.place(ezdac_tilde())[0] +assert dac.name == "ezdac~" + +# Test connections still work +patch.connect([osc.outs[0], dac.ins[0]]) + +# Test save +patch.save("test_class_api") +# → Open test_class_api.maxpat in Max, verify it works +``` diff --git a/tasks/edge_cases.md b/tasks/edge_cases.md new file mode 100644 index 0000000..04a2e77 --- /dev/null +++ b/tasks/edge_cases.md @@ -0,0 +1,353 @@ +# Naming Edge Cases for Class-Based API Migration + +## Table of Contents +1. [Sanitization Rules](#1-sanitization-rules) +2. [Tilde (~) — Audio Objects](#2-tilde----audio-objects) +3. [Dot (.) — Namespace Hierarchies](#3-dot----namespace-hierarchies) +4. [Dot + Tilde Combined](#4-dot--tilde-combined) +5. [Hyphen (-) — Only 1 Object](#5-hyphen----only-1-object) +6. [Leading Digit — Only 1 Object](#6-leading-digit--only-1-object) +7. [Python Keyword Collisions](#7-python-keyword-collisions) +8. [Python Builtin Collisions](#8-python-builtin-collisions) +9. [Operator Symbols — Alias Resolution](#9-operator-symbols--alias-resolution) +10. [Ambiguity: Underscore Flattening](#10-ambiguity-underscore-flattening) +11. [Cross-Package Name Collisions](#11-cross-package-name-collisions) +12. [Summary Table](#12-summary-table) + +--- + +## 1. Sanitization Rules + +The `sanitize_py_name()` function in `importobjs.py` (line 595) converts Max object names to valid Python identifiers. Transformations are applied **in this order**: + +```python +def sanitize_py_name(max_name): + name = max_name.replace("~", "_tilde") # Step 1 + name = name.replace(".", "_") # Step 2 + name = name.replace("-", "_") # Step 3 + if name and name[0].isdigit(): # Step 4 + name = "_" + name + if keyword.iskeyword(name) or name in dir(builtins): # Step 5 + name = name + "_" + return name +``` + +| Step | Rule | Example | +|------|------|---------| +| 1 | `~` → `_tilde` | `cycle~` → `cycle_tilde` | +| 2 | `.` → `_` | `jit.movie` → `jit_movie` | +| 3 | `-` → `_` | `windowed-fft~` → `windowed_fft_tilde` | +| 4 | Leading digit → `_` prefix | `2d.wave~` → `_2d_wave_tilde` | +| 5 | Python keyword/builtin → `_` suffix | `if` → `if_`, `dict` → `dict_` | + +**Order matters:** `in~` first becomes `in_tilde` (step 1), then the keyword check (step 5) does NOT trigger because `in_tilde` is not a keyword. But `in` (without tilde) stays `in` through steps 1-4, then becomes `in_` at step 5. + +--- + +## 2. Tilde (`~`) — Audio Objects + +**Rule:** `~` → `_tilde` + +Applies to ~460 MSP audio-rate objects. This is the most common transformation. + +```python +# Class-based usage +cycle_tilde(440) # cycle~ +ezdac_tilde() # ezdac~ +noise_tilde() # noise~ +phasor_tilde(1) # phasor~ +lores_tilde() # lores~ +delay_tilde() # delay~ +tapin_tilde() # tapin~ +tapout_tilde(500) # tapout~ +buffer_tilde("mybuf") # buffer~ +``` + +**Impact on class-based design:** None — `_tilde` suffix is unambiguous and well-established. Users already use these names with the current stub system. + +--- + +## 3. Dot (`.`) — Namespace Hierarchies + +**Rule:** `.` → `_` + +Used for Max's hierarchical package namespaces. Objects can have 2, 3, or even 4 dot levels. + +```python +# 2 levels (package.object) +jit_movie() # jit.movie +dict_codebox() # dict.codebox +zl_join() # zl.join +mc_assign() # mc.assign +array_change() # array.change + +# 3 levels (package.sub.object) +jit_gl_camera() # jit.gl.camera +jit_anim_drive() # jit.anim.drive +jit_la_determinant() # jit.la.determinant +jit_net_recv() # jit.net.recv +jit_phys_6dof() # jit.phys.6dof + +# 4 levels +jit_gl_pix_codebox() # jit.gl.pix.codebox +``` + +**Concern:** `dict_codebox` — is that `dict.codebox` or a single object named `dict_codebox`? Answer: it's always `dict.codebox` because Max object names use dots, never underscores internally. But users need to **know** this convention. + +--- + +## 4. Dot + Tilde Combined + +Objects with both namespace dots and the audio-rate tilde. + +```python +gen_codebox_tilde() # gen.codebox~ +mc_jit_peek_tilde() # mc.jit.peek~ +jit_catch_tilde() # jit.catch~ +jit_buffer_tilde() # jit.buffer~ +jit_poke_tilde() # jit.poke~ +jit_release_tilde() # jit.release~ +mc_plus_tilde() # mc.plus~ +mcs_cycle_tilde() # mcs.cycle~ +``` + +**Impact on class-based design:** None — transformations compose cleanly. The name is longer but unambiguous. + +--- + +## 5. Hyphen (`-`) — Only 1 Object + +**Rule:** `-` → `_` + +Only a single object in the entire database uses a hyphen: + +```python +windowed_fft_tilde() # windowed-fft~ +``` + +**Impact on class-based design:** None — trivial case. + +--- + +## 6. Leading Digit — Only 1 Object + +**Rule:** If name starts with a digit, prepend `_` + +Only a single object starts with a digit: + +```python +_2d_wave_tilde() # 2d.wave~ +``` + +**Note:** When a dot/namespace comes first, the digit is no longer leading: +- `jit.3m` → `jit_3m` (NOT `_jit_3m` — the `j` is the leading character) +- `mc.2d.wave~` → `mc_2d_wave_tilde` (NOT `_mc_2d_wave_tilde`) +- `jit.phys.6dof` → `jit_phys_6dof` (the `j` is leading) + +**Concern:** The `_` prefix makes it look like a private/internal name in Python convention. This is cosmetic and acceptable — only 1 object is affected. + +--- + +## 7. Python Keyword Collisions + +**Rule:** If the sanitized name is a Python keyword, append `_` + +Only **3 actual Max objects** collide with Python keywords: + +| Max name | Package | Python stub | Keyword | +|----------|---------|-------------|---------| +| `if` | max | `if_()` | `if` | +| `in` | max | `in_()` | `in` | +| `in` | msp | `in_()` | `in` | + +**Note:** `pass` exists only as `pass~` in MSP, which becomes `pass_tilde()` (step 1 runs before step 5), so the keyword check never triggers for it. + +```python +# Class-based usage +if_(cond, then_val, else_val) # if +in_(1) # in (with inlet number) +``` + +**Impact on class-based design:** The trailing `_` looks slightly odd but is standard Python convention (PEP 8) for avoiding keyword conflicts. + +--- + +## 8. Python Builtin Collisions + +**Rule:** If the sanitized name matches a Python builtin, append `_` + +**9 actual Max objects** collide with Python builtins: + +| Max name | Python stub | Shadows builtin | +|----------|-------------|-----------------| +| `abs` | `abs_()` | `abs()` | +| `dict` | `dict_()` | `dict()` | +| `float` | `float_()` | `float()` | +| `int` | `int_()` | `int()` | +| `iter` | `iter_()` | `iter()` | +| `next` | `next_()` | `next()` | +| `pow` | `pow_()` | `pow()` | +| `print` | `print_()` | `print()` | +| `round` | `round_()` | `round()` | + +```python +# Class-based usage +int_(0) # int (Max integer box) +float_(0.0) # float (Max float box) +dict_() # dict (Max dictionary) +abs_() # abs (Max absolute value) +print_() # print (Max print to console) +``` + +**Note:** `abs~` (MSP audio version) becomes `abs_tilde()` — the tilde transformation at step 1 avoids the builtin collision entirely. + +**Impact on class-based design:** Users must remember the `_` suffix for these common names. This is the same as the current stub system — no new burden. + +--- + +## 9. Operator Symbols — Alias Resolution + +Max lets users type `+`, `*`, `>`, etc. directly in a box. These are **not** stored as object names in the database. Instead, `obj_aliases.json` maps them to word names **before** any stub lookup occurs. + +### Control-rate operators (max package) + +| Symbol | Alias resolves to | Stub name | +|--------|-------------------|-----------| +| `+` | `plus` | `plus()` | +| `-` | `minus` | `minus()` | +| `*` | `times` | `times()` | +| `/` | `div` | `div()` | +| `%` | `modulo` | `modulo()` | +| `>` | `greaterthan` | `greaterthan()` | +| `>=` | `greaterthaneq` | `greaterthaneq()` | +| `<` | `lessthan` | `lessthan()` | +| `<=` | `lessthaneq` | `lessthaneq()` | +| `==` | `equals` | `equals()` | +| `!=` | `notequals` | `notequals()` | +| `&&` | `logand` | `logand()` | +| `\|\|` | `logor` | `logor()` | +| `&` | `bitand` | `bitand()` | +| `\|` | `bitor` | `bitor()` | +| `<<` | `shiftleft` | `shiftleft()` | +| `>>` | `shiftright` | `shiftright()` | +| `!/` | `rdiv` | `rdiv()` | +| `!-` | `rminus` | `rminus()` | + +### Audio-rate operators (msp package) + +| Symbol | Alias resolves to | Stub name | +|--------|-------------------|-----------| +| `+~` | `plus~` | `plus_tilde()` | +| `-~` | `minus~` | `minus_tilde()` | +| `*~` | `times~` | `times_tilde()` | +| `/~` | `div~` | `div_tilde()` | +| `%~` | `modulo~` | `modulo_tilde()` | +| `>~` | `greaterthan~` | `greaterthan_tilde()` | +| `>=~` | `greaterthaneq~` | `greaterthaneq_tilde()` | +| `<~` | `lessthan~` | `lessthan_tilde()` | +| `<=~` | `lessthaneq~` | `lessthaneq_tilde()` | +| `==~` | `equals~` | `equals_tilde()` | +| `!=~` | `notequals~` | `notequals_tilde()` | +| `+=~` | `plusequals~` | `plusequals_tilde()` | +| `!/~` | `rdiv~` | `rdiv_tilde()` | +| `!-~` | `rminus~` | `rminus_tilde()` | + +### Multichannel operator aliases (mc package) + +All `mc.*~` operators follow the same pattern: `mc.+~` → `mc.plus~` → `mc_plus_tilde()`. + +```python +# Class-based usage +plus(1, 2) # + (adds two numbers) +times(3) # * (multiplies) +plus_tilde() # +~ (audio addition) +times_tilde() # *~ (audio multiplication) +mc_plus_tilde() # mc.+~ (multichannel audio addition) +``` + +**No stubs exist for the symbol forms** — users must use the word names. This is fine for the class-based API since `+`, `*`, etc. can't be Python identifiers anyway. + +**Impact on class-based design:** None — alias resolution happens at `MaxObject` construction time (inside `get_ref()`), which is downstream of `MaxObjectSpec.__call__()`. If a user writes `MaxObject("+")` directly, aliases still resolve. But in the class-based world, users just use `plus()`. + +--- + +## 10. Ambiguity: Underscore Flattening + +Multiple transformations all collapse to `_`, which could theoretically cause collisions: + +| Separator | Max example | Becomes | +|-----------|------------|---------| +| `.` (dot) | `dict.iter` | `dict_iter` | +| `-` (hyphen) | `windowed-fft~` | `windowed_fft_tilde` | +| `_` (already underscore) | (none exist) | — | + +**Could two different Max objects produce the same Python name?** + +In theory: yes, if Max had both `foo.bar` and `foo-bar`, both would become `foo_bar`. + +In practice: **no**. Max object names never use underscores, and no two objects differ only by `.` vs `-`. There are zero actual collisions in the ~1140 object database. + +**Impact on class-based design:** No action needed. This is a theoretical risk only. + +--- + +## 11. Cross-Package Name Collisions + +The `__init__.py` imports all three packages with `from .{pkg} import *`. If two packages define the same object name, the **last import wins** (silently shadows the first). + +### Actual collision: `in` exists in both `max` and `msp` + +| Package | Max name | JSON file | Stub name | +|---------|----------|-----------|-----------| +| max | `in` | `max/in.json` | `in_` | +| msp | `in` | `msp/in.json` | `in_` | +| msp | `in~` | `msp/in~.json` | `in_tilde` | + +Current import order in `__init__.py`: +```python +from .jit import * # first +from .max import * # second — defines in_ +from .msp import * # third — SHADOWS max's in_ with msp's in_ +``` + +**Result:** `in_` refers to the MSP version. The Max version is inaccessible via the flat import. + +### Other potential collisions + +| Object | Exists in max? | Exists in msp? | Exists in jit? | +|--------|---------------|----------------|----------------| +| `in` | Yes | Yes | No | +| `out` | No | Yes (`out`, `out~`) | No | +| `pass` | No | Yes (`pass~` only) | No | + +Only `in` has an actual cross-package collision. + +### Workaround for users who need the shadowed version + +```python +# Access package-specific versions directly +from maxpylang.objects.max import in_ as max_in +from maxpylang.objects.msp import in_ as msp_in +``` + +**Impact on class-based design:** This is a **pre-existing issue** that the class-based migration doesn't fix but also doesn't make worse. The same shadowing behavior occurs whether the stubs are `MaxObject` instances or `MaxObjectSpec` factories. + +**Potential future fix:** Offer namespaced imports like `from maxpylang.objects.max import in_` alongside the flat `from maxpylang.objects import in_`. + +--- + +## 12. Summary Table + +| Edge Case | Count | Current Handling | Class-Based Impact | Action Needed | +|-----------|-------|-----------------|-------------------|---------------| +| `~` → `_tilde` | ~460 | Works | `cycle_tilde(440)` — clean | None | +| `.` → `_` | ~300+ | Works | `jit_movie()` — clean | None | +| `-` → `_` | 1 | Works | `windowed_fft_tilde()` — clean | None | +| Leading digit → `_` prefix | 1 | Works | `_2d_wave_tilde()` — looks private | Cosmetic, acceptable | +| Python keywords | 3 | `_` suffix | `if_()`, `in_()` — standard PEP 8 | None | +| Python builtins | 9 | `_` suffix | `int_()`, `dict_()` — must remember `_` | None | +| Operator symbols | 52 aliases | Alias → word name | `plus()`, `times_tilde()` — natural | None | +| Underscore flattening | 0 collisions | No actual conflicts | Theoretical risk only | None | +| Cross-package shadowing | 1 (`in`) | Last import wins | Pre-existing issue | Consider namespaced imports | + +**Bottom line:** All naming edge cases are already handled by `sanitize_py_name()`. The class-based migration (`MaxObjectSpec`) inherits these rules as-is. No new edge cases are introduced by making stubs callable. From a773e83678dea33d7ac0235732c717ed6928b761 Mon Sep 17 00:00:00 2001 From: Chris Hyorok Lee Date: Tue, 24 Mar 2026 17:59:13 -0400 Subject: [PATCH 2/7] Remove planning docs from repo and gitignore tasks/ Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 + tasks/class_transition.md | 789 -------------------------------------- tasks/edge_cases.md | 353 ----------------- 3 files changed, 1 insertion(+), 1142 deletions(-) delete mode 100644 tasks/class_transition.md delete mode 100644 tasks/edge_cases.md diff --git a/.gitignore b/.gitignore index afb674a..1cd8c92 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ maxpylang/data/OBJ_ALIASES/ # macOS .DS_Store +tasks/ diff --git a/tasks/class_transition.md b/tasks/class_transition.md deleted file mode 100644 index d1516f3..0000000 --- a/tasks/class_transition.md +++ /dev/null @@ -1,789 +0,0 @@ -# Migration Plan: String Syntax → Class-Based Object API - -## Table of Contents -1. [Problem Statement](#1-problem-statement) -2. [Current Architecture](#2-current-architecture) -3. [Target Architecture](#3-target-architecture) -4. [Design Decisions](#4-design-decisions) -5. [Implementation Phases](#5-implementation-phases) -6. [Edge Cases & Challenges](#6-edge-cases--challenges) -7. [File Change Summary](#7-file-change-summary) -8. [Verification Plan](#8-verification-plan) - ---- - -## 1. Problem Statement - -MaxPyLang currently has two incompatible ways to create objects: - -| Approach | Example | Pros | Cons | -|----------|---------|------|------| -| **String syntax** | `patch.place("cycle~ 440")` | Supports args, attributes | No autocomplete, fragile string concat, Max-specific `@` syntax | -| **Stub instances** | `patch.place(cycle_tilde)` | IDE autocomplete for object names | Cannot pass arguments, must use `edit()` after | - -Neither approach offers the full Python developer experience. The goal is a **single, unified class-based API** that combines the discoverability of stubs with the expressiveness of string syntax. - -### What's Wrong Today - -```python -# String concatenation for dynamic args — fragile, no type safety -freq = 440 -patch.place("cycle~ " + str(freq)) - -# Stubs can't take args — requires awkward two-step -osc = patch.place(cycle_tilde)[0] -osc.edit(text="440") - -# Attributes use Max-specific @ syntax inside strings -patch.place("metro 500 @active 1") - -# No IDE help for which args/attribs an object accepts -patch.place("cycle~ ???") # what arguments does cycle~ take? -``` - -### What We Want - -```python -# Direct Python arguments — type safe, clean -osc = patch.place(cycle_tilde(440))[0] - -# Attributes as keyword arguments — Pythonic -m = patch.place(metro(500, active=1))[0] - -# Dynamic I/O objects work naturally -p = patch.place(pack(0, 0, 0))[0] # → 3 inlets - -# IDE autocomplete for object names + docstrings for args -cycle_tilde( # IDE shows: frequency(number), buffer_name(symbol), ... -``` - ---- - -## 2. Current Architecture - -### Object Creation Flow - -``` -User Code Internal Pipeline -───────── ───────────────── -"cycle~ 440" ──┐ - ├──→ MaxObject.__init__(text) -cycle_tilde ──┘ │ - ├──→ parse_text() → name="cycle~", args=[440], text_attribs={} - ├──→ get_ref("cycle~") → finds cycle~.json in data/OBJ_INFO/msp/ - ├──→ get_info() → loads default dict, arg specs, attrib specs, I/O rules - ├──→ args_valid() → validates arg types against spec - ├──→ make_xlets_from_self_dict() → creates Inlet/Outlet objects - ├──→ update_ins_outs() → adjusts I/O for dynamic objects - └──→ update_text() → rebuilds text field in dict -``` - -### Key Internal State (MaxObject instance) - -| Field | Type | Description | -|-------|------|-------------| -| `_name` | `str` | Object class name (e.g. `"cycle~"`) | -| `_args` | `list` | Positional args (e.g. `[440.0]`) | -| `_text_attribs` | `dict` | In-box @-attributes (e.g. `{"active": ["1"]}`) | -| `_dict` | `dict` | Full JSON representation for .maxpat serialization | -| `_ref_file` | `str\|None` | Path to JSON ref file, `"abstraction"`, or `None` | -| `_ins` / `_outs` | `list` | Inlet/Outlet objects | -| `_ext_file` | `str\|None` | External file path (for js/abstractions) | - -### Stub Files (Current) - -`maxpylang/objects/msp.py` (auto-generated, ~10k lines, ~460 objects): -```python -from maxpylang.maxobject import MaxObject - -__all__ = ['cycle_tilde', 'ezdac_tilde', ...] -_NAMES = {'cycle_tilde': 'cycle~', 'ezdac_tilde': 'ezdac~', ...} - -# stdout suppressed during instantiation (each creates a full MaxObject) -_devnull = open(_os.devnull, 'w') -_sys.stdout = _devnull - -cycle_tilde = MaxObject('cycle~') # reads JSON, parses text, creates xlets -ezdac_tilde = MaxObject('ezdac~') # same for every object... -# ... ~460 more - -_sys.stdout = _old_stdout -``` - -**Problems:** Slow import (each stub reads a JSON file + full instantiation), stdout suppression hack, stubs are instances not factories. - -### Reference Data (JSON per object) - -Example `data/OBJ_INFO/msp/cycle~.json`: -```json -{ - "default": { - "box": { - "maxclass": "newobj", - "numinlets": 2, "numoutlets": 1, - "outlettype": ["signal"], - "text": "cycle~" - } - }, - "args": { - "required": [], - "optional": [ - {"name": "frequency", "units": "hz", "type": ["number"]}, - {"name": "buffer-name", "type": ["symbol"]}, - {"name": "sample-offset", "type": ["int"]} - ] - }, - "attribs": [ - {"name": "COMMON"}, - {"name": "buffer", "type": "symbol", "size": "1"}, - {"name": "frequency", "type": "float", "size": "1"}, - {"name": "phase", "type": "float", "size": "1"} - ], - "in/out": {} -} -``` - -Example `data/OBJ_INFO/max/pack.json` (dynamic I/O): -```json -{ - "args": { - "required": [], - "optional": [{"name": "list-elements", "type": ["any"]}] - }, - "in/out": { - "numinlets": [{"argtype": "a", "index": "all", "type": null}] - } -} -``` - ---- - -## 3. Target Architecture - -### New Class: `MaxObjectSpec` - -A **lightweight callable factory** that stores object metadata and produces `MaxObject` instances when invoked. - -``` -User Code Internal Pipeline (unchanged) -───────── ──────────────────────────── -cycle_tilde(440) - │ - ├──→ MaxObjectSpec.__call__() - │ ├──→ builds text: "cycle~ 440" - │ ├──→ partitions kwargs → text_attribs vs extra_attribs - │ └──→ MaxObject(text, **extra_attribs) ──→ existing pipeline - │ - └──→ Returns MaxObject instance (same as before) -``` - -Key insight: **`MaxObjectSpec` is a thin wrapper that delegates to the existing `MaxObject` pipeline.** No changes needed to serialization, connection logic, or patch saving. - -### API Comparison (Before → After) - -```python -# ── Basic object with args ── -# Before: -osc = patch.place("cycle~ 440")[0] -# After: -osc = patch.place(cycle_tilde(440))[0] - -# ── Object with @-attributes ── -# Before: -m = patch.place("metro 500 @active 1")[0] -# After: -m = patch.place(metro(500, active=1))[0] - -# ── Dynamic I/O ── -# Before: -p = patch.place("pack 0 0 0")[0] # 3 inlets -t = patch.place("trigger b i f")[0] # 3 outlets -# After: -p = patch.place(pack(0, 0, 0))[0] # 3 inlets -t = patch.place(trigger("b", "i", "f"))[0] # 3 outlets - -# ── No-arg objects ── -# Before: -dac = patch.place(ezdac_tilde)[0] # stub instance -# After: -dac = patch.place(ezdac_tilde())[0] # factory call with no args - -# ── Dynamic args in loops ── -# Before: -for freq in [220, 440, 880]: - patch.place(f"cycle~ {freq}") # f-string construction -# After: -for freq in [220, 440, 880]: - patch.place(cycle_tilde(freq)) # native Python args - -# ── Editing after placement ── -# Before: -osc.edit(text="880") -# After (Phase 3): -osc.edit(880) - -# ── Abstractions (unchanged) ── -synth = mp.MaxObject("my_synth", abstraction=True, inlets=2, outlets=2) -placed = patch.place(synth)[0] -``` - ---- - -## 4. Design Decisions - -### Why callable factories, not subclasses? - -**Option A — One class per object** (`class Cycle(MaxObject): ...`): -- Would need ~1140 class definitions -- Inheritance hierarchy unclear (Cycle inherits from MaxObject?) -- `place()` returns `MaxObject`, not `Cycle` — confusing -- Class identity checks (`isinstance(obj, Cycle)`) not useful since all objects serialize the same way - -**Option B — Enhanced `MaxObject` constructor** (`MaxObject("cycle~", freq=440)`): -- Still requires string name — no autocomplete benefit -- Mixes factory pattern into the data class - -**Option C — Callable factories (chosen)** (`cycle_tilde(440) → MaxObject`): -- Stubs already exist with correct names — just make them callable -- Returns `MaxObject` (consistent with current API) -- Metadata stored once, objects created on demand -- Massive import speedup (no more eager instantiation) -- Generation pipeline stays simple - -### How are kwargs partitioned? - -When a user writes `metro(500, active=1)`, the `active` kwarg needs to become `@active 1` in the Max text string (it's an object-specific attribute). But `fontsize=12` would be a common box attribute passed as `**extra_attribs` to `MaxObject`. - -`MaxObjectSpec._partition_attribs()` uses the embedded `attrib_spec` to decide: -- If the kwarg name matches an entry in `attrib_spec` (excluding `COMMON`) → text attribute (`@name val`) -- Otherwise → extra attribute (passed as `**kwargs` to `MaxObject` constructor) - -### What about no-arg calls? - -`ezdac_tilde()` with no arguments produces `MaxObject('ezdac~')` — identical to the current stub behavior. The `()` is required because `ezdac_tilde` is now a factory, not an instance. - -**Breaking change:** `patch.place(ezdac_tilde)` (without parens) will still work during the transition because `place()` will detect `MaxObjectSpec` and call it with no args. But this will emit a deprecation warning. - -### What about `place()` accepting `MaxObjectSpec` directly? - -For convenience during transition, `place()` will detect if an argument is a `MaxObjectSpec` (not yet called) and call it with no args. This means `patch.place(ezdac_tilde)` and `patch.place(ezdac_tilde())` both work, but the former emits a deprecation warning nudging toward the latter. - ---- - -## 5. Implementation Phases - -### Phase 0: `MaxObjectSpec` Foundation - -**Goal:** Add the new factory class, wire it into `place()`, validate with tests. No changes to existing behavior. - -#### 5.0.1 — Create `maxpylang/maxobjectspec.py` - -```python -""" -Callable factory for Max objects. - -MaxObjectSpec stores object metadata (arg specs, attribute specs) and -produces MaxObject instances when called with Python arguments. -""" -import warnings - - -class MaxObjectSpec: - """ - A callable factory that creates MaxObject instances. - - Instead of constructing MaxObjects from text strings, use a - MaxObjectSpec to pass arguments as native Python values: - - cycle_tilde(440) → MaxObject('cycle~ 440') - metro(500, active=1) → MaxObject('metro 500 @active 1') - pack(0, 0, 0) → MaxObject('pack 0 0 0') - """ - - def __init__(self, max_name, arg_spec=None, attrib_spec=None, docstring=""): - self._max_name = max_name - self._arg_spec = arg_spec or {"required": [], "optional": []} - self._attrib_spec = attrib_spec or [] - if docstring: - self.__doc__ = docstring - - def __call__(self, *args, **attribs): - from .maxobject import MaxObject # deferred to avoid circular import - - # Build text: "cycle~ 440 @phase 0.5" - parts = [self._max_name] - for arg in args: - parts.append(str(arg)) - - # Partition kwargs into text attribs vs extra (box) attribs - text_attribs, extra_attribs = self._partition_attribs(attribs) - for attr_name, attr_val in text_attribs.items(): - parts.append(f"@{attr_name}") - if isinstance(attr_val, (list, tuple)): - parts.extend(str(v) for v in attr_val) - else: - parts.append(str(attr_val)) - - text = " ".join(parts) - return MaxObject(text, **extra_attribs) - - def _partition_attribs(self, attribs): - obj_attrib_names = { - a['name'] for a in self._attrib_spec - if a.get('name') and a.get('name') != 'COMMON' - } - text_attribs = {} - extra_attribs = {} - for key, val in attribs.items(): - if key in obj_attrib_names: - text_attribs[key] = val - else: - extra_attribs[key] = val - return text_attribs, extra_attribs - - @property - def max_name(self): - """The Max object class name (e.g. 'cycle~').""" - return self._max_name - - @property - def arg_spec(self): - """Argument specification from the object's reference file.""" - return self._arg_spec - - @property - def attrib_spec(self): - """Attribute specification from the object's reference file.""" - return self._attrib_spec - - def __repr__(self): - return f"MaxObjectSpec('{self._max_name}')" -``` - -#### 5.0.2 — Modify `maxpylang/tools/patchfuncs/placing.py` - -**`get_obj_from_spec()` (line 383):** Add `MaxObjectSpec` handling. - -```python -def get_obj_from_spec(self, obj_spec): - from maxpylang.maxobjectspec import MaxObjectSpec - - if isinstance(obj_spec, str): - obj = MaxObject(obj_spec) - elif isinstance(obj_spec, MaxObjectSpec): - obj = obj_spec() # call factory with no args - else: - assert isinstance(obj_spec, MaxObject), \ - f"object must be specified as a string, MaxObject, or MaxObjectSpec" - obj = obj_spec - - return obj -``` - -**`place_check_args()` (line 110-113):** Add `MaxObjectSpec` to isinstance check. - -```python -from maxpylang.maxobjectspec import MaxObjectSpec - -for obj in objs: - assert isinstance(obj, (MaxObject, MaxObjectSpec, str, list)), \ - f"objs list must be strings, MaxObjects, or MaxObjectSpecs" -``` - -#### 5.0.3 — Modify `maxpylang/__init__.py` - -Add `MaxObjectSpec` to exports. - -#### 5.0.4 — Create `tests/test_maxobjectspec.py` - -Test cases: -- `MaxObjectSpec('cycle~', arg_spec=...)(440)` → MaxObject with `name == "cycle~"`, `_args == [440.0]` -- `MaxObjectSpec('metro', ...)(500, active=1)` → text contains `"metro 500 @active 1"` -- `MaxObjectSpec('pack', ...)(0, 0, 0)` → MaxObject with 3 inlets (dynamic I/O) -- `MaxObjectSpec('trigger', ...)("b", "i", "f")` → MaxObject with 3 outlets -- `MaxObjectSpec('ezdac~', ...)()` → MaxObject with name `"ezdac~"` and no args -- `patch.place(MaxObjectSpec('cycle~', ...)())` → works, returns list of MaxObject -- `patch.place(MaxObjectSpec('cycle~', ...))` → works (auto-called with no args) -- Unknown attrib kwarg → emits warning - ---- - -### Phase 1: Update Auto-Generation Pipeline - -**Goal:** `importobjs.py` generates `MaxObjectSpec` instances instead of `MaxObject` instances. Regenerate all stub files. - -#### 5.1.1 — Modify `maxpylang/importobjs.py` → `generate_stubs()` (line 693) - -**Before** (current generation per object): -```python -stub_lines.append(f"{py_name} = MaxObject('{max_name}')") -``` - -**After:** -```python -import json as _json - -# Embed specs directly from the JSON reference file -arg_spec_str = _json.dumps(obj_info.get('args', {}), indent=None) -attrib_spec_str = _json.dumps(obj_info.get('attribs', []), indent=None) - -stub_lines.append(f"{py_name} = MaxObjectSpec(") -stub_lines.append(f" '{max_name}',") -stub_lines.append(f" arg_spec={arg_spec_str},") -stub_lines.append(f" attrib_spec={attrib_spec_str},") -stub_lines.append(f" docstring={repr(docstring)},") -stub_lines.append(f")") -``` - -**Other changes to `generate_stubs()`:** -- Line 731: Change import from `MaxObject` to `MaxObjectSpec` -- Lines 750-753: **Remove** stdout suppression hack (`_devnull`, `_old_stdout`) -- Lines 770-773: **Remove** stdout restoration -- Keep `_NAMES` dict for now (backward compat); mark for removal in Phase 5 - -**Generated output changes (example):** - -```python -# Before -""" -cycle~ - Sinusoidal oscillator -... -""" -cycle_tilde = MaxObject('cycle~') - -# After -""" -cycle~ - Sinusoidal oscillator -... -""" -cycle_tilde = MaxObjectSpec( - 'cycle~', - arg_spec={"required": [], "optional": [{"name": "frequency", "units": "hz", "type": ["number"]}, ...]}, - attrib_spec=[{"name": "COMMON"}, {"name": "buffer", "type": "symbol", "size": "1"}, ...], - docstring="cycle~ - Sinusoidal oscillator\n\nArgs:\n frequency (number, optional)\n...", -) -``` - -#### 5.1.2 — Modify `maxpylang/objects/__init__.py` - -Remove the `UnknownObjectWarning` suppression — no longer needed since `MaxObjectSpec.__init__` doesn't instantiate `MaxObject`. - -```python -# Before -import warnings -from maxpylang.exceptions import UnknownObjectWarning - -with warnings.catch_warnings(): - warnings.simplefilter("ignore", UnknownObjectWarning) - try: - from .jit import * - except ImportError: - pass - ... - -# After -try: - from .jit import * -except ImportError: - pass -try: - from .max import * -except ImportError: - pass -try: - from .msp import * -except ImportError: - pass -``` - -#### 5.1.3 — Regenerate stub files - -Run `import_objs('vanilla', overwrite=True)` (requires Max open) to regenerate: -- `maxpylang/objects/max.py` (~470 objects) -- `maxpylang/objects/msp.py` (~460 objects) -- `maxpylang/objects/jit.py` (~210 objects) - -**Performance impact:** Import time for `maxpylang.objects` drops from ~1140 JSON reads + MaxObject instantiations to ~1140 dict literal constructions. Expected speedup: 10-50x. - ---- - -### Phase 2: Deprecation Warnings - -**Goal:** Nudge users toward the new API. Old code still works but emits warnings. - -#### 5.2.1 — Modify `maxpylang/maxobject.py` (line 32) - -Add private `_from_spec` parameter: - -```python -def __init__(self, text, from_dict=False, abstraction=False, - inlets=None, outlets=None, _from_spec=False, **extra_attribs): - ... - if not from_dict and not abstraction and not _from_spec and isinstance(text, str): - warnings.warn( - f"String-based MaxObject construction is deprecated. " - f"Use class-based stubs from maxpylang.objects instead.\n" - f" Example: cycle_tilde(440) instead of MaxObject('cycle~ 440')", - DeprecationWarning, stacklevel=2 - ) - ... -``` - -#### 5.2.2 — Modify `maxpylang/maxobjectspec.py` → `__call__` - -Pass `_from_spec=True` so the warning doesn't fire for factory-created objects: - -```python -return MaxObject(text, _from_spec=True, **extra_attribs) -``` - -#### 5.2.3 — Modify `maxpylang/tools/patchfuncs/placing.py` → `get_obj_from_spec()` - -Add deprecation warning for string args in `place()`: - -```python -if isinstance(obj_spec, str): - warnings.warn( - f"Passing strings to place() is deprecated. " - f"Use class-based stubs: place(cycle_tilde(440)) instead of place('cycle~ 440')", - DeprecationWarning, stacklevel=3 - ) - obj = MaxObject(obj_spec, _from_spec=True) # suppress double warning -``` - -Add deprecation warning for uncalled `MaxObjectSpec` in `place()`: - -```python -elif isinstance(obj_spec, MaxObjectSpec): - warnings.warn( - f"Passing uncalled MaxObjectSpec to place() is deprecated. " - f"Call it with parentheses: place({obj_spec.max_name}()) instead of place({obj_spec.max_name})", - DeprecationWarning, stacklevel=3 - ) - obj = obj_spec() -``` - ---- - -### Phase 3: Enhanced `edit()` API - -**Goal:** Make `edit()` accept native Python arguments, matching the `MaxObjectSpec` pattern. - -#### 5.3.1 — Modify `maxpylang/tools/objfuncs/exposed.py` (line 24) - -```python -# Before -def edit(self, text_add="append", text=None, **extra_attribs): - -# After -def edit(self, *args, text_add="append", text=None, **attribs): - """ - Edit an object by adding/replacing arguments and attributes. - - New API (class-based): - obj.edit(440) # set/append positional arg - obj.edit(440, phase=0.5) # arg + attribute - obj.edit(text_add="replace", 880) # replace all args - - Legacy API (still supported): - obj.edit(text="440") # text-based append - obj.edit(text="440", text_add="replace") - """ - - if text is not None: - # Legacy path — existing behavior unchanged - ... (current implementation) - elif args or attribs: - # New path — convert Python args to text, partition attribs - new_args = list(args) - # Partition attribs into text_attribs and extra_attribs - # using object's reference info (same logic as MaxObjectSpec) - ... - else: - return # nothing to edit -``` - -Full backward compatibility: any code using `edit(text="...")` or `edit(**extra_attribs)` works unchanged. - ---- - -### Phase 4: Documentation & Examples - -**Goal:** Update all documentation and examples to use the new class-based API. - -#### Files to update: - -| File | Key Changes | -|------|-------------| -| `CLAUDE.md` | All code examples: string syntax → class-based calls | -| `examples/hello_world/main.py` | `"cycle~ 440"` → `cycle_tilde(440)` | -| `examples/attributes/main.py` | `"metro 500 @active 1"` → `metro(500, active=1)` | -| `examples/random_pitch_generator/tester3.py` | f-string args → Python args in loops | -| `examples/selective-midi-note-generator/tester2.py` | Same pattern | -| `examples/chess-paper-example/...` | Migrate | -| `examples/stocksonification_v1/...` | Migrate | -| `examples/basic-sonification-using-abstracted-csvReader/...` | Migrate | - -#### Documentation pattern: - -```python -# Before (CLAUDE.md) -osc = patch.place("cycle~ 440")[0] -gain = patch.place(gain_tilde)[0] -dac = patch.place(ezdac_tilde)[0] - -# After (CLAUDE.md) -from maxpylang.objects import cycle_tilde, gain_tilde, ezdac_tilde - -osc = patch.place(cycle_tilde(440))[0] -gain = patch.place(gain_tilde())[0] -dac = patch.place(ezdac_tilde())[0] -``` - ---- - -### Phase 5 (Future): Remove String Syntax - -**Goal:** Clean removal after deprecation period. - -| Removal | Location | -|---------|----------| -| String branch in `get_obj_from_spec()` | `placing.py` line 389 | -| Direct string `MaxObject.__init__` (public) | `maxobject.py` line 32 (keep `_from_spec` path) | -| `_NAMES` dict | `objects/max.py`, `msp.py`, `jit.py` | -| `text` parameter in `edit()` | `exposed.py` line 24 | -| `_from_spec` parameter | `maxobject.py` (no longer needed) | - -**What stays:** `MaxObject(text, _from_spec=True)` — used internally by `MaxObjectSpec.__call__()`. The text-based pipeline is preserved as an internal mechanism; only the public string API is removed. - ---- - -## 6. Edge Cases & Challenges - -### 6.1 — Backward Compatibility: `place(stub)` Without Parens - -Currently: `patch.place(cycle_tilde)` works because `cycle_tilde` is a `MaxObject`. -After Phase 1: `cycle_tilde` is a `MaxObjectSpec`, not a `MaxObject`. - -**Solution:** `get_obj_from_spec()` detects `MaxObjectSpec` and calls it with no args. Phase 2 adds a deprecation warning encouraging `place(cycle_tilde())`. - -### 6.2 — Attribute Name Collisions - -`cycle~` has both an arg named `frequency` and an attribute named `frequency`. In `MaxObjectSpec.__call__`, positional args go to `*args` and attributes go to `**kwargs` — no collision: - -```python -cycle_tilde(440, frequency=220) -# → *args=(440,), **attribs={"frequency": 220} -# → text: "cycle~ 440 @frequency 220" -``` - -The positional `440` becomes the in-box argument; the keyword `frequency=220` becomes the `@frequency` attribute. This matches how Max itself distinguishes between typed-in arguments and explicitly set attributes. - -### 6.3 — Dynamic Argument Building in Loops - -Current pattern in examples: -```python -for i in range(voices): - freqs.append(f"cycle~ {220 * (i+1)}") - patch.place(freqs[-1]) -``` - -New pattern: -```python -for i in range(voices): - patch.place(cycle_tilde(220 * (i+1))) -``` - -### 6.4 — Objects With No Stubs (User Abstractions) - -Abstractions are not in the stub files. They continue using `MaxObject` directly: -```python -synth = mp.MaxObject("my_synth 440", abstraction=True, inlets=2, outlets=2) -``` -This path is unaffected — `abstraction=True` bypasses the deprecation warning. - -### 6.5 — Import Performance - -Current: ~1140 `MaxObject` instantiations at import (each reads JSON, creates xlets). -After: ~1140 `MaxObjectSpec` constructions (dict literal storage only). - -The JSON data is embedded directly in the generated stub files, so no file I/O happens at import. Expected: 10-50x faster import. - -Trade-off: Stub files will be larger (embedded JSON dicts). Currently `msp.py` is ~296KB with ~460 `MaxObject('name')` lines. After embedding specs, each object adds ~200-500 bytes of dict literals. File sizes may roughly double but this is acceptable — they're auto-generated and not meant for human reading. - -### 6.6 — Circular Import - -`MaxObjectSpec` imports `MaxObject` in `__call__` (deferred import). No circular dependency because: -- `maxobjectspec.py` does NOT import `maxobject` at module level -- `maxobject.py` does NOT import `maxobjectspec` at all -- `objects/*.py` imports `MaxObjectSpec` (one direction only) - ---- - -## 7. File Change Summary - -| Phase | File | Action | Lines Affected | -|-------|------|--------|----------------| -| 0 | `maxpylang/maxobjectspec.py` | **CREATE** | ~80 lines | -| 0 | `maxpylang/__init__.py` | Modify | Add 1 import | -| 0 | `maxpylang/tools/patchfuncs/placing.py` | Modify | Lines 110-113, 383-399 | -| 0 | `tests/test_maxobjectspec.py` | **CREATE** | ~60 lines | -| 1 | `maxpylang/importobjs.py` | Modify | Lines 693-796 (`generate_stubs`) | -| 1 | `maxpylang/objects/__init__.py` | Modify | Remove warning suppression | -| 1 | `maxpylang/objects/max.py` | **REGENERATE** | Auto-generated | -| 1 | `maxpylang/objects/msp.py` | **REGENERATE** | Auto-generated | -| 1 | `maxpylang/objects/jit.py` | **REGENERATE** | Auto-generated | -| 2 | `maxpylang/maxobject.py` | Modify | Line 32 (add `_from_spec`) | -| 2 | `maxpylang/maxobjectspec.py` | Modify | `__call__` (pass `_from_spec`) | -| 2 | `maxpylang/tools/patchfuncs/placing.py` | Modify | `get_obj_from_spec` (warnings) | -| 3 | `maxpylang/tools/objfuncs/exposed.py` | Modify | Line 24 (`edit` signature) | -| 4 | `CLAUDE.md` | Modify | All code examples | -| 4 | `examples/*` | Modify | All example scripts | - ---- - -## 8. Verification Plan - -### Per-Phase Verification - -| Phase | Verification Steps | -|-------|-------------------| -| 0 | Create `MaxObjectSpec` manually, call with args, verify `MaxObject` output matches string-created equivalent. Place in patch, save, open in Max. | -| 1 | `import maxpylang.objects` succeeds. `cycle_tilde` is a `MaxObjectSpec`. `cycle_tilde(440)` produces correct `MaxObject`. Time import (should be much faster). | -| 2 | String syntax still works but prints `DeprecationWarning`. New syntax produces no warnings. | -| 3 | `obj.edit(880)` works. `obj.edit(text="880")` still works. Verify both produce same result. | -| 4 | All examples run successfully with new syntax. Generated patches open correctly in Max. | - -### End-to-End Test - -```python -import maxpylang as mp -from maxpylang.objects import cycle_tilde, gain_tilde, ezdac_tilde, metro, toggle, pack - -patch = mp.MaxPatch() - -# Test basic args -osc = patch.place(cycle_tilde(440))[0] -assert osc.name == "cycle~" -assert len(osc.ins) == 2 -assert len(osc.outs) == 1 - -# Test attributes -m = patch.place(metro(500, active=1))[0] -assert "metro 500 @active 1" in m._dict['box']['text'] - -# Test dynamic I/O -p = patch.place(pack(0, 0, 0))[0] -assert len(p.ins) == 3 - -# Test no-arg -dac = patch.place(ezdac_tilde())[0] -assert dac.name == "ezdac~" - -# Test connections still work -patch.connect([osc.outs[0], dac.ins[0]]) - -# Test save -patch.save("test_class_api") -# → Open test_class_api.maxpat in Max, verify it works -``` diff --git a/tasks/edge_cases.md b/tasks/edge_cases.md deleted file mode 100644 index 04a2e77..0000000 --- a/tasks/edge_cases.md +++ /dev/null @@ -1,353 +0,0 @@ -# Naming Edge Cases for Class-Based API Migration - -## Table of Contents -1. [Sanitization Rules](#1-sanitization-rules) -2. [Tilde (~) — Audio Objects](#2-tilde----audio-objects) -3. [Dot (.) — Namespace Hierarchies](#3-dot----namespace-hierarchies) -4. [Dot + Tilde Combined](#4-dot--tilde-combined) -5. [Hyphen (-) — Only 1 Object](#5-hyphen----only-1-object) -6. [Leading Digit — Only 1 Object](#6-leading-digit--only-1-object) -7. [Python Keyword Collisions](#7-python-keyword-collisions) -8. [Python Builtin Collisions](#8-python-builtin-collisions) -9. [Operator Symbols — Alias Resolution](#9-operator-symbols--alias-resolution) -10. [Ambiguity: Underscore Flattening](#10-ambiguity-underscore-flattening) -11. [Cross-Package Name Collisions](#11-cross-package-name-collisions) -12. [Summary Table](#12-summary-table) - ---- - -## 1. Sanitization Rules - -The `sanitize_py_name()` function in `importobjs.py` (line 595) converts Max object names to valid Python identifiers. Transformations are applied **in this order**: - -```python -def sanitize_py_name(max_name): - name = max_name.replace("~", "_tilde") # Step 1 - name = name.replace(".", "_") # Step 2 - name = name.replace("-", "_") # Step 3 - if name and name[0].isdigit(): # Step 4 - name = "_" + name - if keyword.iskeyword(name) or name in dir(builtins): # Step 5 - name = name + "_" - return name -``` - -| Step | Rule | Example | -|------|------|---------| -| 1 | `~` → `_tilde` | `cycle~` → `cycle_tilde` | -| 2 | `.` → `_` | `jit.movie` → `jit_movie` | -| 3 | `-` → `_` | `windowed-fft~` → `windowed_fft_tilde` | -| 4 | Leading digit → `_` prefix | `2d.wave~` → `_2d_wave_tilde` | -| 5 | Python keyword/builtin → `_` suffix | `if` → `if_`, `dict` → `dict_` | - -**Order matters:** `in~` first becomes `in_tilde` (step 1), then the keyword check (step 5) does NOT trigger because `in_tilde` is not a keyword. But `in` (without tilde) stays `in` through steps 1-4, then becomes `in_` at step 5. - ---- - -## 2. Tilde (`~`) — Audio Objects - -**Rule:** `~` → `_tilde` - -Applies to ~460 MSP audio-rate objects. This is the most common transformation. - -```python -# Class-based usage -cycle_tilde(440) # cycle~ -ezdac_tilde() # ezdac~ -noise_tilde() # noise~ -phasor_tilde(1) # phasor~ -lores_tilde() # lores~ -delay_tilde() # delay~ -tapin_tilde() # tapin~ -tapout_tilde(500) # tapout~ -buffer_tilde("mybuf") # buffer~ -``` - -**Impact on class-based design:** None — `_tilde` suffix is unambiguous and well-established. Users already use these names with the current stub system. - ---- - -## 3. Dot (`.`) — Namespace Hierarchies - -**Rule:** `.` → `_` - -Used for Max's hierarchical package namespaces. Objects can have 2, 3, or even 4 dot levels. - -```python -# 2 levels (package.object) -jit_movie() # jit.movie -dict_codebox() # dict.codebox -zl_join() # zl.join -mc_assign() # mc.assign -array_change() # array.change - -# 3 levels (package.sub.object) -jit_gl_camera() # jit.gl.camera -jit_anim_drive() # jit.anim.drive -jit_la_determinant() # jit.la.determinant -jit_net_recv() # jit.net.recv -jit_phys_6dof() # jit.phys.6dof - -# 4 levels -jit_gl_pix_codebox() # jit.gl.pix.codebox -``` - -**Concern:** `dict_codebox` — is that `dict.codebox` or a single object named `dict_codebox`? Answer: it's always `dict.codebox` because Max object names use dots, never underscores internally. But users need to **know** this convention. - ---- - -## 4. Dot + Tilde Combined - -Objects with both namespace dots and the audio-rate tilde. - -```python -gen_codebox_tilde() # gen.codebox~ -mc_jit_peek_tilde() # mc.jit.peek~ -jit_catch_tilde() # jit.catch~ -jit_buffer_tilde() # jit.buffer~ -jit_poke_tilde() # jit.poke~ -jit_release_tilde() # jit.release~ -mc_plus_tilde() # mc.plus~ -mcs_cycle_tilde() # mcs.cycle~ -``` - -**Impact on class-based design:** None — transformations compose cleanly. The name is longer but unambiguous. - ---- - -## 5. Hyphen (`-`) — Only 1 Object - -**Rule:** `-` → `_` - -Only a single object in the entire database uses a hyphen: - -```python -windowed_fft_tilde() # windowed-fft~ -``` - -**Impact on class-based design:** None — trivial case. - ---- - -## 6. Leading Digit — Only 1 Object - -**Rule:** If name starts with a digit, prepend `_` - -Only a single object starts with a digit: - -```python -_2d_wave_tilde() # 2d.wave~ -``` - -**Note:** When a dot/namespace comes first, the digit is no longer leading: -- `jit.3m` → `jit_3m` (NOT `_jit_3m` — the `j` is the leading character) -- `mc.2d.wave~` → `mc_2d_wave_tilde` (NOT `_mc_2d_wave_tilde`) -- `jit.phys.6dof` → `jit_phys_6dof` (the `j` is leading) - -**Concern:** The `_` prefix makes it look like a private/internal name in Python convention. This is cosmetic and acceptable — only 1 object is affected. - ---- - -## 7. Python Keyword Collisions - -**Rule:** If the sanitized name is a Python keyword, append `_` - -Only **3 actual Max objects** collide with Python keywords: - -| Max name | Package | Python stub | Keyword | -|----------|---------|-------------|---------| -| `if` | max | `if_()` | `if` | -| `in` | max | `in_()` | `in` | -| `in` | msp | `in_()` | `in` | - -**Note:** `pass` exists only as `pass~` in MSP, which becomes `pass_tilde()` (step 1 runs before step 5), so the keyword check never triggers for it. - -```python -# Class-based usage -if_(cond, then_val, else_val) # if -in_(1) # in (with inlet number) -``` - -**Impact on class-based design:** The trailing `_` looks slightly odd but is standard Python convention (PEP 8) for avoiding keyword conflicts. - ---- - -## 8. Python Builtin Collisions - -**Rule:** If the sanitized name matches a Python builtin, append `_` - -**9 actual Max objects** collide with Python builtins: - -| Max name | Python stub | Shadows builtin | -|----------|-------------|-----------------| -| `abs` | `abs_()` | `abs()` | -| `dict` | `dict_()` | `dict()` | -| `float` | `float_()` | `float()` | -| `int` | `int_()` | `int()` | -| `iter` | `iter_()` | `iter()` | -| `next` | `next_()` | `next()` | -| `pow` | `pow_()` | `pow()` | -| `print` | `print_()` | `print()` | -| `round` | `round_()` | `round()` | - -```python -# Class-based usage -int_(0) # int (Max integer box) -float_(0.0) # float (Max float box) -dict_() # dict (Max dictionary) -abs_() # abs (Max absolute value) -print_() # print (Max print to console) -``` - -**Note:** `abs~` (MSP audio version) becomes `abs_tilde()` — the tilde transformation at step 1 avoids the builtin collision entirely. - -**Impact on class-based design:** Users must remember the `_` suffix for these common names. This is the same as the current stub system — no new burden. - ---- - -## 9. Operator Symbols — Alias Resolution - -Max lets users type `+`, `*`, `>`, etc. directly in a box. These are **not** stored as object names in the database. Instead, `obj_aliases.json` maps them to word names **before** any stub lookup occurs. - -### Control-rate operators (max package) - -| Symbol | Alias resolves to | Stub name | -|--------|-------------------|-----------| -| `+` | `plus` | `plus()` | -| `-` | `minus` | `minus()` | -| `*` | `times` | `times()` | -| `/` | `div` | `div()` | -| `%` | `modulo` | `modulo()` | -| `>` | `greaterthan` | `greaterthan()` | -| `>=` | `greaterthaneq` | `greaterthaneq()` | -| `<` | `lessthan` | `lessthan()` | -| `<=` | `lessthaneq` | `lessthaneq()` | -| `==` | `equals` | `equals()` | -| `!=` | `notequals` | `notequals()` | -| `&&` | `logand` | `logand()` | -| `\|\|` | `logor` | `logor()` | -| `&` | `bitand` | `bitand()` | -| `\|` | `bitor` | `bitor()` | -| `<<` | `shiftleft` | `shiftleft()` | -| `>>` | `shiftright` | `shiftright()` | -| `!/` | `rdiv` | `rdiv()` | -| `!-` | `rminus` | `rminus()` | - -### Audio-rate operators (msp package) - -| Symbol | Alias resolves to | Stub name | -|--------|-------------------|-----------| -| `+~` | `plus~` | `plus_tilde()` | -| `-~` | `minus~` | `minus_tilde()` | -| `*~` | `times~` | `times_tilde()` | -| `/~` | `div~` | `div_tilde()` | -| `%~` | `modulo~` | `modulo_tilde()` | -| `>~` | `greaterthan~` | `greaterthan_tilde()` | -| `>=~` | `greaterthaneq~` | `greaterthaneq_tilde()` | -| `<~` | `lessthan~` | `lessthan_tilde()` | -| `<=~` | `lessthaneq~` | `lessthaneq_tilde()` | -| `==~` | `equals~` | `equals_tilde()` | -| `!=~` | `notequals~` | `notequals_tilde()` | -| `+=~` | `plusequals~` | `plusequals_tilde()` | -| `!/~` | `rdiv~` | `rdiv_tilde()` | -| `!-~` | `rminus~` | `rminus_tilde()` | - -### Multichannel operator aliases (mc package) - -All `mc.*~` operators follow the same pattern: `mc.+~` → `mc.plus~` → `mc_plus_tilde()`. - -```python -# Class-based usage -plus(1, 2) # + (adds two numbers) -times(3) # * (multiplies) -plus_tilde() # +~ (audio addition) -times_tilde() # *~ (audio multiplication) -mc_plus_tilde() # mc.+~ (multichannel audio addition) -``` - -**No stubs exist for the symbol forms** — users must use the word names. This is fine for the class-based API since `+`, `*`, etc. can't be Python identifiers anyway. - -**Impact on class-based design:** None — alias resolution happens at `MaxObject` construction time (inside `get_ref()`), which is downstream of `MaxObjectSpec.__call__()`. If a user writes `MaxObject("+")` directly, aliases still resolve. But in the class-based world, users just use `plus()`. - ---- - -## 10. Ambiguity: Underscore Flattening - -Multiple transformations all collapse to `_`, which could theoretically cause collisions: - -| Separator | Max example | Becomes | -|-----------|------------|---------| -| `.` (dot) | `dict.iter` | `dict_iter` | -| `-` (hyphen) | `windowed-fft~` | `windowed_fft_tilde` | -| `_` (already underscore) | (none exist) | — | - -**Could two different Max objects produce the same Python name?** - -In theory: yes, if Max had both `foo.bar` and `foo-bar`, both would become `foo_bar`. - -In practice: **no**. Max object names never use underscores, and no two objects differ only by `.` vs `-`. There are zero actual collisions in the ~1140 object database. - -**Impact on class-based design:** No action needed. This is a theoretical risk only. - ---- - -## 11. Cross-Package Name Collisions - -The `__init__.py` imports all three packages with `from .{pkg} import *`. If two packages define the same object name, the **last import wins** (silently shadows the first). - -### Actual collision: `in` exists in both `max` and `msp` - -| Package | Max name | JSON file | Stub name | -|---------|----------|-----------|-----------| -| max | `in` | `max/in.json` | `in_` | -| msp | `in` | `msp/in.json` | `in_` | -| msp | `in~` | `msp/in~.json` | `in_tilde` | - -Current import order in `__init__.py`: -```python -from .jit import * # first -from .max import * # second — defines in_ -from .msp import * # third — SHADOWS max's in_ with msp's in_ -``` - -**Result:** `in_` refers to the MSP version. The Max version is inaccessible via the flat import. - -### Other potential collisions - -| Object | Exists in max? | Exists in msp? | Exists in jit? | -|--------|---------------|----------------|----------------| -| `in` | Yes | Yes | No | -| `out` | No | Yes (`out`, `out~`) | No | -| `pass` | No | Yes (`pass~` only) | No | - -Only `in` has an actual cross-package collision. - -### Workaround for users who need the shadowed version - -```python -# Access package-specific versions directly -from maxpylang.objects.max import in_ as max_in -from maxpylang.objects.msp import in_ as msp_in -``` - -**Impact on class-based design:** This is a **pre-existing issue** that the class-based migration doesn't fix but also doesn't make worse. The same shadowing behavior occurs whether the stubs are `MaxObject` instances or `MaxObjectSpec` factories. - -**Potential future fix:** Offer namespaced imports like `from maxpylang.objects.max import in_` alongside the flat `from maxpylang.objects import in_`. - ---- - -## 12. Summary Table - -| Edge Case | Count | Current Handling | Class-Based Impact | Action Needed | -|-----------|-------|-----------------|-------------------|---------------| -| `~` → `_tilde` | ~460 | Works | `cycle_tilde(440)` — clean | None | -| `.` → `_` | ~300+ | Works | `jit_movie()` — clean | None | -| `-` → `_` | 1 | Works | `windowed_fft_tilde()` — clean | None | -| Leading digit → `_` prefix | 1 | Works | `_2d_wave_tilde()` — looks private | Cosmetic, acceptable | -| Python keywords | 3 | `_` suffix | `if_()`, `in_()` — standard PEP 8 | None | -| Python builtins | 9 | `_` suffix | `int_()`, `dict_()` — must remember `_` | None | -| Operator symbols | 52 aliases | Alias → word name | `plus()`, `times_tilde()` — natural | None | -| Underscore flattening | 0 collisions | No actual conflicts | Theoretical risk only | None | -| Cross-package shadowing | 1 (`in`) | Last import wins | Pre-existing issue | Consider namespaced imports | - -**Bottom line:** All naming edge cases are already handled by `sanitize_py_name()`. The class-based migration (`MaxObjectSpec`) inherits these rules as-is. No new edge cases are introduced by making stubs callable. From 7933fedbe48002938d2f06b7b2bf77cc2907c8d9 Mon Sep 17 00:00:00 2001 From: Chris Hyorok Lee Date: Tue, 24 Mar 2026 18:09:18 -0400 Subject: [PATCH 3/7] Fix notein args: mark port-channel and channel as optional notein works without arguments in Max (listens on all ports/channels). The reference file incorrectly marked these as required, causing UnknownObjectWarning when placing notein without args. Co-Authored-By: Claude Opus 4.6 (1M context) --- maxpylang/data/OBJ_INFO/max/notein.json | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/maxpylang/data/OBJ_INFO/max/notein.json b/maxpylang/data/OBJ_INFO/max/notein.json index 3594c32..291698b 100644 --- a/maxpylang/data/OBJ_INFO/max/notein.json +++ b/maxpylang/data/OBJ_INFO/max/notein.json @@ -20,7 +20,8 @@ } }, "args": { - "required": [ + "required": [], + "optional": [ { "name": "port-channel", "type": [ @@ -32,9 +33,7 @@ "type": [ "int" ] - } - ], - "optional": [ + }, { "name": "port", "type": [ From 644b16036f6f0deebf181b1403ee9d6eab0b7dea Mon Sep 17 00:00:00 2001 From: Chris Hyorok Lee Date: Tue, 24 Mar 2026 18:20:55 -0400 Subject: [PATCH 4/7] Harden amxd save/load from code review findings - Add bounds check in load_amxd to catch truncated/corrupt .amxd files - Use removesuffix(b"\x00") instead of rstrip for precise null handling - Add TypeError guard in save_amxd for non-dict input - Fix truthiness check to explicit `is not None` in save() verbose path - Update load_file docstring to mention .amxd support - Fix misleading comment in extension handling Co-Authored-By: Claude Opus 4.6 (1M context) --- maxpylang/amxd.py | 10 +++++++--- maxpylang/tools/patchfuncs/instantiation.py | 2 +- maxpylang/tools/patchfuncs/saving.py | 4 ++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/maxpylang/amxd.py b/maxpylang/amxd.py index c80aba1..ff963de 100644 --- a/maxpylang/amxd.py +++ b/maxpylang/amxd.py @@ -17,6 +17,8 @@ def save_amxd(patcher_json, filename, device_type="instrument"): """Wrap a patcher JSON dict in .amxd binary format and write to file.""" + if not isinstance(patcher_json, dict): + raise TypeError(f"patcher_json must be a dict, got {type(patcher_json).__name__}") if device_type not in DEVICE_TYPES: raise ValueError(f"Unknown device_type {device_type!r}. " f"Choose from: {', '.join(DEVICE_TYPES)}") @@ -44,12 +46,14 @@ def load_amxd(filename): data = f.read() offset = 0 - while offset < len(data): - field = data[offset:offset + 4].decode("ascii") + while offset + 8 <= len(data): + field = data[offset:offset + 4].decode("ascii", errors="replace") size = struct.unpack(" len(data): + raise ValueError(f"Chunk '{field}' size {size} extends past end of file") if field == "ptch": json_bytes = data[offset + 8:offset + 8 + size] - return json.loads(json_bytes.rstrip(b"\x00")) + return json.loads(json_bytes.removesuffix(b"\x00")) offset += 8 + size raise ValueError("No ptch chunk found in .amxd file") diff --git a/maxpylang/tools/patchfuncs/instantiation.py b/maxpylang/tools/patchfuncs/instantiation.py index eb66ec9..e56f0f3 100644 --- a/maxpylang/tools/patchfuncs/instantiation.py +++ b/maxpylang/tools/patchfuncs/instantiation.py @@ -50,7 +50,7 @@ def load_template(self, t, verbose=True): def load_file(self, f, reorder=True, verbose=True): """ Helper function for instantiation. - Loads in an existing .maxpat file. + Loads in an existing .maxpat or .amxd file. reorder --> re-number objects, starting from 1 verbose --> log to console diff --git a/maxpylang/tools/patchfuncs/saving.py b/maxpylang/tools/patchfuncs/saving.py index 63299d2..f1afcce 100644 --- a/maxpylang/tools/patchfuncs/saving.py +++ b/maxpylang/tools/patchfuncs/saving.py @@ -38,7 +38,7 @@ def save(self, filename="default.maxpat", device_type=None, verbose=True, check= ) if ".amxd" not in Path(filename).suffixes: - # strip .maxpat if present, add .amxd + # replace existing extension (e.g. .maxpat) with .amxd, or append .amxd if none filename = str(Path(filename).with_suffix(".amxd")) json_dict = self.get_json() @@ -65,7 +65,7 @@ def save(self, filename="default.maxpat", device_type=None, verbose=True, check= #log messages if verbose: - if device_type: + if device_type is not None: print(f"maxpatch saved to {filename} (M4L {device_type})") else: print("maxpatch saved to", filename) From f1086bd19db2881cd25fb1dabf6dc17bc061a882 Mon Sep 17 00:00:00 2001 From: Chris Hyorok Lee Date: Sat, 28 Mar 2026 19:48:30 -0400 Subject: [PATCH 5/7] Harden load_amxd: rstrip nulls, compare raw bytes, add context to errors - Use rstrip(b"\x00") instead of removesuffix to handle multiple trailing nulls - Compare chunk tags as raw bytes instead of decoding to ASCII - Wrap JSONDecodeError with filename context for easier debugging - Include filename in "no ptch chunk" error message Co-Authored-By: Claude Opus 4.6 (1M context) --- maxpylang/amxd.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/maxpylang/amxd.py b/maxpylang/amxd.py index ff963de..2053d1f 100644 --- a/maxpylang/amxd.py +++ b/maxpylang/amxd.py @@ -47,13 +47,18 @@ def load_amxd(filename): offset = 0 while offset + 8 <= len(data): - field = data[offset:offset + 4].decode("ascii", errors="replace") + tag = data[offset:offset + 4] size = struct.unpack(" len(data): - raise ValueError(f"Chunk '{field}' size {size} extends past end of file") - if field == "ptch": + raise ValueError(f"Chunk {tag!r} size {size} extends past end of file") + if tag == b"ptch": json_bytes = data[offset + 8:offset + 8 + size] - return json.loads(json_bytes.removesuffix(b"\x00")) + try: + return json.loads(json_bytes.rstrip(b"\x00")) + except json.JSONDecodeError as e: + raise ValueError( + f"ptch chunk in '{filename}' contains invalid JSON: {e}" + ) from e offset += 8 + size - raise ValueError("No ptch chunk found in .amxd file") + raise ValueError(f"No ptch chunk found in '{filename}'") From 11dee9b8720a0d2df868faa6dcb7d653b9d68c25 Mon Sep 17 00:00:00 2001 From: Chris Hyorok Lee Date: Mon, 30 Mar 2026 11:07:08 -0400 Subject: [PATCH 6/7] Add CI: run all standalone examples and pytest suite Expand CI to run M4L examples (m4l_instrument, m4l_audio_effect), attributes, and random_pitch_generator alongside hello_world. Add pytest step with 81 tests covering amxd support and backward compat. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 29 +- tests/test_amxd.py | 531 ++++++++++++++++++++++++++++++ tests/test_backward_compat.py | 586 ++++++++++++++++++++++++++++++++++ 3 files changed, 1137 insertions(+), 9 deletions(-) create mode 100644 tests/test_amxd.py create mode 100644 tests/test_backward_compat.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 211e856..94f06da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,17 +26,28 @@ jobs: python -m pip install --upgrade pip pip install "setuptools>=77.0.0" wheel pip install -e . + pip install pytest - - name: Run hello_world example + - name: Run examples run: | - cd examples/hello_world - python main.py + cd examples/hello_world && python main.py && cd ../.. + cd examples/m4l_instrument && python main.py && cd ../.. + cd examples/m4l_audio_effect && python main.py && cd ../.. + cd examples/attributes && python main.py && cd ../.. + cd examples/random_pitch_generator && python tester3.py && cd ../.. - - name: Verify output file was created + - name: Verify output files were created run: | test -f examples/hello_world/hello_world.maxpat - echo "✓ hello_world.maxpat successfully created" - - - name: Display output file - run: | - cat examples/hello_world/hello_world.maxpat + echo "hello_world.maxpat created" + test -f examples/m4l_instrument/m4l_instrument.amxd + echo "m4l_instrument.amxd created" + test -f examples/m4l_audio_effect/m4l_audio_effect.amxd + echo "m4l_audio_effect.amxd created" + test -f examples/attributes/attributes.maxpat + echo "attributes.maxpat created" + test -f examples/random_pitch_generator/tester3.maxpat + echo "random_pitch_generator/tester3.maxpat created" + + - name: Run tests + run: python -m pytest tests/ -v diff --git a/tests/test_amxd.py b/tests/test_amxd.py new file mode 100644 index 0000000..9ce8f6d --- /dev/null +++ b/tests/test_amxd.py @@ -0,0 +1,531 @@ +""" +Exhaustive tests for Max for Live (.amxd) support in MaxPyLang. + +Tests: +1. Save all 3 device types +2. Binary format validation +3. Round-trip for each device type +4. Extension handling edge cases +5. Standalone functions (save_amxd / load_amxd) +6. M4L I/O objects +7. Complex M4L instrument patch +8. Example files execution +""" + +import os +import sys +import json +import struct +import tempfile +import warnings + +import pytest + +# Ensure the package is importable from the repo root +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +import maxpylang as mp +from maxpylang.amxd import save_amxd, load_amxd, DEVICE_TYPES + + +@pytest.fixture +def tmpdir_amxd(tmp_path): + """Return a temporary directory path for test outputs.""" + return tmp_path + + +def _make_simple_patch(): + """Create a simple patch with one oscillator and one dac.""" + patch = mp.MaxPatch(verbose=False) + osc = patch.place("cycle~ 440", verbose=False)[0] + dac = patch.place("ezdac~", verbose=False)[0] + patch.connect([osc.outs[0], dac.ins[0]], verbose=False) + return patch + + +# ===================================================================== +# TEST 1: Save all 3 device types +# ===================================================================== + +class TestSaveAllDeviceTypes: + @pytest.mark.parametrize("device_type", ["instrument", "audio_effect", "midi_effect"]) + def test_save_device_type(self, tmpdir_amxd, device_type): + """Each device type should produce a valid .amxd binary file.""" + patch = _make_simple_patch() + filename = str(tmpdir_amxd / f"test_{device_type}.amxd") + patch.save(filename, device_type=device_type, verbose=False, check=False) + + assert os.path.exists(filename), f"File not created: {filename}" + fsize = os.path.getsize(filename) + assert fsize > 0, f"File is empty: {filename}" + # Minimum: ampf(12) + meta(12) + ptch(8 + json) > 32 bytes + assert fsize > 32, f"File too small ({fsize} bytes), likely malformed" + + +# ===================================================================== +# TEST 2: Binary format validation +# ===================================================================== + +class TestBinaryFormatValidation: + @pytest.mark.parametrize("device_type,expected_code", [ + ("instrument", b"iiii"), + ("audio_effect", b"aaaa"), + ("midi_effect", b"mmmm"), + ]) + def test_ampf_chunk_device_code(self, tmpdir_amxd, device_type, expected_code): + """ampf chunk must contain the correct 4-byte device code.""" + patch = _make_simple_patch() + filename = str(tmpdir_amxd / f"test_{device_type}.amxd") + patch.save(filename, device_type=device_type, verbose=False, check=False) + + with open(filename, "rb") as f: + data = f.read() + + # ampf chunk starts at byte 0 + assert data[:4] == b"ampf", f"First chunk is not 'ampf': {data[:4]}" + ampf_size = struct.unpack(" load -> verify objects match -> re-save as .maxpat -> verify JSON identical.""" + # Create and save + patch1 = _make_simple_patch() + amxd_file = str(tmpdir_amxd / f"rt_{device_type}.amxd") + patch1.save(amxd_file, device_type=device_type, verbose=False, check=False) + + # Load back from .amxd + patch2 = mp.MaxPatch(load_file=amxd_file, verbose=False) + + # Verify same number of objects + assert patch2.num_objs == patch1.num_objs, ( + f"Object count mismatch: original={patch1.num_objs}, loaded={patch2.num_objs}" + ) + + # Verify object names match + orig_names = sorted(obj.name for obj in patch1.objs.values()) + loaded_names = sorted(obj.name for obj in patch2.objs.values()) + assert orig_names == loaded_names, ( + f"Object names mismatch: {orig_names} vs {loaded_names}" + ) + + # Re-save as .maxpat + maxpat_file = str(tmpdir_amxd / f"rt_{device_type}.maxpat") + patch2.save(maxpat_file, verbose=False, check=False) + + # Also re-save original as .maxpat + maxpat_orig = str(tmpdir_amxd / f"rt_{device_type}_orig.maxpat") + patch1.save(maxpat_orig, verbose=False, check=False) + + # Load both and compare JSON structure + with open(maxpat_file, "r") as f: + json2 = json.load(f) + with open(maxpat_orig, "r") as f: + json1 = json.load(f) + + # Compare box counts + assert len(json1["patcher"]["boxes"]) == len(json2["patcher"]["boxes"]), ( + f"Box count mismatch in JSON: {len(json1['patcher']['boxes'])} vs {len(json2['patcher']['boxes'])}" + ) + # Compare line counts + assert len(json1["patcher"]["lines"]) == len(json2["patcher"]["lines"]), ( + f"Line count mismatch in JSON: {len(json1['patcher']['lines'])} vs {len(json2['patcher']['lines'])}" + ) + + +# ===================================================================== +# TEST 4: Extension handling edge cases +# ===================================================================== + +class TestExtensionHandling: + def test_amxd_extension_with_device_type(self, tmpdir_amxd): + """save('foo.amxd', device_type='instrument') -> saves as .amxd.""" + patch = _make_simple_patch() + filename = str(tmpdir_amxd / "foo.amxd") + patch.save(filename, device_type="instrument", verbose=False, check=False) + assert os.path.exists(filename) + + def test_no_extension_with_device_type(self, tmpdir_amxd): + """save('foo', device_type='instrument') -> forces .amxd extension.""" + patch = _make_simple_patch() + filename = str(tmpdir_amxd / "foo") + patch.save(filename, device_type="instrument", verbose=False, check=False) + expected = str(tmpdir_amxd / "foo.amxd") + assert os.path.exists(expected), ( + f"Expected {expected} to be created. " + f"Files: {os.listdir(tmpdir_amxd)}" + ) + + def test_maxpat_extension_with_device_type_changes_to_amxd(self, tmpdir_amxd): + """save('foo.maxpat', device_type='instrument') -> changes to .amxd.""" + patch = _make_simple_patch() + filename = str(tmpdir_amxd / "foo.maxpat") + patch.save(filename, device_type="instrument", verbose=False, check=False) + expected = str(tmpdir_amxd / "foo.amxd") + assert os.path.exists(expected), ( + f"Expected {expected} to be created. " + f"Files: {os.listdir(tmpdir_amxd)}" + ) + + def test_amxd_extension_without_device_type_raises(self, tmpdir_amxd): + """save('foo.amxd') without device_type -> should raise ValueError.""" + patch = _make_simple_patch() + filename = str(tmpdir_amxd / "foo.amxd") + with pytest.raises(ValueError, match="device_type is required"): + patch.save(filename, verbose=False, check=False) + + def test_invalid_device_type_raises(self, tmpdir_amxd): + """save('foo.amxd', device_type='invalid_type') -> should raise error.""" + patch = _make_simple_patch() + filename = str(tmpdir_amxd / "foo.amxd") + with pytest.raises(ValueError): + patch.save(filename, device_type="invalid_type", verbose=False, check=False) + + +# ===================================================================== +# TEST 5: Standalone functions (save_amxd / load_amxd) +# ===================================================================== + +class TestStandaloneFunctions: + def test_save_amxd_creates_file(self, tmpdir_amxd): + """save_amxd() should write a binary file.""" + patcher_json = { + "patcher": { + "boxes": [], + "lines": [], + } + } + filename = str(tmpdir_amxd / "standalone.amxd") + save_amxd(patcher_json, filename, device_type="instrument") + assert os.path.exists(filename) + assert os.path.getsize(filename) > 0 + + def test_load_amxd_returns_json(self, tmpdir_amxd): + """load_amxd() should return the same JSON dict that was saved.""" + patcher_json = { + "patcher": { + "boxes": [{"box": {"maxclass": "newobj", "text": "cycle~ 440"}}], + "lines": [], + } + } + filename = str(tmpdir_amxd / "standalone_rt.amxd") + save_amxd(patcher_json, filename, device_type="audio_effect") + + loaded = load_amxd(filename) + assert loaded == patcher_json, ( + f"JSON mismatch:\n saved: {patcher_json}\n loaded: {loaded}" + ) + + def test_save_amxd_invalid_device_type(self, tmpdir_amxd): + """save_amxd() with bad device_type should raise ValueError.""" + patcher_json = {"patcher": {"boxes": [], "lines": []}} + filename = str(tmpdir_amxd / "bad_type.amxd") + with pytest.raises(ValueError, match="Unknown device_type"): + save_amxd(patcher_json, filename, device_type="bogus") + + def test_load_amxd_no_ptch_raises(self, tmpdir_amxd): + """load_amxd() on a file with no ptch chunk should raise ValueError.""" + filename = str(tmpdir_amxd / "no_ptch.amxd") + with open(filename, "wb") as f: + f.write(b"ampf") + f.write(struct.pack("= 2, f"plugin~ should have >= 2 outlets, got {len(obj.outs)}" + + def test_plugout_tilde(self): + """plugout~ should create with proper inlet/outlet counts.""" + patch = mp.MaxPatch(verbose=False) + obj = patch.place("plugout~", verbose=False)[0] + assert obj.name == "plugout~" + assert not obj.notknown(), "plugout~ should be a known object" + assert len(obj.ins) >= 2, f"plugout~ should have >= 2 inlets, got {len(obj.ins)}" + + def test_notein(self): + """notein should create with proper inlet/outlet counts.""" + patch = mp.MaxPatch(verbose=False) + obj = patch.place("notein", verbose=False)[0] + assert obj.name == "notein" + assert not obj.notknown(), "notein should be a known object" + assert len(obj.outs) >= 3, f"notein should have >= 3 outlets (pitch, vel, channel), got {len(obj.outs)}" + + def test_midiin(self): + """midiin should create correctly.""" + patch = mp.MaxPatch(verbose=False) + obj = patch.place("midiin", verbose=False)[0] + assert obj.name == "midiin" + assert not obj.notknown(), "midiin should be a known object" + assert len(obj.outs) >= 1, f"midiin should have >= 1 outlet, got {len(obj.outs)}" + + def test_midiout(self): + """midiout should create correctly.""" + patch = mp.MaxPatch(verbose=False) + obj = patch.place("midiout", verbose=False)[0] + assert obj.name == "midiout" + assert not obj.notknown(), "midiout should be a known object" + assert len(obj.ins) >= 1, f"midiout should have >= 1 inlet, got {len(obj.ins)}" + + def test_midiparse(self): + """midiparse should create correctly.""" + patch = mp.MaxPatch(verbose=False) + obj = patch.place("midiparse", verbose=False)[0] + assert obj.name == "midiparse" + assert not obj.notknown(), "midiparse should be a known object" + assert len(obj.outs) >= 1, f"midiparse should have >= 1 outlet, got {len(obj.outs)}" + + def test_midiformat(self): + """midiformat should create correctly.""" + patch = mp.MaxPatch(verbose=False) + obj = patch.place("midiformat", verbose=False)[0] + assert obj.name == "midiformat" + assert not obj.notknown(), "midiformat should be a known object" + assert len(obj.ins) >= 1, f"midiformat should have >= 1 inlet, got {len(obj.ins)}" + + +# ===================================================================== +# TEST 7: Complex M4L instrument patch +# ===================================================================== + +class TestComplexM4LPatch: + def test_full_instrument_round_trip(self, tmpdir_amxd): + """Build notein->mtof->cycle~->*~->clip~->plugout~, save as .amxd, load back, verify.""" + patch = mp.MaxPatch(verbose=False) + + # Build the chain + patch.set_position(30, 60) + notein = patch.place("notein", verbose=False)[0] + + patch.set_position(30, 100) + mtof = patch.place("mtof", verbose=False)[0] + + patch.set_position(30, 140) + osc = patch.place("cycle~", verbose=False)[0] + + patch.set_position(30, 180) + mult = patch.place("*~ 0.5", verbose=False)[0] + + patch.set_position(30, 220) + clip = patch.place("clip~ -1. 1.", verbose=False)[0] + + patch.set_position(30, 260) + plugout = patch.place("plugout~", verbose=False)[0] + + # Connect the chain + patch.connect( + [notein.outs[0], mtof.ins[0]], + [mtof.outs[0], osc.ins[0]], + [osc.outs[0], mult.ins[0]], + [mult.outs[0], clip.ins[0]], + [clip.outs[0], plugout.ins[0]], + [clip.outs[0], plugout.ins[1]], + verbose=False, + ) + + # Verify objects were created + expected_names = {"notein", "mtof", "cycle~", "*~", "clip~", "plugout~"} + actual_names = {obj.name for obj in patch.objs.values()} + assert expected_names == actual_names, ( + f"Object names mismatch: expected {expected_names}, got {actual_names}" + ) + + # Save as .amxd + amxd_file = str(tmpdir_amxd / "complex_instrument.amxd") + patch.save(amxd_file, device_type="instrument", verbose=False, check=False) + assert os.path.exists(amxd_file) + + # Load back + patch2 = mp.MaxPatch(load_file=amxd_file, verbose=False) + + # Verify same object count + assert patch2.num_objs == patch.num_objs, ( + f"Object count mismatch after load: {patch.num_objs} vs {patch2.num_objs}" + ) + + # Verify object names survived + loaded_names = {obj.name for obj in patch2.objs.values()} + assert expected_names == loaded_names, ( + f"Object names after load: expected {expected_names}, got {loaded_names}" + ) + + # Verify connections survived - count total connections + orig_connections = 0 + for obj in patch.objs.values(): + for outlet in obj.outs: + orig_connections += len(outlet.destinations) + + loaded_connections = 0 + for obj in patch2.objs.values(): + for outlet in obj.outs: + loaded_connections += len(outlet.destinations) + + assert orig_connections == loaded_connections, ( + f"Connection count mismatch: original={orig_connections}, loaded={loaded_connections}" + ) + + # Re-save as .maxpat and verify JSON is valid + maxpat_file = str(tmpdir_amxd / "complex_instrument.maxpat") + patch2.save(maxpat_file, verbose=False, check=False) + with open(maxpat_file, "r") as f: + json_data = json.load(f) + assert len(json_data["patcher"]["boxes"]) == 6 + assert len(json_data["patcher"]["lines"]) == 6 + + +# ===================================================================== +# TEST 8: Run example files +# ===================================================================== + +class TestExampleFiles: + def test_m4l_instrument_example(self, tmpdir_amxd): + """examples/m4l_instrument/main.py should run without errors.""" + example_path = os.path.join( + os.path.dirname(__file__), "..", "examples", "m4l_instrument", "main.py" + ) + assert os.path.exists(example_path), f"Example file not found: {example_path}" + + # Change to tmpdir so output files go there + old_cwd = os.getcwd() + os.chdir(str(tmpdir_amxd)) + try: + exec(open(example_path).read(), {"__name__": "__main__"}) + finally: + os.chdir(old_cwd) + + # Verify the output file was created + amxd_file = str(tmpdir_amxd / "m4l_instrument.amxd") + assert os.path.exists(amxd_file), ( + f"Expected output file not found. Files: {os.listdir(tmpdir_amxd)}" + ) + assert os.path.getsize(amxd_file) > 0 + + def test_m4l_audio_effect_example(self, tmpdir_amxd): + """examples/m4l_audio_effect/main.py should run without errors.""" + example_path = os.path.join( + os.path.dirname(__file__), "..", "examples", "m4l_audio_effect", "main.py" + ) + assert os.path.exists(example_path), f"Example file not found: {example_path}" + + old_cwd = os.getcwd() + os.chdir(str(tmpdir_amxd)) + try: + exec(open(example_path).read(), {"__name__": "__main__"}) + finally: + os.chdir(old_cwd) + + amxd_file = str(tmpdir_amxd / "m4l_audio_effect.amxd") + assert os.path.exists(amxd_file), ( + f"Expected output file not found. Files: {os.listdir(tmpdir_amxd)}" + ) + assert os.path.getsize(amxd_file) > 0 diff --git a/tests/test_backward_compat.py b/tests/test_backward_compat.py new file mode 100644 index 0000000..cfe8d9a --- /dev/null +++ b/tests/test_backward_compat.py @@ -0,0 +1,586 @@ +""" +Backward compatibility test suite for MaxPyLang. +Ensures the new M4L/.amxd changes don't break any existing functionality. + +Run with: python -m pytest tests/test_backward_compat.py -v +""" + +import json +import os +import sys +import tempfile +import warnings + +import pytest + +# Ensure maxpylang is importable from the project root +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + + +# ============================================================ +# TEST 1: Import +# ============================================================ + +class TestImport: + def test_import_maxpylang(self): + """import maxpylang as mp — core classes present.""" + import maxpylang as mp + assert hasattr(mp, "MaxPatch") + assert hasattr(mp, "MaxObject") + assert hasattr(mp, "Inlet") + assert hasattr(mp, "Outlet") + assert hasattr(mp, "import_objs") + assert hasattr(mp, "save_amxd") + assert hasattr(mp, "load_amxd") + assert hasattr(mp, "DEVICE_TYPES") + + def test_import_objects(self): + """from maxpylang.objects import stubs.""" + from maxpylang.objects import cycle_tilde, ezdac_tilde, metro, toggle + assert cycle_tilde is not None + assert ezdac_tilde is not None + assert metro is not None + assert toggle is not None + + +# ============================================================ +# TEST 2: Basic patch creation and save +# ============================================================ + +class TestBasicPatchCreation: + def test_create_place_connect_save(self, tmp_path): + """Create a MaxPatch, place objects, connect them, save as .maxpat.""" + import maxpylang as mp + + patch = mp.MaxPatch(verbose=False) + osc = patch.place("cycle~ 440", verbose=False)[0] + dac = patch.place("ezdac~", verbose=False)[0] + patch.connect([osc.outs[0], dac.ins[0]], verbose=False) + + out = str(tmp_path / "test_basic.maxpat") + patch.save(out, verbose=False, check=False) + + assert os.path.exists(out) + with open(out, "r") as f: + data = json.load(f) + assert "patcher" in data + assert len(data["patcher"]["boxes"]) == 2 + assert len(data["patcher"]["lines"]) == 1 + + +# ============================================================ +# TEST 3: All save() variations +# ============================================================ + +class TestSaveVariations: + def test_save_no_extension(self, tmp_path): + """save('foo') auto-appends .maxpat.""" + import maxpylang as mp + os.chdir(tmp_path) + + patch = mp.MaxPatch(verbose=False) + patch.place("toggle", verbose=False) + patch.save("foo", verbose=False, check=False) + assert os.path.exists(tmp_path / "foo.maxpat") + + def test_save_with_extension(self, tmp_path): + """save('bar.maxpat') explicit extension.""" + import maxpylang as mp + + out = str(tmp_path / "bar.maxpat") + patch = mp.MaxPatch(verbose=False) + patch.place("toggle", verbose=False) + patch.save(out, verbose=False, check=False) + assert os.path.exists(out) + + def test_save_verbose_false(self, tmp_path, capsys): + """save(verbose=False) suppresses output.""" + import maxpylang as mp + + out = str(tmp_path / "verbose_test.maxpat") + patch = mp.MaxPatch(verbose=False) + patch.place("toggle", verbose=False) + patch.save(out, verbose=False, check=False) + captured = capsys.readouterr() + assert "saved" not in captured.out.lower() + + def test_save_verbose_true(self, tmp_path, capsys): + """save(verbose=True) prints save message.""" + import maxpylang as mp + + out = str(tmp_path / "verbose_true_test.maxpat") + patch = mp.MaxPatch(verbose=False) + patch.place("toggle", verbose=False) + patch.save(out, verbose=True, check=False) + captured = capsys.readouterr() + assert "saved" in captured.out.lower() + + def test_save_check_false(self, tmp_path): + """save(check=False) skips checking.""" + import maxpylang as mp + + out = str(tmp_path / "check_false.maxpat") + patch = mp.MaxPatch(verbose=False) + patch.place("toggle", verbose=False) + patch.save(out, verbose=False, check=False) + assert os.path.exists(out) + + def test_save_check_true(self, tmp_path): + """save(check=True) runs checking without errors.""" + import maxpylang as mp + + out = str(tmp_path / "check_true.maxpat") + patch = mp.MaxPatch(verbose=False) + patch.place("toggle", verbose=False) + patch.save(out, verbose=False, check=True) + assert os.path.exists(out) + + +# ============================================================ +# TEST 4: Loading existing .maxpat files +# ============================================================ + +class TestLoadFile: + def test_load_maxpat(self, tmp_path): + """MaxPatch(load_file=...) loads .maxpat correctly.""" + import maxpylang as mp + + # Create a patch to load + out = str(tmp_path / "to_load.maxpat") + patch1 = mp.MaxPatch(verbose=False) + osc = patch1.place("cycle~ 440", verbose=False)[0] + dac = patch1.place("ezdac~", verbose=False)[0] + patch1.connect([osc.outs[0], dac.ins[0]], verbose=False) + patch1.save(out, verbose=False, check=False) + + # Load it back + patch2 = mp.MaxPatch(load_file=out, verbose=False) + assert patch2.num_objs == 2 + obj_names = [obj.name for obj in patch2.objs.values()] + assert "cycle~" in obj_names + assert "ezdac~" in obj_names + + def test_load_existing_example(self): + """Load an existing example .maxpat from the repo.""" + import maxpylang as mp + + example = os.path.join( + os.path.dirname(__file__), + "..", + "examples", + "random_pitch_generator", + "tester3.maxpat", + ) + if not os.path.exists(example): + pytest.skip("Example file not found") + + patch = mp.MaxPatch(load_file=example, verbose=False) + assert patch.num_objs > 0 + + def test_load_and_resave_roundtrip(self, tmp_path): + """Load-and-resave roundtrip preserves objects and connections.""" + import maxpylang as mp + + orig_path = str(tmp_path / "roundtrip_orig.maxpat") + resave_path = str(tmp_path / "roundtrip_resave.maxpat") + + patch1 = mp.MaxPatch(verbose=False) + m = patch1.place("metro 500", verbose=False)[0] + t = patch1.place("toggle", verbose=False)[0] + patch1.connect([t.outs[0], m.ins[0]], verbose=False) + patch1.save(orig_path, verbose=False, check=False) + + patch2 = mp.MaxPatch(load_file=orig_path, verbose=False) + patch2.save(resave_path, verbose=False, check=False) + + with open(orig_path) as f: + orig = json.load(f) + with open(resave_path) as f: + resaved = json.load(f) + + assert len(orig["patcher"]["boxes"]) == len(resaved["patcher"]["boxes"]) + assert len(orig["patcher"]["lines"]) == len(resaved["patcher"]["lines"]) + + +# ============================================================ +# TEST 5: Object placement +# ============================================================ + +class TestPlacement: + def test_place_string_returns_list(self): + """place() with string returns list of MaxObject.""" + import maxpylang as mp + + patch = mp.MaxPatch(verbose=False) + objs = patch.place("cycle~ 440", verbose=False) + assert isinstance(objs, list) + assert len(objs) == 1 + assert objs[0].name == "cycle~" + + def test_place_num_objs(self): + """place() with num_objs=5 returns 5 objects.""" + import maxpylang as mp + + patch = mp.MaxPatch(verbose=False) + toggles = patch.place("toggle", num_objs=5, verbose=False) + assert len(toggles) == 5 + for t in toggles: + assert t.name == "toggle" + + def test_place_starting_pos(self): + """place() with starting_pos places objects offset from given position. + + Grid placement adds spacing before the first object, so with + starting_pos=[100, 200] and default spacing=[80, 80], the first + object is at x=180, y=200. + """ + import maxpylang as mp + + patch = mp.MaxPatch(verbose=False) + objs = patch.place("toggle", num_objs=3, starting_pos=[100, 200], verbose=False) + assert len(objs) == 3 + # Grid spacing adds x_space before first object + first_pos = objs[0]._dict["box"]["patching_rect"][:2] + assert first_pos[0] == 180.0 # 100 + 80 (default x spacing) + assert first_pos[1] == 200.0 + + def test_place_stub_object(self): + """place() with stub object works.""" + from maxpylang.objects import cycle_tilde + import maxpylang as mp + + patch = mp.MaxPatch(verbose=False) + osc = patch.place(cycle_tilde, verbose=False)[0] + assert osc.name == "cycle~" + + def test_place_spacing_grid(self): + """place() with spacing_type='grid'.""" + import maxpylang as mp + + patch = mp.MaxPatch(verbose=False) + objs = patch.place( + "toggle", num_objs=4, spacing_type="grid", + spacing=[100, 100], starting_pos=[0, 0], verbose=False + ) + assert len(objs) == 4 + + def test_place_spacing_vertical(self): + """place() with spacing_type='vertical' (spacing must be a scalar).""" + import maxpylang as mp + + patch = mp.MaxPatch(verbose=False) + objs = patch.place( + "toggle", num_objs=3, spacing_type="vertical", + spacing=80, starting_pos=[0, 0], verbose=False + ) + assert len(objs) == 3 + + +# ============================================================ +# TEST 6: Connections +# ============================================================ + +class TestConnections: + def test_single_connection(self): + """Single connection wires correctly.""" + import maxpylang as mp + + patch = mp.MaxPatch(verbose=False) + a = patch.place("toggle", verbose=False)[0] + b = patch.place("metro 500", verbose=False)[0] + patch.connect([a.outs[0], b.ins[0]], verbose=False) + assert len(a.outs[0].destinations) == 1 + assert a.outs[0].destinations[0].parent is b + + def test_multiple_connections(self): + """Multiple connections in one call.""" + import maxpylang as mp + + patch = mp.MaxPatch(verbose=False) + osc = patch.place("cycle~ 440", verbose=False)[0] + gain = patch.place("gain~", verbose=False)[0] + dac = patch.place("ezdac~", verbose=False)[0] + patch.connect( + [osc.outs[0], gain.ins[0]], + [gain.outs[0], dac.ins[0]], + [gain.outs[0], dac.ins[1]], + verbose=False, + ) + assert len(osc.outs[0].destinations) == 1 + assert len(gain.outs[0].destinations) == 2 + + +# ============================================================ +# TEST 7: Common objects — inlets/outlets +# ============================================================ + +class TestCommonObjects: + def test_cycle_tilde(self): + """cycle~ has inlets and outlets.""" + import maxpylang as mp + obj = mp.MaxObject("cycle~ 440") + assert obj.name == "cycle~" + assert len(obj.ins) > 0 + assert len(obj.outs) > 0 + + def test_ezdac_tilde(self): + """ezdac~ has >= 2 inlets.""" + import maxpylang as mp + obj = mp.MaxObject("ezdac~") + assert obj.name == "ezdac~" + assert len(obj.ins) >= 2 + + def test_metro(self): + """metro has inlets and outlets.""" + import maxpylang as mp + obj = mp.MaxObject("metro 500") + assert obj.name == "metro" + assert len(obj.ins) >= 1 + assert len(obj.outs) >= 1 + + def test_toggle(self): + """toggle has inlets and outlets.""" + import maxpylang as mp + obj = mp.MaxObject("toggle") + assert obj.name == "toggle" + assert len(obj.ins) >= 1 + assert len(obj.outs) >= 1 + + def test_pack(self): + """pack has inlets and outlets.""" + import maxpylang as mp + obj = mp.MaxObject("pack 0 0 0") + assert obj.name == "pack" + assert len(obj.ins) >= 1 + assert len(obj.outs) >= 1 + + def test_trigger(self): + """trigger b i f has 3 outlets.""" + import maxpylang as mp + obj = mp.MaxObject("trigger b i f") + assert obj.name == "trigger" + assert len(obj.ins) >= 1 + assert len(obj.outs) == 3 + + def test_metro_with_attribs(self): + """metro with @active attribute parses correctly.""" + import maxpylang as mp + obj = mp.MaxObject("metro 500 @active 1") + assert obj.name == "metro" + + +# ============================================================ +# TEST 8: Abstractions +# ============================================================ + +class TestAbstractions: + def test_abstraction_basic(self): + """MaxObject(abstraction=True, inlets=2, outlets=2) declares abstraction.""" + import maxpylang as mp + obj = mp.MaxObject("my_synth", abstraction=True, inlets=2, outlets=2) + assert obj.name == "my_synth" + assert len(obj.ins) == 2 + assert len(obj.outs) == 2 + assert obj.notknown() is False + + def test_abstraction_with_args(self): + """Abstraction with arguments.""" + import maxpylang as mp + obj = mp.MaxObject("my_synth 440 0.5", abstraction=True, inlets=2, outlets=2) + assert obj.name == "my_synth" + assert len(obj.ins) == 2 + assert len(obj.outs) == 2 + + def test_abstraction_place(self): + """Place abstraction in patch.""" + import maxpylang as mp + patch = mp.MaxPatch(verbose=False) + obj = mp.MaxObject("my_fx", abstraction=True, inlets=1, outlets=1) + placed = patch.place(obj, verbose=False)[0] + assert placed.name == "my_fx" + assert len(placed.ins) == 1 + assert len(placed.outs) == 1 + + def test_abstraction_default_io(self): + """Abstraction with default I/O (0, 0).""" + import maxpylang as mp + obj = mp.MaxObject("no_io_synth", abstraction=True) + assert obj.name == "no_io_synth" + assert len(obj.ins) == 0 + assert len(obj.outs) == 0 + + +# ============================================================ +# TEST 9: Edge case — save with device_type=None +# ============================================================ + +class TestDeviceTypeNone: + def test_save_device_type_none(self, tmp_path): + """save(device_type=None) produces .maxpat (not .amxd).""" + import maxpylang as mp + + out = str(tmp_path / "devnone.maxpat") + patch = mp.MaxPatch(verbose=False) + patch.place("toggle", verbose=False) + patch.save(out, device_type=None, verbose=False, check=False) + assert os.path.exists(out) + # Verify it's valid JSON (not binary amxd) + with open(out, "r") as f: + data = json.load(f) + assert "patcher" in data + + def test_save_no_ext_device_type_none(self, tmp_path): + """save('foo', device_type=None) auto-appends .maxpat.""" + import maxpylang as mp + os.chdir(tmp_path) + + patch = mp.MaxPatch(verbose=False) + patch.place("toggle", verbose=False) + patch.save("foo2", device_type=None, verbose=False, check=False) + assert os.path.exists(tmp_path / "foo2.maxpat") + with open(tmp_path / "foo2.maxpat", "r") as f: + data = json.load(f) + assert "patcher" in data + + def test_save_amxd_without_device_type_raises(self, tmp_path): + """save('test.amxd') without device_type raises ValueError.""" + import maxpylang as mp + + out = str(tmp_path / "test.amxd") + patch = mp.MaxPatch(verbose=False) + patch.place("toggle", verbose=False) + with pytest.raises(ValueError, match="device_type"): + patch.save(out, verbose=False, check=False) + + +# ============================================================ +# TEST 10: set_position +# ============================================================ + +class TestSetPosition: + def test_set_position(self): + """set_position works correctly.""" + import maxpylang as mp + patch = mp.MaxPatch(verbose=False) + patch.set_position(100, 200) + assert patch.curr_position == [100, 200] + + +# ============================================================ +# TEST 11: Patch properties +# ============================================================ + +class TestPatchProperties: + def test_objs_and_num_objs(self): + """patch.objs and patch.num_objs are correct.""" + import maxpylang as mp + patch = mp.MaxPatch(verbose=False) + assert isinstance(patch.objs, dict) + assert patch.num_objs == 0 + patch.place("toggle", verbose=False) + assert patch.num_objs == 1 + assert len(patch.objs) == 1 + + +# ============================================================ +# TEST 12: MaxObject methods +# ============================================================ + +class TestMaxObjectMethods: + def test_move(self): + """obj.move(x, y) repositions object.""" + import maxpylang as mp + obj = mp.MaxObject("toggle") + obj.move(150, 250) + rect = obj._dict["box"]["patching_rect"] + assert rect[0] == 150.0 + assert rect[1] == 250.0 + + def test_edit(self): + """obj.edit() changes object text.""" + import maxpylang as mp + obj = mp.MaxObject("metro 500") + obj.edit(text_add="replace", text="metro 1000") + assert obj.name == "metro" + + def test_notknown_for_known_object(self): + """obj.notknown() returns False for known objects.""" + import maxpylang as mp + obj = mp.MaxObject("toggle") + assert obj.notknown() is False + + +# ============================================================ +# TEST 13: Full workflow (docs examples) +# ============================================================ + +class TestFullWorkflow: + def test_audio_chain(self, tmp_path): + """Full audio chain from docs example.""" + from maxpylang.objects import gain_tilde, ezdac_tilde + import maxpylang as mp + + patch = mp.MaxPatch(verbose=False) + osc = patch.place("cycle~ 440", verbose=False)[0] + gain = patch.place(gain_tilde, verbose=False)[0] + dac = patch.place(ezdac_tilde, verbose=False)[0] + patch.connect( + [osc.outs[0], gain.ins[0]], + [gain.outs[0], dac.ins[0]], + [gain.outs[0], dac.ins[1]], + verbose=False, + ) + out = str(tmp_path / "audio_chain_test.maxpat") + patch.save(out, verbose=False, check=False) + assert os.path.exists(out) + with open(out) as f: + data = json.load(f) + assert len(data["patcher"]["boxes"]) == 3 + assert len(data["patcher"]["lines"]) == 3 + + def test_loop_pattern(self, tmp_path): + """Loop pattern from docs example.""" + import maxpylang as mp + + patch = mp.MaxPatch(verbose=False) + n = 5 + toggles = patch.place("toggle", num_objs=n, starting_pos=[0, 100], verbose=False) + gates = patch.place("gate", num_objs=n, starting_pos=[0, 200], verbose=False) + for t, g in zip(toggles, gates): + patch.connect([t.outs[0], g.ins[0]], verbose=False) + out = str(tmp_path / "loop_test.maxpat") + patch.save(out, verbose=False, check=False) + assert os.path.exists(out) + with open(out) as f: + data = json.load(f) + assert len(data["patcher"]["boxes"]) == 10 + assert len(data["patcher"]["lines"]) == 5 + + +# ============================================================ +# TEST 14: JSON output format correctness +# ============================================================ + +class TestJSONFormat: + def test_json_structure(self): + """get_json() returns proper .maxpat JSON structure.""" + import maxpylang as mp + + patch = mp.MaxPatch(verbose=False) + osc = patch.place("cycle~ 440", verbose=False)[0] + dac = patch.place("ezdac~", verbose=False)[0] + patch.connect([osc.outs[0], dac.ins[0]], verbose=False) + json_dict = patch.get_json() + assert "patcher" in json_dict + assert "boxes" in json_dict["patcher"] + assert "lines" in json_dict["patcher"] + # Check boxes have required fields + for box in json_dict["patcher"]["boxes"]: + assert "box" in box + assert "id" in box["box"] + assert "patching_rect" in box["box"] + # Check lines have required fields + for line in json_dict["patcher"]["lines"]: + assert "patchline" in line + assert "source" in line["patchline"] + assert "destination" in line["patchline"] From fe8322536778ec7aa74e95bb03153c0e512aaeaf Mon Sep 17 00:00:00 2001 From: Chris Hyorok Lee Date: Mon, 30 Mar 2026 11:21:58 -0400 Subject: [PATCH 7/7] Address code review: elif .maxpat guard, remove backward compat tests - load_file: change else to elif .maxpat with ValueError on unknown extensions - Remove test_backward_compat.py per reviewer request (one-time verification) Co-Authored-By: Claude Opus 4.6 (1M context) --- maxpylang/tools/patchfuncs/instantiation.py | 10 +- tests/test_backward_compat.py | 586 -------------------- 2 files changed, 8 insertions(+), 588 deletions(-) delete mode 100644 tests/test_backward_compat.py diff --git a/maxpylang/tools/patchfuncs/instantiation.py b/maxpylang/tools/patchfuncs/instantiation.py index e56f0f3..3f96dda 100644 --- a/maxpylang/tools/patchfuncs/instantiation.py +++ b/maxpylang/tools/patchfuncs/instantiation.py @@ -61,12 +61,18 @@ def load_file(self, f, reorder=True, verbose=True): print("Patcher: loading patch from existing file:", os.path.split(f)[-1]) #read .maxpat or .amxd file into dict - if Path(f).suffix == ".amxd": + ext = Path(f).suffix + if ext == ".amxd": from ...amxd import load_amxd patch_dict = load_amxd(f) - else: + elif ext == ".maxpat": with open(f, 'r') as file: patch_dict = json.loads(file.read()) + else: + raise ValueError( + f"Unsupported file extension '{ext}'. " + f"Expected '.maxpat' or '.amxd'." + ) #load in objs diff --git a/tests/test_backward_compat.py b/tests/test_backward_compat.py deleted file mode 100644 index cfe8d9a..0000000 --- a/tests/test_backward_compat.py +++ /dev/null @@ -1,586 +0,0 @@ -""" -Backward compatibility test suite for MaxPyLang. -Ensures the new M4L/.amxd changes don't break any existing functionality. - -Run with: python -m pytest tests/test_backward_compat.py -v -""" - -import json -import os -import sys -import tempfile -import warnings - -import pytest - -# Ensure maxpylang is importable from the project root -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) - - -# ============================================================ -# TEST 1: Import -# ============================================================ - -class TestImport: - def test_import_maxpylang(self): - """import maxpylang as mp — core classes present.""" - import maxpylang as mp - assert hasattr(mp, "MaxPatch") - assert hasattr(mp, "MaxObject") - assert hasattr(mp, "Inlet") - assert hasattr(mp, "Outlet") - assert hasattr(mp, "import_objs") - assert hasattr(mp, "save_amxd") - assert hasattr(mp, "load_amxd") - assert hasattr(mp, "DEVICE_TYPES") - - def test_import_objects(self): - """from maxpylang.objects import stubs.""" - from maxpylang.objects import cycle_tilde, ezdac_tilde, metro, toggle - assert cycle_tilde is not None - assert ezdac_tilde is not None - assert metro is not None - assert toggle is not None - - -# ============================================================ -# TEST 2: Basic patch creation and save -# ============================================================ - -class TestBasicPatchCreation: - def test_create_place_connect_save(self, tmp_path): - """Create a MaxPatch, place objects, connect them, save as .maxpat.""" - import maxpylang as mp - - patch = mp.MaxPatch(verbose=False) - osc = patch.place("cycle~ 440", verbose=False)[0] - dac = patch.place("ezdac~", verbose=False)[0] - patch.connect([osc.outs[0], dac.ins[0]], verbose=False) - - out = str(tmp_path / "test_basic.maxpat") - patch.save(out, verbose=False, check=False) - - assert os.path.exists(out) - with open(out, "r") as f: - data = json.load(f) - assert "patcher" in data - assert len(data["patcher"]["boxes"]) == 2 - assert len(data["patcher"]["lines"]) == 1 - - -# ============================================================ -# TEST 3: All save() variations -# ============================================================ - -class TestSaveVariations: - def test_save_no_extension(self, tmp_path): - """save('foo') auto-appends .maxpat.""" - import maxpylang as mp - os.chdir(tmp_path) - - patch = mp.MaxPatch(verbose=False) - patch.place("toggle", verbose=False) - patch.save("foo", verbose=False, check=False) - assert os.path.exists(tmp_path / "foo.maxpat") - - def test_save_with_extension(self, tmp_path): - """save('bar.maxpat') explicit extension.""" - import maxpylang as mp - - out = str(tmp_path / "bar.maxpat") - patch = mp.MaxPatch(verbose=False) - patch.place("toggle", verbose=False) - patch.save(out, verbose=False, check=False) - assert os.path.exists(out) - - def test_save_verbose_false(self, tmp_path, capsys): - """save(verbose=False) suppresses output.""" - import maxpylang as mp - - out = str(tmp_path / "verbose_test.maxpat") - patch = mp.MaxPatch(verbose=False) - patch.place("toggle", verbose=False) - patch.save(out, verbose=False, check=False) - captured = capsys.readouterr() - assert "saved" not in captured.out.lower() - - def test_save_verbose_true(self, tmp_path, capsys): - """save(verbose=True) prints save message.""" - import maxpylang as mp - - out = str(tmp_path / "verbose_true_test.maxpat") - patch = mp.MaxPatch(verbose=False) - patch.place("toggle", verbose=False) - patch.save(out, verbose=True, check=False) - captured = capsys.readouterr() - assert "saved" in captured.out.lower() - - def test_save_check_false(self, tmp_path): - """save(check=False) skips checking.""" - import maxpylang as mp - - out = str(tmp_path / "check_false.maxpat") - patch = mp.MaxPatch(verbose=False) - patch.place("toggle", verbose=False) - patch.save(out, verbose=False, check=False) - assert os.path.exists(out) - - def test_save_check_true(self, tmp_path): - """save(check=True) runs checking without errors.""" - import maxpylang as mp - - out = str(tmp_path / "check_true.maxpat") - patch = mp.MaxPatch(verbose=False) - patch.place("toggle", verbose=False) - patch.save(out, verbose=False, check=True) - assert os.path.exists(out) - - -# ============================================================ -# TEST 4: Loading existing .maxpat files -# ============================================================ - -class TestLoadFile: - def test_load_maxpat(self, tmp_path): - """MaxPatch(load_file=...) loads .maxpat correctly.""" - import maxpylang as mp - - # Create a patch to load - out = str(tmp_path / "to_load.maxpat") - patch1 = mp.MaxPatch(verbose=False) - osc = patch1.place("cycle~ 440", verbose=False)[0] - dac = patch1.place("ezdac~", verbose=False)[0] - patch1.connect([osc.outs[0], dac.ins[0]], verbose=False) - patch1.save(out, verbose=False, check=False) - - # Load it back - patch2 = mp.MaxPatch(load_file=out, verbose=False) - assert patch2.num_objs == 2 - obj_names = [obj.name for obj in patch2.objs.values()] - assert "cycle~" in obj_names - assert "ezdac~" in obj_names - - def test_load_existing_example(self): - """Load an existing example .maxpat from the repo.""" - import maxpylang as mp - - example = os.path.join( - os.path.dirname(__file__), - "..", - "examples", - "random_pitch_generator", - "tester3.maxpat", - ) - if not os.path.exists(example): - pytest.skip("Example file not found") - - patch = mp.MaxPatch(load_file=example, verbose=False) - assert patch.num_objs > 0 - - def test_load_and_resave_roundtrip(self, tmp_path): - """Load-and-resave roundtrip preserves objects and connections.""" - import maxpylang as mp - - orig_path = str(tmp_path / "roundtrip_orig.maxpat") - resave_path = str(tmp_path / "roundtrip_resave.maxpat") - - patch1 = mp.MaxPatch(verbose=False) - m = patch1.place("metro 500", verbose=False)[0] - t = patch1.place("toggle", verbose=False)[0] - patch1.connect([t.outs[0], m.ins[0]], verbose=False) - patch1.save(orig_path, verbose=False, check=False) - - patch2 = mp.MaxPatch(load_file=orig_path, verbose=False) - patch2.save(resave_path, verbose=False, check=False) - - with open(orig_path) as f: - orig = json.load(f) - with open(resave_path) as f: - resaved = json.load(f) - - assert len(orig["patcher"]["boxes"]) == len(resaved["patcher"]["boxes"]) - assert len(orig["patcher"]["lines"]) == len(resaved["patcher"]["lines"]) - - -# ============================================================ -# TEST 5: Object placement -# ============================================================ - -class TestPlacement: - def test_place_string_returns_list(self): - """place() with string returns list of MaxObject.""" - import maxpylang as mp - - patch = mp.MaxPatch(verbose=False) - objs = patch.place("cycle~ 440", verbose=False) - assert isinstance(objs, list) - assert len(objs) == 1 - assert objs[0].name == "cycle~" - - def test_place_num_objs(self): - """place() with num_objs=5 returns 5 objects.""" - import maxpylang as mp - - patch = mp.MaxPatch(verbose=False) - toggles = patch.place("toggle", num_objs=5, verbose=False) - assert len(toggles) == 5 - for t in toggles: - assert t.name == "toggle" - - def test_place_starting_pos(self): - """place() with starting_pos places objects offset from given position. - - Grid placement adds spacing before the first object, so with - starting_pos=[100, 200] and default spacing=[80, 80], the first - object is at x=180, y=200. - """ - import maxpylang as mp - - patch = mp.MaxPatch(verbose=False) - objs = patch.place("toggle", num_objs=3, starting_pos=[100, 200], verbose=False) - assert len(objs) == 3 - # Grid spacing adds x_space before first object - first_pos = objs[0]._dict["box"]["patching_rect"][:2] - assert first_pos[0] == 180.0 # 100 + 80 (default x spacing) - assert first_pos[1] == 200.0 - - def test_place_stub_object(self): - """place() with stub object works.""" - from maxpylang.objects import cycle_tilde - import maxpylang as mp - - patch = mp.MaxPatch(verbose=False) - osc = patch.place(cycle_tilde, verbose=False)[0] - assert osc.name == "cycle~" - - def test_place_spacing_grid(self): - """place() with spacing_type='grid'.""" - import maxpylang as mp - - patch = mp.MaxPatch(verbose=False) - objs = patch.place( - "toggle", num_objs=4, spacing_type="grid", - spacing=[100, 100], starting_pos=[0, 0], verbose=False - ) - assert len(objs) == 4 - - def test_place_spacing_vertical(self): - """place() with spacing_type='vertical' (spacing must be a scalar).""" - import maxpylang as mp - - patch = mp.MaxPatch(verbose=False) - objs = patch.place( - "toggle", num_objs=3, spacing_type="vertical", - spacing=80, starting_pos=[0, 0], verbose=False - ) - assert len(objs) == 3 - - -# ============================================================ -# TEST 6: Connections -# ============================================================ - -class TestConnections: - def test_single_connection(self): - """Single connection wires correctly.""" - import maxpylang as mp - - patch = mp.MaxPatch(verbose=False) - a = patch.place("toggle", verbose=False)[0] - b = patch.place("metro 500", verbose=False)[0] - patch.connect([a.outs[0], b.ins[0]], verbose=False) - assert len(a.outs[0].destinations) == 1 - assert a.outs[0].destinations[0].parent is b - - def test_multiple_connections(self): - """Multiple connections in one call.""" - import maxpylang as mp - - patch = mp.MaxPatch(verbose=False) - osc = patch.place("cycle~ 440", verbose=False)[0] - gain = patch.place("gain~", verbose=False)[0] - dac = patch.place("ezdac~", verbose=False)[0] - patch.connect( - [osc.outs[0], gain.ins[0]], - [gain.outs[0], dac.ins[0]], - [gain.outs[0], dac.ins[1]], - verbose=False, - ) - assert len(osc.outs[0].destinations) == 1 - assert len(gain.outs[0].destinations) == 2 - - -# ============================================================ -# TEST 7: Common objects — inlets/outlets -# ============================================================ - -class TestCommonObjects: - def test_cycle_tilde(self): - """cycle~ has inlets and outlets.""" - import maxpylang as mp - obj = mp.MaxObject("cycle~ 440") - assert obj.name == "cycle~" - assert len(obj.ins) > 0 - assert len(obj.outs) > 0 - - def test_ezdac_tilde(self): - """ezdac~ has >= 2 inlets.""" - import maxpylang as mp - obj = mp.MaxObject("ezdac~") - assert obj.name == "ezdac~" - assert len(obj.ins) >= 2 - - def test_metro(self): - """metro has inlets and outlets.""" - import maxpylang as mp - obj = mp.MaxObject("metro 500") - assert obj.name == "metro" - assert len(obj.ins) >= 1 - assert len(obj.outs) >= 1 - - def test_toggle(self): - """toggle has inlets and outlets.""" - import maxpylang as mp - obj = mp.MaxObject("toggle") - assert obj.name == "toggle" - assert len(obj.ins) >= 1 - assert len(obj.outs) >= 1 - - def test_pack(self): - """pack has inlets and outlets.""" - import maxpylang as mp - obj = mp.MaxObject("pack 0 0 0") - assert obj.name == "pack" - assert len(obj.ins) >= 1 - assert len(obj.outs) >= 1 - - def test_trigger(self): - """trigger b i f has 3 outlets.""" - import maxpylang as mp - obj = mp.MaxObject("trigger b i f") - assert obj.name == "trigger" - assert len(obj.ins) >= 1 - assert len(obj.outs) == 3 - - def test_metro_with_attribs(self): - """metro with @active attribute parses correctly.""" - import maxpylang as mp - obj = mp.MaxObject("metro 500 @active 1") - assert obj.name == "metro" - - -# ============================================================ -# TEST 8: Abstractions -# ============================================================ - -class TestAbstractions: - def test_abstraction_basic(self): - """MaxObject(abstraction=True, inlets=2, outlets=2) declares abstraction.""" - import maxpylang as mp - obj = mp.MaxObject("my_synth", abstraction=True, inlets=2, outlets=2) - assert obj.name == "my_synth" - assert len(obj.ins) == 2 - assert len(obj.outs) == 2 - assert obj.notknown() is False - - def test_abstraction_with_args(self): - """Abstraction with arguments.""" - import maxpylang as mp - obj = mp.MaxObject("my_synth 440 0.5", abstraction=True, inlets=2, outlets=2) - assert obj.name == "my_synth" - assert len(obj.ins) == 2 - assert len(obj.outs) == 2 - - def test_abstraction_place(self): - """Place abstraction in patch.""" - import maxpylang as mp - patch = mp.MaxPatch(verbose=False) - obj = mp.MaxObject("my_fx", abstraction=True, inlets=1, outlets=1) - placed = patch.place(obj, verbose=False)[0] - assert placed.name == "my_fx" - assert len(placed.ins) == 1 - assert len(placed.outs) == 1 - - def test_abstraction_default_io(self): - """Abstraction with default I/O (0, 0).""" - import maxpylang as mp - obj = mp.MaxObject("no_io_synth", abstraction=True) - assert obj.name == "no_io_synth" - assert len(obj.ins) == 0 - assert len(obj.outs) == 0 - - -# ============================================================ -# TEST 9: Edge case — save with device_type=None -# ============================================================ - -class TestDeviceTypeNone: - def test_save_device_type_none(self, tmp_path): - """save(device_type=None) produces .maxpat (not .amxd).""" - import maxpylang as mp - - out = str(tmp_path / "devnone.maxpat") - patch = mp.MaxPatch(verbose=False) - patch.place("toggle", verbose=False) - patch.save(out, device_type=None, verbose=False, check=False) - assert os.path.exists(out) - # Verify it's valid JSON (not binary amxd) - with open(out, "r") as f: - data = json.load(f) - assert "patcher" in data - - def test_save_no_ext_device_type_none(self, tmp_path): - """save('foo', device_type=None) auto-appends .maxpat.""" - import maxpylang as mp - os.chdir(tmp_path) - - patch = mp.MaxPatch(verbose=False) - patch.place("toggle", verbose=False) - patch.save("foo2", device_type=None, verbose=False, check=False) - assert os.path.exists(tmp_path / "foo2.maxpat") - with open(tmp_path / "foo2.maxpat", "r") as f: - data = json.load(f) - assert "patcher" in data - - def test_save_amxd_without_device_type_raises(self, tmp_path): - """save('test.amxd') without device_type raises ValueError.""" - import maxpylang as mp - - out = str(tmp_path / "test.amxd") - patch = mp.MaxPatch(verbose=False) - patch.place("toggle", verbose=False) - with pytest.raises(ValueError, match="device_type"): - patch.save(out, verbose=False, check=False) - - -# ============================================================ -# TEST 10: set_position -# ============================================================ - -class TestSetPosition: - def test_set_position(self): - """set_position works correctly.""" - import maxpylang as mp - patch = mp.MaxPatch(verbose=False) - patch.set_position(100, 200) - assert patch.curr_position == [100, 200] - - -# ============================================================ -# TEST 11: Patch properties -# ============================================================ - -class TestPatchProperties: - def test_objs_and_num_objs(self): - """patch.objs and patch.num_objs are correct.""" - import maxpylang as mp - patch = mp.MaxPatch(verbose=False) - assert isinstance(patch.objs, dict) - assert patch.num_objs == 0 - patch.place("toggle", verbose=False) - assert patch.num_objs == 1 - assert len(patch.objs) == 1 - - -# ============================================================ -# TEST 12: MaxObject methods -# ============================================================ - -class TestMaxObjectMethods: - def test_move(self): - """obj.move(x, y) repositions object.""" - import maxpylang as mp - obj = mp.MaxObject("toggle") - obj.move(150, 250) - rect = obj._dict["box"]["patching_rect"] - assert rect[0] == 150.0 - assert rect[1] == 250.0 - - def test_edit(self): - """obj.edit() changes object text.""" - import maxpylang as mp - obj = mp.MaxObject("metro 500") - obj.edit(text_add="replace", text="metro 1000") - assert obj.name == "metro" - - def test_notknown_for_known_object(self): - """obj.notknown() returns False for known objects.""" - import maxpylang as mp - obj = mp.MaxObject("toggle") - assert obj.notknown() is False - - -# ============================================================ -# TEST 13: Full workflow (docs examples) -# ============================================================ - -class TestFullWorkflow: - def test_audio_chain(self, tmp_path): - """Full audio chain from docs example.""" - from maxpylang.objects import gain_tilde, ezdac_tilde - import maxpylang as mp - - patch = mp.MaxPatch(verbose=False) - osc = patch.place("cycle~ 440", verbose=False)[0] - gain = patch.place(gain_tilde, verbose=False)[0] - dac = patch.place(ezdac_tilde, verbose=False)[0] - patch.connect( - [osc.outs[0], gain.ins[0]], - [gain.outs[0], dac.ins[0]], - [gain.outs[0], dac.ins[1]], - verbose=False, - ) - out = str(tmp_path / "audio_chain_test.maxpat") - patch.save(out, verbose=False, check=False) - assert os.path.exists(out) - with open(out) as f: - data = json.load(f) - assert len(data["patcher"]["boxes"]) == 3 - assert len(data["patcher"]["lines"]) == 3 - - def test_loop_pattern(self, tmp_path): - """Loop pattern from docs example.""" - import maxpylang as mp - - patch = mp.MaxPatch(verbose=False) - n = 5 - toggles = patch.place("toggle", num_objs=n, starting_pos=[0, 100], verbose=False) - gates = patch.place("gate", num_objs=n, starting_pos=[0, 200], verbose=False) - for t, g in zip(toggles, gates): - patch.connect([t.outs[0], g.ins[0]], verbose=False) - out = str(tmp_path / "loop_test.maxpat") - patch.save(out, verbose=False, check=False) - assert os.path.exists(out) - with open(out) as f: - data = json.load(f) - assert len(data["patcher"]["boxes"]) == 10 - assert len(data["patcher"]["lines"]) == 5 - - -# ============================================================ -# TEST 14: JSON output format correctness -# ============================================================ - -class TestJSONFormat: - def test_json_structure(self): - """get_json() returns proper .maxpat JSON structure.""" - import maxpylang as mp - - patch = mp.MaxPatch(verbose=False) - osc = patch.place("cycle~ 440", verbose=False)[0] - dac = patch.place("ezdac~", verbose=False)[0] - patch.connect([osc.outs[0], dac.ins[0]], verbose=False) - json_dict = patch.get_json() - assert "patcher" in json_dict - assert "boxes" in json_dict["patcher"] - assert "lines" in json_dict["patcher"] - # Check boxes have required fields - for box in json_dict["patcher"]["boxes"]: - assert "box" in box - assert "id" in box["box"] - assert "patching_rect" in box["box"] - # Check lines have required fields - for line in json_dict["patcher"]["lines"]: - assert "patchline" in line - assert "source" in line["patchline"] - assert "destination" in line["patchline"]