py2max 0.3.3__tar.gz → 0.3.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {py2max-0.3.3 → py2max-0.3.4}/CHANGELOG.md +17 -0
- {py2max-0.3.3 → py2max-0.3.4}/PKG-INFO +1 -1
- {py2max-0.3.3 → py2max-0.3.4}/py2max/__init__.py +5 -1
- {py2max-0.3.3 → py2max-0.3.4}/py2max/cli.py +13 -35
- {py2max-0.3.3 → py2max-0.3.4}/py2max/core/factory.py +6 -1
- {py2max-0.3.3 → py2max-0.3.4}/py2max/core/patcher.py +33 -1
- {py2max-0.3.3 → py2max-0.3.4}/py2max/core/serialization.py +27 -0
- {py2max-0.3.3 → py2max-0.3.4}/py2max/layout/base.py +122 -1
- py2max-0.3.4/py2max/lint.py +226 -0
- {py2max-0.3.3 → py2max-0.3.4}/py2max/maxref/data/bundle.json.gz +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/py2max/maxref/parser.py +49 -44
- py2max-0.3.4/py2max/maxref/porttypes.py +321 -0
- {py2max-0.3.3 → py2max-0.3.4}/pyproject.toml +1 -1
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_error_handling.py +5 -4
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_examples.py +60 -0
- py2max-0.3.4/tests/test_lint.py +180 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_maxref_bundle.py +17 -0
- py2max-0.3.4/tests/test_param_placement.py +148 -0
- py2max-0.3.4/tests/test_porttypes.py +85 -0
- py2max-0.3.4/tests/test_validation.py +67 -0
- {py2max-0.3.3 → py2max-0.3.4}/LICENSE +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/README.md +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/py2max/__main__.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/py2max/core/__init__.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/py2max/core/abstract.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/py2max/core/box.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/py2max/core/colors.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/py2max/core/common.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/py2max/core/patchline.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/py2max/exceptions.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/py2max/export/__init__.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/py2max/export/converters.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/py2max/export/svg.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/py2max/layout/__init__.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/py2max/layout/external.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/py2max/layout/flow.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/py2max/layout/graph.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/py2max/layout/grid.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/py2max/layout/matrix.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/py2max/log.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/py2max/m4l.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/py2max/maxref/__init__.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/py2max/maxref/category.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/py2max/maxref/db.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/py2max/maxref/legacy.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/py2max/py.typed +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/py2max/transformers.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/py2max/utils.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/__init__.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/conftest.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/data/complex.maxpat +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/data/desc.maxpat +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/data/empty.maxpat +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/data/mydevice.amxd +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/data/mydevice2.amxd +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/data/nested.maxpat +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/data/simple.maxpat +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/data/tabular.maxpat +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/data/umenu.maxref.xml +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/README.md +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/advanced/connection_patterns.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/advanced/custom_extensions.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/advanced/data_containers.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/advanced/error_handling.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/advanced/performance_optimization.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/advanced/subpatchers.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/api/box_api_examples.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/api/patcher_api_examples.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/auto_layout_demo.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/db/category_db_demo.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/db/maxref_db_demo.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/layout/columnar_layout_examples.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/layout/flow_layout_examples.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/layout/grid_layout_examples.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/layout/matrix_layout_examples.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/preview/basic_synth.maxpat +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/preview/basic_synth.svg +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/preview/complex_synth.maxpat +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/preview/complex_synth.svg +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/preview/flow_layout.maxpat +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/preview/flow_layout.svg +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/preview/grid_layout.maxpat +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/preview/grid_layout.svg +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/preview/horizontal_layout.maxpat +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/preview/horizontal_layout.svg +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/preview/styled_no_ports.svg +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/preview/styled_no_title.svg +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/preview/styled_patch.maxpat +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/preview/styled_with_ports.svg +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/preview/svg_preview_demo.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/preview/vertical_layout.maxpat +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/preview/vertical_layout.svg +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/preview/workflow_demo.maxpat +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/preview/workflow_demo.svg +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/quickstart/basic_patch.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/quickstart/layout_examples.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/tutorial/generative_music.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/tutorial/interactive_controller.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/tutorial/signal_processing_chain.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/examples/tutorial/simple_synthesis.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/graphs/random/v30e33.tglf +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_abstract_coverage.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_abstraction.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_add.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_amxd.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_attrui.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_basic.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_beap.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_bpatcher.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_cli.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_coll.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_colors.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_colors_theme.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_comment.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_connection_validation.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_converters.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_core_coverage.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_db.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_defaults.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_dict.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_edit_operations.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_encapsulate.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_ezdac.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_gen.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_group.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_itable.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_js.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_kwds_filter.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_layout.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_layout_box_dims.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_layout_builtins.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_layout_coverage.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_layout_flow.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_layout_graph.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_layout_graph_layout.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_layout_graph_manager.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_layout_matrix.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_layout_overlaps.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_layout_vertical.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_linking.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_m4l.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_maxref.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_maxref_lazy.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_maxref_parser.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_maxref_refpages.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_mc_cycle.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_mc_poly.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_message.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_mypatch.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_nested.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_nested_patchers.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_number_tilde.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_numbers.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_param.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_patcher.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_pitched_osc.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_presets.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_pydantic.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_rnbo.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_rnbo_subpatcher.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_scripting_name.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_search.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_semantic_ids.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_subpatch.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_svg.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_svg_fidelity.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_table.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_transformers.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_transformers_e4.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_tree.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_tree_builder.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_tutorial_simple_synthesis.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_two_sines.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_umenu.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_utils.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_validate_attrs.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_varname.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_wheel_bundle.py +0 -0
- {py2max-0.3.3 → py2max-0.3.4}/tests/test_zl_group.py +0 -0
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.3.4]
|
|
6
|
+
|
|
7
|
+
### New: Param docking in layouts
|
|
8
|
+
|
|
9
|
+
- `Patcher(..., param_placement=True)` docks value/UI "param" objects (`flonum`, `number`, `message`, `toggle`, `slider`, `dial`, `live.*`) next to the single object they drive, instead of spreading them through the signal graph. During `optimize_layout()`, a param whose outgoing connections all go to one non-param target is placed perpendicular to the signal flow -- above the target for a horizontal flow, to its left for a vertical flow (and to the right / below when the flow hugs that edge) -- ordered by, and for a lone param aligned to, the inlet it feeds, then de-overlapped. Works across every built-in layout (grid, flow, columnar, matrix). Off by default; a control that fans out to more than one object is left in the flow. Implemented as `LayoutManager.place_params()`.
|
|
10
|
+
|
|
11
|
+
### New: Patch linting and message-type-aware connection validation
|
|
12
|
+
|
|
13
|
+
- Added `Patcher.lint()` (and the `py2max.lint` module: `lint()`, `Finding`) -- a patch-level health check returning structured findings with a `severity`, a `code`, and object/connection references. It covers invalid connections, out-of-range outlet/inlet indices, orphaned patchlines, duplicate IDs, overlapping objects, off-canvas objects, and unknown object classes.
|
|
14
|
+
- **Linting runs automatically on `save()`**: error-severity findings (bad connections, out-of-range ports, orphaned lines, duplicate IDs) are logged. Pass `Patcher(strict=True)` to raise `InvalidPatchError` on any error instead. Layout warnings (overlaps / off-canvas / unknown objects) are left to an explicit `lint()` or `py2max validate` to keep normal saves quiet. This is on by default and non-breaking -- saves still succeed unless `strict=True`.
|
|
15
|
+
- Connection validation is now **message-type aware and bidirectional**. The previous check only rejected a signal outlet wired into a non-signal inlet; it now also catches a control outlet (a bang from `metro` / `loadbang` / `button`) wired into an oscillator's signal inlet -- e.g. `metro -> cycle~`, which Max rejects. The rules are deliberately conservative (ambiguous cases and maxref-unknown objects are allowed) so on-by-default checking never rejects a valid patch; notably a bang into `adsr~` (a legitimate envelope trigger) is *not* flagged.
|
|
16
|
+
- Port typing is now modeled in `py2max/maxref/porttypes.py`, normalizing maxref's placeholder control types (`OUTLET_TYPE` / `INLET_TYPE`) into message kinds, and resolving **argument-dependent port counts** that maxref reports as the arg-less default -- both value-scaled (`limi~ 2` -> 2 in/out) and arg-count-scaled (`select a b c` -> 4 outlets, `route`, `pack`/`unpack`, `selector~`/`switch`) -- plus curated overrides for the handful of objects maxref mis-types.
|
|
17
|
+
- `py2max validate` (CLI) now reports the full lint result -- errors and warnings with codes -- and exits non-zero on any error.
|
|
18
|
+
- A corpus test re-lints every shipped layout example patch and fails on any error, so Max-invalid wiring can no longer ship unnoticed.
|
|
19
|
+
- Subpatchers are handled: a subpatcher/bpatcher box's inlet/outlet count is derived from the `inlet` / `outlet` objects it contains (not the maxref default), and `lint()` recurses into nested patchers -- findings inside a subpatcher are reported path-qualified (e.g. `sub-box-id/obj-1`).
|
|
20
|
+
- Inlet acceptance is now derived from each object's `<methodlist>` -- its real message vocabulary in Max's own docs -- rather than the placeholder inlet `type` (Cycling '74 ships `INLET_TYPE`/`OUTLET_TYPE` for control ports, so the type attribute alone is useless). This is what distinguishes a bang into `cycle~` (no `bang` method -> rejected) from a bang into `adsr~` (has an `anything` wildcard method -> allowed), replacing hand-curation with data that generalizes to all ~1050 objects that carry method lists. The shipped `bundle.json.gz` was regenerated so no-Max users get the same data (a `test_bundle_method_data_quality` guard prevents a future regeneration from dropping it).
|
|
21
|
+
|
|
5
22
|
## [0.3.3]
|
|
6
23
|
|
|
7
24
|
### Removed: `serve` and `repl` CLI subcommands
|
|
@@ -35,7 +35,7 @@ Example:
|
|
|
35
35
|
>>> p.save()
|
|
36
36
|
"""
|
|
37
37
|
|
|
38
|
-
__version__ = "0.3.
|
|
38
|
+
__version__ = "0.3.4"
|
|
39
39
|
|
|
40
40
|
from .core import Box, Patcher, Patchline
|
|
41
41
|
from .exceptions import (
|
|
@@ -48,6 +48,7 @@ from .exceptions import (
|
|
|
48
48
|
PatcherIOError,
|
|
49
49
|
Py2MaxError,
|
|
50
50
|
)
|
|
51
|
+
from .lint import Finding, lint
|
|
51
52
|
|
|
52
53
|
__all__ = [
|
|
53
54
|
# Version
|
|
@@ -56,6 +57,9 @@ __all__ = [
|
|
|
56
57
|
"Patcher",
|
|
57
58
|
"Box",
|
|
58
59
|
"Patchline",
|
|
60
|
+
# Linting
|
|
61
|
+
"lint",
|
|
62
|
+
"Finding",
|
|
59
63
|
# Exceptions
|
|
60
64
|
"Py2MaxError",
|
|
61
65
|
"InvalidConnectionError",
|
|
@@ -15,12 +15,12 @@ try: # pragma: no cover - optional dependency
|
|
|
15
15
|
except Exception: # pragma: no cover - optional dependency
|
|
16
16
|
yaml = None
|
|
17
17
|
|
|
18
|
-
from .core import Patcher
|
|
18
|
+
from .core import Patcher
|
|
19
19
|
from .core.common import Rect
|
|
20
20
|
from .exceptions import Py2MaxError
|
|
21
21
|
from .export import export_svg
|
|
22
22
|
from .export.converters import maxpat_to_python, maxref_to_sqlite
|
|
23
|
-
from .maxref import MaxRefCache, MaxRefDB
|
|
23
|
+
from .maxref import MaxRefCache, MaxRefDB
|
|
24
24
|
from .transformers import available_transformers, create_transformer, run_pipeline
|
|
25
25
|
|
|
26
26
|
LAYOUT_CHOICES = ["horizontal", "vertical", "grid", "flow", "matrix"]
|
|
@@ -230,40 +230,18 @@ def cmd_validate(args: argparse.Namespace) -> int:
|
|
|
230
230
|
patcher = Patcher.from_file(path)
|
|
231
231
|
_coerce_rect(patcher)
|
|
232
232
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
src_id, dst_id = patchline.src, patchline.dst
|
|
238
|
-
src_port = int(patchline.source[1]) if len(patchline.source) > 1 else 0
|
|
239
|
-
dst_port = (
|
|
240
|
-
int(patchline.destination[1]) if len(patchline.destination) > 1 else 0
|
|
241
|
-
)
|
|
242
|
-
|
|
243
|
-
src_obj = patcher._objects.get(src_id)
|
|
244
|
-
dst_obj = patcher._objects.get(dst_id)
|
|
245
|
-
|
|
246
|
-
if not src_obj or not dst_obj:
|
|
247
|
-
errors.append(f"Dangling connection: {src_id} -> {dst_id}")
|
|
248
|
-
continue
|
|
249
|
-
|
|
250
|
-
src_name = _object_name(src_obj)
|
|
251
|
-
dst_name = _object_name(dst_obj)
|
|
252
|
-
|
|
253
|
-
is_valid, message = validate_connection(src_name, src_port, dst_name, dst_port)
|
|
254
|
-
if not is_valid:
|
|
255
|
-
errors.append(
|
|
256
|
-
f"Invalid connection {src_name}[{src_port}] -> {dst_name}[{dst_port}]: {message}"
|
|
257
|
-
)
|
|
258
|
-
|
|
259
|
-
if errors:
|
|
260
|
-
print("Connection validation failed:", file=sys.stderr)
|
|
261
|
-
for error in errors:
|
|
262
|
-
print(f" - {error}", file=sys.stderr)
|
|
263
|
-
return 1
|
|
233
|
+
findings = patcher.lint()
|
|
234
|
+
if not findings:
|
|
235
|
+
print("Patch is clean: no lint findings.")
|
|
236
|
+
return 0
|
|
264
237
|
|
|
265
|
-
|
|
266
|
-
|
|
238
|
+
errors = [f for f in findings if f.severity == "error"]
|
|
239
|
+
warnings = [f for f in findings if f.severity == "warning"]
|
|
240
|
+
stream = sys.stderr if errors else sys.stdout
|
|
241
|
+
for finding in findings:
|
|
242
|
+
print(f" {finding}", file=stream)
|
|
243
|
+
print(f"{len(errors)} error(s), {len(warnings)} warning(s).", file=stream)
|
|
244
|
+
return 1 if errors else 0
|
|
267
245
|
|
|
268
246
|
|
|
269
247
|
def _parse_transform_spec(spec: str) -> tuple[str, str | None]:
|
|
@@ -310,7 +310,12 @@ class BoxFactoryMixin(AbstractPatcher):
|
|
|
310
310
|
is_valid = not error_msg
|
|
311
311
|
else:
|
|
312
312
|
is_valid, error_msg = maxref.validate_connection(
|
|
313
|
-
src_name,
|
|
313
|
+
src_name,
|
|
314
|
+
src_outlet,
|
|
315
|
+
dst_name,
|
|
316
|
+
dst_inlet,
|
|
317
|
+
src_text=getattr(src_obj, "text", None),
|
|
318
|
+
dst_text=getattr(dst_obj, "text", None),
|
|
314
319
|
)
|
|
315
320
|
|
|
316
321
|
if not is_valid:
|
|
@@ -16,7 +16,20 @@ Example:
|
|
|
16
16
|
import json
|
|
17
17
|
import re
|
|
18
18
|
from pathlib import Path
|
|
19
|
-
from typing import
|
|
19
|
+
from typing import (
|
|
20
|
+
TYPE_CHECKING,
|
|
21
|
+
Any,
|
|
22
|
+
Dict,
|
|
23
|
+
Iterator,
|
|
24
|
+
List,
|
|
25
|
+
Optional,
|
|
26
|
+
Tuple,
|
|
27
|
+
Union,
|
|
28
|
+
cast,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from ..lint import Finding
|
|
20
33
|
|
|
21
34
|
from py2max import layout as layout_module
|
|
22
35
|
from py2max.log import get_logger
|
|
@@ -109,6 +122,7 @@ class Patcher(BoxFactoryMixin, SerializationMixin, AbstractPatcher):
|
|
|
109
122
|
openinpresentation: int = 0,
|
|
110
123
|
validate_connections: bool = False,
|
|
111
124
|
validate_attrs: bool = False,
|
|
125
|
+
strict: bool = False,
|
|
112
126
|
flow_direction: str = "horizontal",
|
|
113
127
|
cluster_connected: bool = False,
|
|
114
128
|
# Matrix layout configuration parameters
|
|
@@ -116,6 +130,7 @@ class Patcher(BoxFactoryMixin, SerializationMixin, AbstractPatcher):
|
|
|
116
130
|
dimension_spacing: float = 100.0,
|
|
117
131
|
semantic_ids: bool = False,
|
|
118
132
|
device_type: str = "audio_effect",
|
|
133
|
+
param_placement: bool = False,
|
|
119
134
|
):
|
|
120
135
|
logger.debug(
|
|
121
136
|
f"Initializing Patcher: path={path}, layout={layout}, "
|
|
@@ -143,6 +158,8 @@ class Patcher(BoxFactoryMixin, SerializationMixin, AbstractPatcher):
|
|
|
143
158
|
self._auto_hints = auto_hints
|
|
144
159
|
self._validate_connections = validate_connections
|
|
145
160
|
self._validate_attrs = validate_attrs
|
|
161
|
+
self._strict = strict
|
|
162
|
+
self._param_placement = param_placement
|
|
146
163
|
self._pending_comments: list[
|
|
147
164
|
tuple[str, str, Optional[str]]
|
|
148
165
|
] = [] # [(box_id, comment_text, comment_pos), ...]
|
|
@@ -656,5 +673,20 @@ class Patcher(BoxFactoryMixin, SerializationMixin, AbstractPatcher):
|
|
|
656
673
|
if hasattr(self._layout_mgr, "optimize_layout"):
|
|
657
674
|
self._layout_mgr.optimize_layout()
|
|
658
675
|
|
|
676
|
+
# Dock value/UI params next to the object they drive (opt-in).
|
|
677
|
+
if self._param_placement and hasattr(self._layout_mgr, "place_params"):
|
|
678
|
+
self._layout_mgr.place_params()
|
|
679
|
+
|
|
659
680
|
# Process pending comments after layout optimization
|
|
660
681
|
self._process_pending_comments()
|
|
682
|
+
|
|
683
|
+
def lint(self) -> "List[Finding]":
|
|
684
|
+
"""Return patch-level lint findings (errors and warnings), errors first.
|
|
685
|
+
|
|
686
|
+
Checks connection validity, out-of-range ports, orphaned patchlines,
|
|
687
|
+
duplicate IDs, overlapping objects, off-canvas objects, and unknown
|
|
688
|
+
object classes. See :mod:`py2max.lint`.
|
|
689
|
+
"""
|
|
690
|
+
from ..lint import lint as _lint
|
|
691
|
+
|
|
692
|
+
return _lint(self)
|
|
@@ -76,6 +76,8 @@ class SerializationMixin(AbstractPatcher):
|
|
|
76
76
|
):
|
|
77
77
|
self.render()
|
|
78
78
|
|
|
79
|
+
self._lint_on_save()
|
|
80
|
+
|
|
79
81
|
# Use resolved path for writing
|
|
80
82
|
if resolved_path.suffix.lower() == ".amxd":
|
|
81
83
|
# Lazy import: m4l is a feature layer, kept off core's import path.
|
|
@@ -107,6 +109,31 @@ class SerializationMixin(AbstractPatcher):
|
|
|
107
109
|
operation="write",
|
|
108
110
|
) from e
|
|
109
111
|
|
|
112
|
+
def _lint_on_save(self) -> None:
|
|
113
|
+
"""Lint the top-level patch on save.
|
|
114
|
+
|
|
115
|
+
Error-severity findings (bad connections, out-of-range ports, orphaned
|
|
116
|
+
lines, duplicate IDs) are logged. When the patcher was created with
|
|
117
|
+
``strict=True`` they are raised as ``InvalidPatchError`` instead. Layout
|
|
118
|
+
warnings (overlaps / off-canvas / unknown objects) are left to an
|
|
119
|
+
explicit ``lint()`` call or ``py2max validate`` to keep saves quiet.
|
|
120
|
+
"""
|
|
121
|
+
if self._parent: # only lint the top-level patcher
|
|
122
|
+
return
|
|
123
|
+
from ..lint import lint as _lint_patch
|
|
124
|
+
|
|
125
|
+
errors = [f for f in _lint_patch(self) if f.severity == "error"]
|
|
126
|
+
if not errors:
|
|
127
|
+
return
|
|
128
|
+
for finding in errors:
|
|
129
|
+
logger.warning("lint: %s", finding)
|
|
130
|
+
if getattr(self, "_strict", False):
|
|
131
|
+
from ..exceptions import InvalidPatchError
|
|
132
|
+
|
|
133
|
+
raise InvalidPatchError(
|
|
134
|
+
f"patch has {len(errors)} validation error(s); first: {errors[0]}"
|
|
135
|
+
)
|
|
136
|
+
|
|
110
137
|
def save(self) -> None:
|
|
111
138
|
"""Save the patch to the default file path.
|
|
112
139
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
This module provides the base LayoutManager class that all layout managers inherit from.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
-
from typing import Dict, Optional, Set, cast
|
|
6
|
+
from typing import Any, Dict, List, Optional, Set, Tuple, cast
|
|
7
7
|
|
|
8
8
|
from py2max.core.abstract import AbstractLayoutManager, AbstractPatcher
|
|
9
9
|
from py2max.core.common import Rect
|
|
@@ -14,6 +14,18 @@ from .graph import PatchGraph
|
|
|
14
14
|
# Threshold for incremental vs full layout (30% of total objects)
|
|
15
15
|
INCREMENTAL_THRESHOLD = 0.3
|
|
16
16
|
|
|
17
|
+
# Value/UI "param" objects: their role is to set a value on one object's inlet,
|
|
18
|
+
# so they read better docked next to that object than spread through the graph.
|
|
19
|
+
PARAM_MAXCLASSES = frozenset(
|
|
20
|
+
{"flonum", "number", "message", "toggle", "slider", "dial"}
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _is_param(box: object) -> bool:
|
|
25
|
+
"""True for a value/UI control that parameterizes another object."""
|
|
26
|
+
mc = getattr(box, "maxclass", "") or ""
|
|
27
|
+
return mc in PARAM_MAXCLASSES or mc.startswith("live.")
|
|
28
|
+
|
|
17
29
|
|
|
18
30
|
class LayoutManager(AbstractLayoutManager):
|
|
19
31
|
"""Basic horizontal layout manager.
|
|
@@ -237,6 +249,115 @@ class LayoutManager(AbstractLayoutManager):
|
|
|
237
249
|
|
|
238
250
|
return iterations_performed
|
|
239
251
|
|
|
252
|
+
def place_params(self, direction: Optional[str] = None, gap: float = 8.0) -> None:
|
|
253
|
+
"""Dock value/UI 'param' objects next to the single object they drive.
|
|
254
|
+
|
|
255
|
+
A param (number box, toggle, slider, dial, message, ``live.*``) whose
|
|
256
|
+
outgoing connections all go to one non-param target is repositioned
|
|
257
|
+
adjacent to that target, perpendicular to the signal flow -- above the
|
|
258
|
+
target for a horizontal flow, to its left for a vertical flow -- and
|
|
259
|
+
aligned to the inlet it feeds. This keeps value controls with their
|
|
260
|
+
object instead of spread through the signal graph.
|
|
261
|
+
|
|
262
|
+
Runs after the main layout as the final placement pass. ``direction``
|
|
263
|
+
defaults to the manager's ``flow_direction``.
|
|
264
|
+
"""
|
|
265
|
+
objects = self.parent._objects
|
|
266
|
+
|
|
267
|
+
# outgoing edges per source: source_id -> [(inlet, target_id), ...]
|
|
268
|
+
out_edges: Dict[str, List[Tuple[int, str]]] = {}
|
|
269
|
+
for line in self.parent._lines:
|
|
270
|
+
source = getattr(line, "source", None)
|
|
271
|
+
destination = getattr(line, "destination", None)
|
|
272
|
+
if not source or not destination:
|
|
273
|
+
continue
|
|
274
|
+
inlet = int(destination[1]) if len(destination) > 1 else 0
|
|
275
|
+
out_edges.setdefault(source[0], []).append((inlet, destination[0]))
|
|
276
|
+
|
|
277
|
+
# a param drives exactly one non-param target -> group by that target
|
|
278
|
+
by_target: Dict[str, List[Tuple[int, Any]]] = {}
|
|
279
|
+
for src_id, edges in out_edges.items():
|
|
280
|
+
box = objects.get(src_id)
|
|
281
|
+
if box is None or not _is_param(box):
|
|
282
|
+
continue
|
|
283
|
+
targets = {t for _, t in edges}
|
|
284
|
+
if len(targets) != 1:
|
|
285
|
+
continue
|
|
286
|
+
target_id = next(iter(targets))
|
|
287
|
+
target = objects.get(target_id)
|
|
288
|
+
if target is None or _is_param(target):
|
|
289
|
+
continue
|
|
290
|
+
inlet = min(i for i, _ in edges)
|
|
291
|
+
by_target.setdefault(target_id, []).append((inlet, box))
|
|
292
|
+
|
|
293
|
+
if not by_target:
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
direction = direction or getattr(self, "flow_direction", "horizontal")
|
|
297
|
+
left_side = direction in ("vertical", "column")
|
|
298
|
+
for target_id, params in by_target.items():
|
|
299
|
+
params.sort(key=lambda p: p[0])
|
|
300
|
+
self._dock_params(objects[target_id], params, left_side, gap)
|
|
301
|
+
|
|
302
|
+
self.prevent_overlaps()
|
|
303
|
+
|
|
304
|
+
def _dock_params(
|
|
305
|
+
self,
|
|
306
|
+
target: Any,
|
|
307
|
+
params: List[Tuple[int, Any]],
|
|
308
|
+
left_side: bool,
|
|
309
|
+
gap: float,
|
|
310
|
+
) -> None:
|
|
311
|
+
"""Position a target's param satellites (params sorted by inlet).
|
|
312
|
+
|
|
313
|
+
Docks on the perpendicular side that has room: left of the target when a
|
|
314
|
+
vertical flow leaves space, otherwise right; above for a horizontal flow,
|
|
315
|
+
otherwise below. This keeps params clear of a flow that hugs an edge.
|
|
316
|
+
"""
|
|
317
|
+
tr = target.patching_rect
|
|
318
|
+
tx, ty, tw, th = float(tr[0]), float(tr[1]), float(tr[2]), float(tr[3])
|
|
319
|
+
n_inlets = int(getattr(target, "numinlets", 1) or 1)
|
|
320
|
+
|
|
321
|
+
if left_side:
|
|
322
|
+
max_w = max(float(b.patching_rect[2]) for _, b in params)
|
|
323
|
+
x = tx - max_w - gap
|
|
324
|
+
if x < self.pad: # no room on the left -> dock on the right
|
|
325
|
+
x = tx + tw + gap
|
|
326
|
+
y = ty
|
|
327
|
+
for _, box in params:
|
|
328
|
+
bw, bh = float(box.patching_rect[2]), float(box.patching_rect[3])
|
|
329
|
+
box.patching_rect = Rect(x, y, bw, bh)
|
|
330
|
+
y += bh + gap
|
|
331
|
+
return
|
|
332
|
+
|
|
333
|
+
max_h = max(float(b.patching_rect[3]) for _, b in params)
|
|
334
|
+
y = ty - max_h - gap
|
|
335
|
+
if y < self.pad: # no room above -> dock below
|
|
336
|
+
y = ty + th + gap
|
|
337
|
+
if len(params) == 1:
|
|
338
|
+
inlet, box = params[0]
|
|
339
|
+
bw = float(box.patching_rect[2])
|
|
340
|
+
cx = self._inlet_center_x(tx, tw, n_inlets, inlet)
|
|
341
|
+
box.patching_rect = Rect(
|
|
342
|
+
max(self.pad, cx - bw / 2), y, bw, box.patching_rect[3]
|
|
343
|
+
)
|
|
344
|
+
else:
|
|
345
|
+
# pack params as a centered row, in inlet order
|
|
346
|
+
widths = [float(b.patching_rect[2]) for _, b in params]
|
|
347
|
+
row_w = sum(widths) + gap * (len(params) - 1)
|
|
348
|
+
x = max(self.pad, tx + tw / 2 - row_w / 2)
|
|
349
|
+
for (_, box), bw in zip(params, widths):
|
|
350
|
+
box.patching_rect = Rect(x, y, bw, float(box.patching_rect[3]))
|
|
351
|
+
x += bw + gap
|
|
352
|
+
|
|
353
|
+
@staticmethod
|
|
354
|
+
def _inlet_center_x(tx: float, tw: float, n_inlets: int, inlet: int) -> float:
|
|
355
|
+
"""Approximate x of the center of ``inlet`` on a box (Max spreads inlets
|
|
356
|
+
edge-to-edge across the top)."""
|
|
357
|
+
if n_inlets <= 1:
|
|
358
|
+
return tx + tw / 2
|
|
359
|
+
return tx + tw * min(inlet, n_inlets - 1) / (n_inlets - 1)
|
|
360
|
+
|
|
240
361
|
def get_connected_objects(self, obj_ids: Set[str]) -> Set[str]:
|
|
241
362
|
"""Get all objects connected to the given objects via patchlines.
|
|
242
363
|
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Patch-level linting: structural and connection health checks.
|
|
2
|
+
|
|
3
|
+
``lint(patcher)`` returns a list of :class:`Finding`s -- errors and warnings --
|
|
4
|
+
covering connection validity, out-of-range ports, orphaned patchlines,
|
|
5
|
+
duplicate IDs, overlapping objects, off-canvas objects, and unknown object
|
|
6
|
+
classes. It is the productized form of the ad-hoc checks that repeatedly caught
|
|
7
|
+
real bugs, and the basis for on-by-default checking on ``save()``.
|
|
8
|
+
|
|
9
|
+
Only the top-level patcher is linted; nested subpatchers have their own
|
|
10
|
+
coordinate space and are left to a future recursive pass.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any, List, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
from .maxref import get_object_info
|
|
19
|
+
from .maxref import porttypes
|
|
20
|
+
from .utils import object_name
|
|
21
|
+
|
|
22
|
+
# --- severities ---
|
|
23
|
+
ERROR = "error"
|
|
24
|
+
WARNING = "warning"
|
|
25
|
+
|
|
26
|
+
# --- finding codes ---
|
|
27
|
+
E_DUP_ID = "E-DUP-ID"
|
|
28
|
+
E_ORPHAN_LINE = "E-ORPHAN-LINE"
|
|
29
|
+
E_OUTLET_RANGE = "E-OUTLET-RANGE"
|
|
30
|
+
E_INLET_RANGE = "E-INLET-RANGE"
|
|
31
|
+
E_BAD_CONNECTION = "E-BAD-CONNECTION"
|
|
32
|
+
W_OVERLAP = "W-OVERLAP"
|
|
33
|
+
W_OFFCANVAS = "W-OFFCANVAS"
|
|
34
|
+
W_UNKNOWN_OBJECT = "W-UNKNOWN-OBJECT"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Finding:
|
|
39
|
+
"""A single lint result."""
|
|
40
|
+
|
|
41
|
+
code: str
|
|
42
|
+
severity: str
|
|
43
|
+
message: str
|
|
44
|
+
obj_id: Optional[str] = None
|
|
45
|
+
#: (src_id, outlet, dst_id, inlet) for connection findings
|
|
46
|
+
line: Optional[Tuple[str, int, str, int]] = None
|
|
47
|
+
|
|
48
|
+
def __str__(self) -> str:
|
|
49
|
+
where = ""
|
|
50
|
+
if self.line is not None:
|
|
51
|
+
s, so, d, di = self.line
|
|
52
|
+
where = f" [{s}:{so} -> {d}:{di}]"
|
|
53
|
+
elif self.obj_id is not None:
|
|
54
|
+
where = f" [{self.obj_id}]"
|
|
55
|
+
return f"{self.severity.upper()} {self.code}: {self.message}{where}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _overlap(a: Any, b: Any) -> bool:
|
|
59
|
+
return bool(
|
|
60
|
+
a[0] < b[0] + b[2]
|
|
61
|
+
and b[0] < a[0] + a[2]
|
|
62
|
+
and a[1] < b[1] + b[3]
|
|
63
|
+
and b[1] < a[1] + a[3]
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _port(pair: Any, idx: int) -> int:
|
|
68
|
+
"""Second element of a source/destination pair, defaulting to 0."""
|
|
69
|
+
return int(pair[idx]) if len(pair) > idx else 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _effective_counts(box: Any, name: str) -> Tuple[Optional[int], Optional[int]]:
|
|
73
|
+
"""(inlet_count, outlet_count) for a box, subpatcher-aware."""
|
|
74
|
+
sub_in, sub_out = porttypes.subpatcher_counts(box)
|
|
75
|
+
n_in, n_out = porttypes.port_counts(name, getattr(box, "text", None))
|
|
76
|
+
return (
|
|
77
|
+
sub_in if sub_in is not None else n_in,
|
|
78
|
+
sub_out if sub_out is not None else n_out,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def lint(patcher: Any) -> List[Finding]:
|
|
83
|
+
"""Return all lint findings for ``patcher`` and its subpatchers, errors first."""
|
|
84
|
+
findings: List[Finding] = []
|
|
85
|
+
_lint_level(patcher, findings, path="")
|
|
86
|
+
findings.sort(key=lambda f: 0 if f.severity == ERROR else 1)
|
|
87
|
+
return findings
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _lint_level(patcher: Any, findings: List[Finding], path: str) -> None:
|
|
91
|
+
"""Lint one patcher level; recurse into subpatchers.
|
|
92
|
+
|
|
93
|
+
``path`` prefixes object ids so a finding inside ``p sub`` is reported as
|
|
94
|
+
``sub-box-id/obj-1`` rather than an ambiguous ``obj-1``.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def qid(box_id: str) -> str:
|
|
98
|
+
return f"{path}{box_id}"
|
|
99
|
+
|
|
100
|
+
boxes = list(patcher._boxes)
|
|
101
|
+
by_id: dict[str, Any] = {}
|
|
102
|
+
|
|
103
|
+
# duplicate IDs
|
|
104
|
+
for b in boxes:
|
|
105
|
+
if b.id in by_id:
|
|
106
|
+
findings.append(
|
|
107
|
+
Finding(
|
|
108
|
+
E_DUP_ID, ERROR, f"duplicate object id {b.id!r}", obj_id=qid(b.id)
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
else:
|
|
112
|
+
by_id[b.id] = b
|
|
113
|
+
|
|
114
|
+
# unknown object classes
|
|
115
|
+
for b in boxes:
|
|
116
|
+
name = object_name(b)
|
|
117
|
+
if name and get_object_info(name) is None:
|
|
118
|
+
findings.append(
|
|
119
|
+
Finding(
|
|
120
|
+
W_UNKNOWN_OBJECT,
|
|
121
|
+
WARNING,
|
|
122
|
+
f"object {name!r} is not in the Max reference",
|
|
123
|
+
obj_id=qid(b.id),
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# off-canvas: object extends outside the patcher window
|
|
128
|
+
rect = patcher.rect
|
|
129
|
+
win_w, win_h = float(rect[2]), float(rect[3])
|
|
130
|
+
for b in boxes:
|
|
131
|
+
r = b.patching_rect
|
|
132
|
+
x, y, w, h = float(r[0]), float(r[1]), float(r[2]), float(r[3])
|
|
133
|
+
if x < 0 or y < 0 or x + w > win_w or y + h > win_h:
|
|
134
|
+
findings.append(
|
|
135
|
+
Finding(
|
|
136
|
+
W_OFFCANVAS,
|
|
137
|
+
WARNING,
|
|
138
|
+
f"object {b.id!r} extends outside the patcher window "
|
|
139
|
+
f"({win_w:.0f}x{win_h:.0f})",
|
|
140
|
+
obj_id=qid(b.id),
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# overlaps (dimension-aware)
|
|
145
|
+
for i in range(len(boxes)):
|
|
146
|
+
for j in range(i + 1, len(boxes)):
|
|
147
|
+
if _overlap(boxes[i].patching_rect, boxes[j].patching_rect):
|
|
148
|
+
findings.append(
|
|
149
|
+
Finding(
|
|
150
|
+
W_OVERLAP,
|
|
151
|
+
WARNING,
|
|
152
|
+
f"objects {boxes[i].id!r} and {boxes[j].id!r} overlap",
|
|
153
|
+
obj_id=qid(boxes[i].id),
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# patchlines: orphans, out-of-range ports, message-type compatibility
|
|
158
|
+
for pl in patcher._lines:
|
|
159
|
+
src_id, outlet = pl.source[0], _port(pl.source, 1)
|
|
160
|
+
dst_id, inlet = pl.destination[0], _port(pl.destination, 1)
|
|
161
|
+
line = (qid(src_id), outlet, qid(dst_id), inlet)
|
|
162
|
+
sb, db = by_id.get(src_id), by_id.get(dst_id)
|
|
163
|
+
if sb is None or db is None:
|
|
164
|
+
findings.append(
|
|
165
|
+
Finding(
|
|
166
|
+
E_ORPHAN_LINE,
|
|
167
|
+
ERROR,
|
|
168
|
+
"patchline references a missing object",
|
|
169
|
+
line=line,
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
continue
|
|
173
|
+
|
|
174
|
+
src_name, dst_name = object_name(sb), object_name(db)
|
|
175
|
+
_, n_out = _effective_counts(sb, src_name)
|
|
176
|
+
n_in, _ = _effective_counts(db, dst_name)
|
|
177
|
+
if n_out is not None and outlet >= n_out:
|
|
178
|
+
findings.append(
|
|
179
|
+
Finding(
|
|
180
|
+
E_OUTLET_RANGE,
|
|
181
|
+
ERROR,
|
|
182
|
+
f"{src_name!r} has {n_out} outlet(s); outlet {outlet} is out of range",
|
|
183
|
+
line=line,
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
continue
|
|
187
|
+
if n_in is not None and inlet >= n_in:
|
|
188
|
+
findings.append(
|
|
189
|
+
Finding(
|
|
190
|
+
E_INLET_RANGE,
|
|
191
|
+
ERROR,
|
|
192
|
+
f"{dst_name!r} has {n_in} inlet(s); inlet {inlet} is out of range",
|
|
193
|
+
line=line,
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
continue
|
|
197
|
+
|
|
198
|
+
emit = porttypes.outlet_emits(src_name, outlet)
|
|
199
|
+
accepts, authoritative = porttypes.inlet_acceptance(dst_name, inlet)
|
|
200
|
+
bang_reject = emit == porttypes.BANG and porttypes.inlet_rejects_bang(
|
|
201
|
+
dst_name, inlet
|
|
202
|
+
)
|
|
203
|
+
if (
|
|
204
|
+
bang_reject
|
|
205
|
+
or porttypes.message_compatible(emit, accepts, authoritative) is False
|
|
206
|
+
):
|
|
207
|
+
findings.append(
|
|
208
|
+
Finding(
|
|
209
|
+
E_BAD_CONNECTION,
|
|
210
|
+
ERROR,
|
|
211
|
+
f"cannot connect {emit} outlet {outlet} of {src_name!r} "
|
|
212
|
+
f"to inlet {inlet} of {dst_name!r}",
|
|
213
|
+
line=line,
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# recurse into subpatchers (each has its own coordinate space)
|
|
218
|
+
for b in boxes:
|
|
219
|
+
child = getattr(b, "_patcher", None)
|
|
220
|
+
if child is not None:
|
|
221
|
+
_lint_level(child, findings, path=f"{path}{b.id}/")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def has_errors(findings: List[Finding]) -> bool:
|
|
225
|
+
"""True if any finding is error-severity."""
|
|
226
|
+
return any(f.severity == ERROR for f in findings)
|
|
Binary file
|