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.
Files changed (179) hide show
  1. {py2max-0.3.3 → py2max-0.3.4}/CHANGELOG.md +17 -0
  2. {py2max-0.3.3 → py2max-0.3.4}/PKG-INFO +1 -1
  3. {py2max-0.3.3 → py2max-0.3.4}/py2max/__init__.py +5 -1
  4. {py2max-0.3.3 → py2max-0.3.4}/py2max/cli.py +13 -35
  5. {py2max-0.3.3 → py2max-0.3.4}/py2max/core/factory.py +6 -1
  6. {py2max-0.3.3 → py2max-0.3.4}/py2max/core/patcher.py +33 -1
  7. {py2max-0.3.3 → py2max-0.3.4}/py2max/core/serialization.py +27 -0
  8. {py2max-0.3.3 → py2max-0.3.4}/py2max/layout/base.py +122 -1
  9. py2max-0.3.4/py2max/lint.py +226 -0
  10. {py2max-0.3.3 → py2max-0.3.4}/py2max/maxref/data/bundle.json.gz +0 -0
  11. {py2max-0.3.3 → py2max-0.3.4}/py2max/maxref/parser.py +49 -44
  12. py2max-0.3.4/py2max/maxref/porttypes.py +321 -0
  13. {py2max-0.3.3 → py2max-0.3.4}/pyproject.toml +1 -1
  14. {py2max-0.3.3 → py2max-0.3.4}/tests/test_error_handling.py +5 -4
  15. {py2max-0.3.3 → py2max-0.3.4}/tests/test_examples.py +60 -0
  16. py2max-0.3.4/tests/test_lint.py +180 -0
  17. {py2max-0.3.3 → py2max-0.3.4}/tests/test_maxref_bundle.py +17 -0
  18. py2max-0.3.4/tests/test_param_placement.py +148 -0
  19. py2max-0.3.4/tests/test_porttypes.py +85 -0
  20. py2max-0.3.4/tests/test_validation.py +67 -0
  21. {py2max-0.3.3 → py2max-0.3.4}/LICENSE +0 -0
  22. {py2max-0.3.3 → py2max-0.3.4}/README.md +0 -0
  23. {py2max-0.3.3 → py2max-0.3.4}/py2max/__main__.py +0 -0
  24. {py2max-0.3.3 → py2max-0.3.4}/py2max/core/__init__.py +0 -0
  25. {py2max-0.3.3 → py2max-0.3.4}/py2max/core/abstract.py +0 -0
  26. {py2max-0.3.3 → py2max-0.3.4}/py2max/core/box.py +0 -0
  27. {py2max-0.3.3 → py2max-0.3.4}/py2max/core/colors.py +0 -0
  28. {py2max-0.3.3 → py2max-0.3.4}/py2max/core/common.py +0 -0
  29. {py2max-0.3.3 → py2max-0.3.4}/py2max/core/patchline.py +0 -0
  30. {py2max-0.3.3 → py2max-0.3.4}/py2max/exceptions.py +0 -0
  31. {py2max-0.3.3 → py2max-0.3.4}/py2max/export/__init__.py +0 -0
  32. {py2max-0.3.3 → py2max-0.3.4}/py2max/export/converters.py +0 -0
  33. {py2max-0.3.3 → py2max-0.3.4}/py2max/export/svg.py +0 -0
  34. {py2max-0.3.3 → py2max-0.3.4}/py2max/layout/__init__.py +0 -0
  35. {py2max-0.3.3 → py2max-0.3.4}/py2max/layout/external.py +0 -0
  36. {py2max-0.3.3 → py2max-0.3.4}/py2max/layout/flow.py +0 -0
  37. {py2max-0.3.3 → py2max-0.3.4}/py2max/layout/graph.py +0 -0
  38. {py2max-0.3.3 → py2max-0.3.4}/py2max/layout/grid.py +0 -0
  39. {py2max-0.3.3 → py2max-0.3.4}/py2max/layout/matrix.py +0 -0
  40. {py2max-0.3.3 → py2max-0.3.4}/py2max/log.py +0 -0
  41. {py2max-0.3.3 → py2max-0.3.4}/py2max/m4l.py +0 -0
  42. {py2max-0.3.3 → py2max-0.3.4}/py2max/maxref/__init__.py +0 -0
  43. {py2max-0.3.3 → py2max-0.3.4}/py2max/maxref/category.py +0 -0
  44. {py2max-0.3.3 → py2max-0.3.4}/py2max/maxref/db.py +0 -0
  45. {py2max-0.3.3 → py2max-0.3.4}/py2max/maxref/legacy.py +0 -0
  46. {py2max-0.3.3 → py2max-0.3.4}/py2max/py.typed +0 -0
  47. {py2max-0.3.3 → py2max-0.3.4}/py2max/transformers.py +0 -0
  48. {py2max-0.3.3 → py2max-0.3.4}/py2max/utils.py +0 -0
  49. {py2max-0.3.3 → py2max-0.3.4}/tests/__init__.py +0 -0
  50. {py2max-0.3.3 → py2max-0.3.4}/tests/conftest.py +0 -0
  51. {py2max-0.3.3 → py2max-0.3.4}/tests/data/complex.maxpat +0 -0
  52. {py2max-0.3.3 → py2max-0.3.4}/tests/data/desc.maxpat +0 -0
  53. {py2max-0.3.3 → py2max-0.3.4}/tests/data/empty.maxpat +0 -0
  54. {py2max-0.3.3 → py2max-0.3.4}/tests/data/mydevice.amxd +0 -0
  55. {py2max-0.3.3 → py2max-0.3.4}/tests/data/mydevice2.amxd +0 -0
  56. {py2max-0.3.3 → py2max-0.3.4}/tests/data/nested.maxpat +0 -0
  57. {py2max-0.3.3 → py2max-0.3.4}/tests/data/simple.maxpat +0 -0
  58. {py2max-0.3.3 → py2max-0.3.4}/tests/data/tabular.maxpat +0 -0
  59. {py2max-0.3.3 → py2max-0.3.4}/tests/data/umenu.maxref.xml +0 -0
  60. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/README.md +0 -0
  61. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/advanced/connection_patterns.py +0 -0
  62. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/advanced/custom_extensions.py +0 -0
  63. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/advanced/data_containers.py +0 -0
  64. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/advanced/error_handling.py +0 -0
  65. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/advanced/performance_optimization.py +0 -0
  66. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/advanced/subpatchers.py +0 -0
  67. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/api/box_api_examples.py +0 -0
  68. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/api/patcher_api_examples.py +0 -0
  69. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/auto_layout_demo.py +0 -0
  70. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/db/category_db_demo.py +0 -0
  71. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/db/maxref_db_demo.py +0 -0
  72. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/layout/columnar_layout_examples.py +0 -0
  73. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/layout/flow_layout_examples.py +0 -0
  74. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/layout/grid_layout_examples.py +0 -0
  75. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/layout/matrix_layout_examples.py +0 -0
  76. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/preview/basic_synth.maxpat +0 -0
  77. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/preview/basic_synth.svg +0 -0
  78. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/preview/complex_synth.maxpat +0 -0
  79. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/preview/complex_synth.svg +0 -0
  80. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/preview/flow_layout.maxpat +0 -0
  81. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/preview/flow_layout.svg +0 -0
  82. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/preview/grid_layout.maxpat +0 -0
  83. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/preview/grid_layout.svg +0 -0
  84. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/preview/horizontal_layout.maxpat +0 -0
  85. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/preview/horizontal_layout.svg +0 -0
  86. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/preview/styled_no_ports.svg +0 -0
  87. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/preview/styled_no_title.svg +0 -0
  88. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/preview/styled_patch.maxpat +0 -0
  89. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/preview/styled_with_ports.svg +0 -0
  90. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/preview/svg_preview_demo.py +0 -0
  91. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/preview/vertical_layout.maxpat +0 -0
  92. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/preview/vertical_layout.svg +0 -0
  93. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/preview/workflow_demo.maxpat +0 -0
  94. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/preview/workflow_demo.svg +0 -0
  95. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/quickstart/basic_patch.py +0 -0
  96. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/quickstart/layout_examples.py +0 -0
  97. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/tutorial/generative_music.py +0 -0
  98. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/tutorial/interactive_controller.py +0 -0
  99. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/tutorial/signal_processing_chain.py +0 -0
  100. {py2max-0.3.3 → py2max-0.3.4}/tests/examples/tutorial/simple_synthesis.py +0 -0
  101. {py2max-0.3.3 → py2max-0.3.4}/tests/graphs/random/v30e33.tglf +0 -0
  102. {py2max-0.3.3 → py2max-0.3.4}/tests/test_abstract_coverage.py +0 -0
  103. {py2max-0.3.3 → py2max-0.3.4}/tests/test_abstraction.py +0 -0
  104. {py2max-0.3.3 → py2max-0.3.4}/tests/test_add.py +0 -0
  105. {py2max-0.3.3 → py2max-0.3.4}/tests/test_amxd.py +0 -0
  106. {py2max-0.3.3 → py2max-0.3.4}/tests/test_attrui.py +0 -0
  107. {py2max-0.3.3 → py2max-0.3.4}/tests/test_basic.py +0 -0
  108. {py2max-0.3.3 → py2max-0.3.4}/tests/test_beap.py +0 -0
  109. {py2max-0.3.3 → py2max-0.3.4}/tests/test_bpatcher.py +0 -0
  110. {py2max-0.3.3 → py2max-0.3.4}/tests/test_cli.py +0 -0
  111. {py2max-0.3.3 → py2max-0.3.4}/tests/test_coll.py +0 -0
  112. {py2max-0.3.3 → py2max-0.3.4}/tests/test_colors.py +0 -0
  113. {py2max-0.3.3 → py2max-0.3.4}/tests/test_colors_theme.py +0 -0
  114. {py2max-0.3.3 → py2max-0.3.4}/tests/test_comment.py +0 -0
  115. {py2max-0.3.3 → py2max-0.3.4}/tests/test_connection_validation.py +0 -0
  116. {py2max-0.3.3 → py2max-0.3.4}/tests/test_converters.py +0 -0
  117. {py2max-0.3.3 → py2max-0.3.4}/tests/test_core_coverage.py +0 -0
  118. {py2max-0.3.3 → py2max-0.3.4}/tests/test_db.py +0 -0
  119. {py2max-0.3.3 → py2max-0.3.4}/tests/test_defaults.py +0 -0
  120. {py2max-0.3.3 → py2max-0.3.4}/tests/test_dict.py +0 -0
  121. {py2max-0.3.3 → py2max-0.3.4}/tests/test_edit_operations.py +0 -0
  122. {py2max-0.3.3 → py2max-0.3.4}/tests/test_encapsulate.py +0 -0
  123. {py2max-0.3.3 → py2max-0.3.4}/tests/test_ezdac.py +0 -0
  124. {py2max-0.3.3 → py2max-0.3.4}/tests/test_gen.py +0 -0
  125. {py2max-0.3.3 → py2max-0.3.4}/tests/test_group.py +0 -0
  126. {py2max-0.3.3 → py2max-0.3.4}/tests/test_itable.py +0 -0
  127. {py2max-0.3.3 → py2max-0.3.4}/tests/test_js.py +0 -0
  128. {py2max-0.3.3 → py2max-0.3.4}/tests/test_kwds_filter.py +0 -0
  129. {py2max-0.3.3 → py2max-0.3.4}/tests/test_layout.py +0 -0
  130. {py2max-0.3.3 → py2max-0.3.4}/tests/test_layout_box_dims.py +0 -0
  131. {py2max-0.3.3 → py2max-0.3.4}/tests/test_layout_builtins.py +0 -0
  132. {py2max-0.3.3 → py2max-0.3.4}/tests/test_layout_coverage.py +0 -0
  133. {py2max-0.3.3 → py2max-0.3.4}/tests/test_layout_flow.py +0 -0
  134. {py2max-0.3.3 → py2max-0.3.4}/tests/test_layout_graph.py +0 -0
  135. {py2max-0.3.3 → py2max-0.3.4}/tests/test_layout_graph_layout.py +0 -0
  136. {py2max-0.3.3 → py2max-0.3.4}/tests/test_layout_graph_manager.py +0 -0
  137. {py2max-0.3.3 → py2max-0.3.4}/tests/test_layout_matrix.py +0 -0
  138. {py2max-0.3.3 → py2max-0.3.4}/tests/test_layout_overlaps.py +0 -0
  139. {py2max-0.3.3 → py2max-0.3.4}/tests/test_layout_vertical.py +0 -0
  140. {py2max-0.3.3 → py2max-0.3.4}/tests/test_linking.py +0 -0
  141. {py2max-0.3.3 → py2max-0.3.4}/tests/test_m4l.py +0 -0
  142. {py2max-0.3.3 → py2max-0.3.4}/tests/test_maxref.py +0 -0
  143. {py2max-0.3.3 → py2max-0.3.4}/tests/test_maxref_lazy.py +0 -0
  144. {py2max-0.3.3 → py2max-0.3.4}/tests/test_maxref_parser.py +0 -0
  145. {py2max-0.3.3 → py2max-0.3.4}/tests/test_maxref_refpages.py +0 -0
  146. {py2max-0.3.3 → py2max-0.3.4}/tests/test_mc_cycle.py +0 -0
  147. {py2max-0.3.3 → py2max-0.3.4}/tests/test_mc_poly.py +0 -0
  148. {py2max-0.3.3 → py2max-0.3.4}/tests/test_message.py +0 -0
  149. {py2max-0.3.3 → py2max-0.3.4}/tests/test_mypatch.py +0 -0
  150. {py2max-0.3.3 → py2max-0.3.4}/tests/test_nested.py +0 -0
  151. {py2max-0.3.3 → py2max-0.3.4}/tests/test_nested_patchers.py +0 -0
  152. {py2max-0.3.3 → py2max-0.3.4}/tests/test_number_tilde.py +0 -0
  153. {py2max-0.3.3 → py2max-0.3.4}/tests/test_numbers.py +0 -0
  154. {py2max-0.3.3 → py2max-0.3.4}/tests/test_param.py +0 -0
  155. {py2max-0.3.3 → py2max-0.3.4}/tests/test_patcher.py +0 -0
  156. {py2max-0.3.3 → py2max-0.3.4}/tests/test_pitched_osc.py +0 -0
  157. {py2max-0.3.3 → py2max-0.3.4}/tests/test_presets.py +0 -0
  158. {py2max-0.3.3 → py2max-0.3.4}/tests/test_pydantic.py +0 -0
  159. {py2max-0.3.3 → py2max-0.3.4}/tests/test_rnbo.py +0 -0
  160. {py2max-0.3.3 → py2max-0.3.4}/tests/test_rnbo_subpatcher.py +0 -0
  161. {py2max-0.3.3 → py2max-0.3.4}/tests/test_scripting_name.py +0 -0
  162. {py2max-0.3.3 → py2max-0.3.4}/tests/test_search.py +0 -0
  163. {py2max-0.3.3 → py2max-0.3.4}/tests/test_semantic_ids.py +0 -0
  164. {py2max-0.3.3 → py2max-0.3.4}/tests/test_subpatch.py +0 -0
  165. {py2max-0.3.3 → py2max-0.3.4}/tests/test_svg.py +0 -0
  166. {py2max-0.3.3 → py2max-0.3.4}/tests/test_svg_fidelity.py +0 -0
  167. {py2max-0.3.3 → py2max-0.3.4}/tests/test_table.py +0 -0
  168. {py2max-0.3.3 → py2max-0.3.4}/tests/test_transformers.py +0 -0
  169. {py2max-0.3.3 → py2max-0.3.4}/tests/test_transformers_e4.py +0 -0
  170. {py2max-0.3.3 → py2max-0.3.4}/tests/test_tree.py +0 -0
  171. {py2max-0.3.3 → py2max-0.3.4}/tests/test_tree_builder.py +0 -0
  172. {py2max-0.3.3 → py2max-0.3.4}/tests/test_tutorial_simple_synthesis.py +0 -0
  173. {py2max-0.3.3 → py2max-0.3.4}/tests/test_two_sines.py +0 -0
  174. {py2max-0.3.3 → py2max-0.3.4}/tests/test_umenu.py +0 -0
  175. {py2max-0.3.3 → py2max-0.3.4}/tests/test_utils.py +0 -0
  176. {py2max-0.3.3 → py2max-0.3.4}/tests/test_validate_attrs.py +0 -0
  177. {py2max-0.3.3 → py2max-0.3.4}/tests/test_varname.py +0 -0
  178. {py2max-0.3.3 → py2max-0.3.4}/tests/test_wheel_bundle.py +0 -0
  179. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: py2max
3
- Version: 0.3.3
3
+ Version: 0.3.4
4
4
  Summary: A library for offline generation of Max/MSP patcher (.maxpat) files.
5
5
  Keywords: Max,maxpat,offline
6
6
  Author: Shakeeb Alireza
@@ -35,7 +35,7 @@ Example:
35
35
  >>> p.save()
36
36
  """
37
37
 
38
- __version__ = "0.3.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, Patchline
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, validate_connection
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
- errors: List[str] = []
234
-
235
- for line in patcher._lines:
236
- patchline = cast(Patchline, line)
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
- print("All connections are valid.")
266
- return 0
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, src_outlet, dst_name, dst_inlet
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 Any, Dict, Iterator, List, Optional, Tuple, Union, cast
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)