py2max 0.2.1__py3-none-any.whl

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 (48) hide show
  1. py2max/__init__.py +67 -0
  2. py2max/__main__.py +6 -0
  3. py2max/cli.py +1251 -0
  4. py2max/core/__init__.py +39 -0
  5. py2max/core/abstract.py +146 -0
  6. py2max/core/box.py +231 -0
  7. py2max/core/common.py +19 -0
  8. py2max/core/patcher.py +1658 -0
  9. py2max/core/patchline.py +68 -0
  10. py2max/exceptions.py +385 -0
  11. py2max/export/__init__.py +20 -0
  12. py2max/export/converters.py +345 -0
  13. py2max/export/svg.py +393 -0
  14. py2max/layout/__init__.py +26 -0
  15. py2max/layout/base.py +463 -0
  16. py2max/layout/flow.py +405 -0
  17. py2max/layout/grid.py +374 -0
  18. py2max/layout/matrix.py +628 -0
  19. py2max/log.py +338 -0
  20. py2max/maxref/__init__.py +78 -0
  21. py2max/maxref/category.py +163 -0
  22. py2max/maxref/db.py +1082 -0
  23. py2max/maxref/legacy.py +324 -0
  24. py2max/maxref/parser.py +703 -0
  25. py2max/py.typed +0 -0
  26. py2max/server/__init__.py +54 -0
  27. py2max/server/client.py +295 -0
  28. py2max/server/inline.py +312 -0
  29. py2max/server/repl.py +561 -0
  30. py2max/server/rpc.py +240 -0
  31. py2max/server/websocket.py +997 -0
  32. py2max/static/cola.min.js +4 -0
  33. py2max/static/d3.v7.min.js +2 -0
  34. py2max/static/dagre-bundle.js +328 -0
  35. py2max/static/elk.bundled.js +6663 -0
  36. py2max/static/index.html +168 -0
  37. py2max/static/interactive.html +589 -0
  38. py2max/static/interactive.js +2111 -0
  39. py2max/static/live-preview.js +324 -0
  40. py2max/static/svg.min.js +13 -0
  41. py2max/static/svg.min.js.map +1 -0
  42. py2max/transformers.py +168 -0
  43. py2max/utils.py +83 -0
  44. py2max-0.2.1.dist-info/METADATA +390 -0
  45. py2max-0.2.1.dist-info/RECORD +48 -0
  46. py2max-0.2.1.dist-info/WHEEL +4 -0
  47. py2max-0.2.1.dist-info/entry_points.txt +3 -0
  48. py2max-0.2.1.dist-info/licenses/LICENSE +19 -0
py2max/maxref/db.py ADDED
@@ -0,0 +1,1082 @@
1
+ """db.py - SQLite database for Max object reference data"""
2
+
3
+ import json
4
+ import sqlite3
5
+ import sys
6
+ from contextlib import contextmanager
7
+ from pathlib import Path
8
+ from typing import Any, Dict, List, Optional, Union
9
+
10
+ from .parser import (
11
+ get_all_jit_objects,
12
+ get_all_m4l_objects,
13
+ get_all_max_objects,
14
+ get_all_msp_objects,
15
+ get_available_objects,
16
+ get_object_info,
17
+ )
18
+
19
+
20
+ class MaxRefDB:
21
+ """SQLite database for Max object reference data
22
+
23
+ By default, uses a cached database at:
24
+ - macOS: ~/Library/Caches/py2max/maxref.db
25
+ - Linux: ~/.cache/py2max/maxref.db
26
+ - Windows: ~/AppData/Local/py2max/Cache/maxref.db
27
+
28
+ The cache is automatically populated on first use.
29
+ """
30
+
31
+ @staticmethod
32
+ def get_cache_dir() -> Path:
33
+ """Get platform-specific cache directory for py2max
34
+
35
+ Returns:
36
+ Path to cache directory:
37
+ - macOS: ~/Library/Caches/py2max
38
+ - Linux: ~/.cache/py2max
39
+ - Windows: ~/AppData/Local/py2max/Cache
40
+ """
41
+ home = Path.home()
42
+
43
+ if sys.platform == "darwin": # macOS
44
+ cache_dir = home / "Library" / "Caches" / "py2max"
45
+ elif sys.platform == "win32": # Windows
46
+ cache_dir = home / "AppData" / "Local" / "py2max" / "Cache"
47
+ else: # Linux and others
48
+ cache_dir = home / ".cache" / "py2max"
49
+
50
+ cache_dir.mkdir(parents=True, exist_ok=True)
51
+ return cache_dir
52
+
53
+ @staticmethod
54
+ def get_default_db_path() -> Path:
55
+ """Get default database path
56
+
57
+ Returns:
58
+ Path to default maxref.db in cache directory
59
+ """
60
+ return MaxRefDB.get_cache_dir() / "maxref.db"
61
+
62
+ def __init__(
63
+ self, db_path: Optional[Union[Path, str]] = None, auto_populate: bool = True
64
+ ):
65
+ """Initialize database connection
66
+
67
+ Args:
68
+ db_path: Path to SQLite database file.
69
+ If None, uses platform-specific cache location.
70
+ Use ":memory:" for in-memory database.
71
+ auto_populate: If True and database is empty, automatically populate
72
+ with all Max objects (only applies to file-based databases).
73
+ """
74
+ if db_path is None:
75
+ self.db_path: Union[Path, str] = self.get_default_db_path()
76
+ self._use_cache = True
77
+ elif db_path == ":memory:":
78
+ self.db_path = ":memory:"
79
+ self._use_cache = False
80
+ else:
81
+ self.db_path = Path(db_path)
82
+ self._use_cache = False
83
+
84
+ self._conn = None
85
+ # For in-memory databases, maintain a persistent connection
86
+ if self.db_path == ":memory:":
87
+ self._conn = sqlite3.connect(":memory:")
88
+ self._conn.row_factory = sqlite3.Row
89
+
90
+ self._init_schema()
91
+
92
+ # Auto-populate cache on first use
93
+ if auto_populate and self._use_cache and self.count == 0:
94
+ self._auto_populate_cache()
95
+
96
+ def __len__(self) -> int:
97
+ """Return number of objects in database"""
98
+ return self.count
99
+
100
+ def __enter__(self) -> "MaxRefDB":
101
+ """Context manager entry"""
102
+ return self
103
+
104
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
105
+ """Context manager exit - close connection"""
106
+ self.close()
107
+
108
+ def __del__(self) -> None:
109
+ """Destructor - ensure connection is closed"""
110
+ self.close()
111
+
112
+ def close(self) -> None:
113
+ """Close database connection
114
+
115
+ Safe to call multiple times. Should be called when done with
116
+ in-memory databases to prevent ResourceWarning.
117
+ """
118
+ if self._conn is not None:
119
+ try:
120
+ self._conn.close()
121
+ except Exception:
122
+ pass # Ignore errors during close
123
+ self._conn = None
124
+
125
+ def __contains__(self, name: str) -> bool:
126
+ """Check if object exists in database"""
127
+ with self._get_cursor() as cursor:
128
+ cursor.execute("SELECT 1 FROM objects WHERE name = ?", (name,))
129
+ return cursor.fetchone() is not None
130
+
131
+ def __getitem__(self, name: str) -> Dict[str, Any]:
132
+ """Get object by name using subscript notation"""
133
+ obj = self.get_object(name)
134
+ if obj is None:
135
+ raise KeyError(f"Object '{name}' not found in database")
136
+ return obj
137
+
138
+ @classmethod
139
+ def create_database(cls, db_path: Path, populate: bool = True) -> "MaxRefDB":
140
+ """Create and optionally populate a Max reference database
141
+
142
+ Args:
143
+ db_path: Path to SQLite database file
144
+ populate: Whether to populate with all available .maxref.xml files
145
+
146
+ Returns:
147
+ MaxRefDB instance
148
+ """
149
+ db = cls(db_path)
150
+ if populate:
151
+ db.populate_from_maxref()
152
+ return db
153
+
154
+ @contextmanager
155
+ def _get_cursor(self):
156
+ """Context manager for database cursor"""
157
+ if self._conn is not None:
158
+ # Use persistent connection for in-memory databases
159
+ cursor = self._conn.cursor()
160
+ try:
161
+ yield cursor
162
+ self._conn.commit()
163
+ except Exception:
164
+ self._conn.rollback()
165
+ raise
166
+ else:
167
+ # Use new connection for file-based databases
168
+ conn = sqlite3.connect(self.db_path)
169
+ conn.row_factory = sqlite3.Row
170
+ cursor = conn.cursor()
171
+ try:
172
+ yield cursor
173
+ conn.commit()
174
+ except Exception:
175
+ conn.rollback()
176
+ raise
177
+ finally:
178
+ conn.close()
179
+
180
+ def _init_schema(self):
181
+ """Create database schema"""
182
+ with self._get_cursor() as cursor:
183
+ # Main objects table
184
+ cursor.execute("""
185
+ CREATE TABLE IF NOT EXISTS objects (
186
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
187
+ name TEXT UNIQUE NOT NULL,
188
+ digest TEXT,
189
+ description TEXT,
190
+ category TEXT,
191
+ module_pathname TEXT,
192
+ autofit INTEGER DEFAULT 0,
193
+ root_attrs TEXT
194
+ )
195
+ """)
196
+
197
+ # Metadata table
198
+ cursor.execute("""
199
+ CREATE TABLE IF NOT EXISTS metadata (
200
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
201
+ object_id INTEGER NOT NULL,
202
+ key TEXT NOT NULL,
203
+ value TEXT NOT NULL,
204
+ FOREIGN KEY (object_id) REFERENCES objects(id) ON DELETE CASCADE
205
+ )
206
+ """)
207
+
208
+ # Inlets table
209
+ cursor.execute("""
210
+ CREATE TABLE IF NOT EXISTS inlets (
211
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
212
+ object_id INTEGER NOT NULL,
213
+ inlet_id TEXT NOT NULL,
214
+ inlet_type TEXT,
215
+ digest TEXT,
216
+ description TEXT,
217
+ attrs TEXT,
218
+ FOREIGN KEY (object_id) REFERENCES objects(id) ON DELETE CASCADE
219
+ )
220
+ """)
221
+
222
+ # Outlets table
223
+ cursor.execute("""
224
+ CREATE TABLE IF NOT EXISTS outlets (
225
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
226
+ object_id INTEGER NOT NULL,
227
+ outlet_id TEXT NOT NULL,
228
+ outlet_type TEXT,
229
+ digest TEXT,
230
+ description TEXT,
231
+ attrs TEXT,
232
+ FOREIGN KEY (object_id) REFERENCES objects(id) ON DELETE CASCADE
233
+ )
234
+ """)
235
+
236
+ # Object arguments table
237
+ cursor.execute("""
238
+ CREATE TABLE IF NOT EXISTS objargs (
239
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
240
+ object_id INTEGER NOT NULL,
241
+ name TEXT,
242
+ optional INTEGER DEFAULT 0,
243
+ arg_type TEXT,
244
+ digest TEXT,
245
+ description TEXT,
246
+ attrs TEXT,
247
+ FOREIGN KEY (object_id) REFERENCES objects(id) ON DELETE CASCADE
248
+ )
249
+ """)
250
+
251
+ # Methods table
252
+ cursor.execute("""
253
+ CREATE TABLE IF NOT EXISTS methods (
254
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
255
+ object_id INTEGER NOT NULL,
256
+ name TEXT NOT NULL,
257
+ digest TEXT,
258
+ description TEXT,
259
+ attrs TEXT,
260
+ FOREIGN KEY (object_id) REFERENCES objects(id) ON DELETE CASCADE
261
+ )
262
+ """)
263
+
264
+ # Method arguments table
265
+ cursor.execute("""
266
+ CREATE TABLE IF NOT EXISTS method_args (
267
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
268
+ method_id INTEGER NOT NULL,
269
+ name TEXT,
270
+ optional INTEGER DEFAULT 0,
271
+ arg_type TEXT,
272
+ attrs TEXT,
273
+ FOREIGN KEY (method_id) REFERENCES methods(id) ON DELETE CASCADE
274
+ )
275
+ """)
276
+
277
+ # Attributes table
278
+ cursor.execute("""
279
+ CREATE TABLE IF NOT EXISTS attributes (
280
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
281
+ object_id INTEGER NOT NULL,
282
+ name TEXT NOT NULL,
283
+ can_get INTEGER DEFAULT 1,
284
+ can_set INTEGER DEFAULT 1,
285
+ attr_type TEXT,
286
+ size INTEGER,
287
+ digest TEXT,
288
+ description TEXT,
289
+ attrs TEXT,
290
+ FOREIGN KEY (object_id) REFERENCES objects(id) ON DELETE CASCADE
291
+ )
292
+ """)
293
+
294
+ # Attribute enum values table
295
+ cursor.execute("""
296
+ CREATE TABLE IF NOT EXISTS attribute_enums (
297
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
298
+ attribute_id INTEGER NOT NULL,
299
+ name TEXT,
300
+ digest TEXT,
301
+ description TEXT,
302
+ attrs TEXT,
303
+ FOREIGN KEY (attribute_id) REFERENCES attributes(id) ON DELETE CASCADE
304
+ )
305
+ """)
306
+
307
+ # Examples table
308
+ cursor.execute("""
309
+ CREATE TABLE IF NOT EXISTS examples (
310
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
311
+ object_id INTEGER NOT NULL,
312
+ name TEXT,
313
+ caption TEXT,
314
+ attrs TEXT,
315
+ FOREIGN KEY (object_id) REFERENCES objects(id) ON DELETE CASCADE
316
+ )
317
+ """)
318
+
319
+ # See also references table
320
+ cursor.execute("""
321
+ CREATE TABLE IF NOT EXISTS seealso (
322
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
323
+ object_id INTEGER NOT NULL,
324
+ ref_name TEXT NOT NULL,
325
+ FOREIGN KEY (object_id) REFERENCES objects(id) ON DELETE CASCADE
326
+ )
327
+ """)
328
+
329
+ # Misc table (for Output, Connections, etc.)
330
+ cursor.execute("""
331
+ CREATE TABLE IF NOT EXISTS misc (
332
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
333
+ object_id INTEGER NOT NULL,
334
+ category TEXT NOT NULL,
335
+ entry_name TEXT NOT NULL,
336
+ description TEXT,
337
+ FOREIGN KEY (object_id) REFERENCES objects(id) ON DELETE CASCADE
338
+ )
339
+ """)
340
+
341
+ # Palette information table
342
+ cursor.execute("""
343
+ CREATE TABLE IF NOT EXISTS palette (
344
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
345
+ object_id INTEGER NOT NULL,
346
+ category TEXT,
347
+ palette_index INTEGER,
348
+ attrs TEXT,
349
+ FOREIGN KEY (object_id) REFERENCES objects(id) ON DELETE CASCADE
350
+ )
351
+ """)
352
+
353
+ # Parameter information table
354
+ cursor.execute("""
355
+ CREATE TABLE IF NOT EXISTS parameter (
356
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
357
+ object_id INTEGER NOT NULL,
358
+ attrs TEXT,
359
+ FOREIGN KEY (object_id) REFERENCES objects(id) ON DELETE CASCADE
360
+ )
361
+ """)
362
+
363
+ # Create indices for faster queries
364
+ cursor.execute(
365
+ "CREATE INDEX IF NOT EXISTS idx_objects_name ON objects(name)"
366
+ )
367
+ cursor.execute(
368
+ "CREATE INDEX IF NOT EXISTS idx_metadata_object ON metadata(object_id)"
369
+ )
370
+ cursor.execute(
371
+ "CREATE INDEX IF NOT EXISTS idx_inlets_object ON inlets(object_id)"
372
+ )
373
+ cursor.execute(
374
+ "CREATE INDEX IF NOT EXISTS idx_outlets_object ON outlets(object_id)"
375
+ )
376
+ cursor.execute(
377
+ "CREATE INDEX IF NOT EXISTS idx_methods_object ON methods(object_id)"
378
+ )
379
+ cursor.execute(
380
+ "CREATE INDEX IF NOT EXISTS idx_attributes_object ON attributes(object_id)"
381
+ )
382
+
383
+ def populate(
384
+ self, object_names: Optional[List[str]] = None, category: Optional[str] = None
385
+ ):
386
+ """Populate database from .maxref.xml files
387
+
388
+ Args:
389
+ object_names: List of object names to import. If None, imports based on category.
390
+ category: Category to import ('max', 'msp', 'jit', 'm4l', or None for all).
391
+ Ignored if object_names is provided.
392
+ """
393
+ if object_names is None:
394
+ if category is None:
395
+ object_names = get_available_objects()
396
+ elif category.lower() == "max":
397
+ object_names = get_all_max_objects()
398
+ elif category.lower() == "msp":
399
+ object_names = get_all_msp_objects()
400
+ elif category.lower() == "jit":
401
+ object_names = get_all_jit_objects()
402
+ elif category.lower() == "m4l":
403
+ object_names = get_all_m4l_objects()
404
+ else:
405
+ raise ValueError(
406
+ f"Unknown category: {category}. Use 'max', 'msp', 'jit', 'm4l', or None"
407
+ )
408
+
409
+ for name in object_names:
410
+ data = get_object_info(name)
411
+ if data:
412
+ self.insert_object(name, data)
413
+
414
+ # Deprecated methods - use populate() instead
415
+ def populate_from_maxref(self, object_names: Optional[List[str]] = None):
416
+ """Populate database from .maxref.xml files (deprecated: use populate())
417
+
418
+ Args:
419
+ object_names: List of object names to import. If None, imports all available objects.
420
+ """
421
+ self.populate(object_names)
422
+
423
+ def populate_all_objects(self):
424
+ """Populate database with all available Max objects (deprecated: use populate())"""
425
+ self.populate(category=None)
426
+
427
+ def populate_all_max_objects(self):
428
+ """Populate database with all Max objects (deprecated: use populate(category='max'))"""
429
+ self.populate(category="max")
430
+
431
+ def populate_all_jit_objects(self):
432
+ """Populate database with all Jitter objects (deprecated: use populate(category='jit'))"""
433
+ self.populate(category="jit")
434
+
435
+ def populate_all_msp_objects(self):
436
+ """Populate database with all MSP objects (deprecated: use populate(category='msp'))"""
437
+ self.populate(category="msp")
438
+
439
+ def populate_all_m4l_objects(self):
440
+ """Populate database with all Max for Live objects (deprecated: use populate(category='m4l'))"""
441
+ self.populate(category="m4l")
442
+
443
+ # -------------------------------------------------------------------------
444
+ # Insert helpers
445
+ # -------------------------------------------------------------------------
446
+
447
+ def _extract_extra_attrs(
448
+ self, data: Dict[str, Any], exclude: List[str]
449
+ ) -> Optional[str]:
450
+ """Extract extra attributes not in exclude list and JSON-encode them."""
451
+ extra = {k: v for k, v in data.items() if k not in exclude}
452
+ return json.dumps(extra) if extra else None
453
+
454
+ def _insert_main_object(
455
+ self, cursor: sqlite3.Cursor, name: str, data: Dict[str, Any]
456
+ ) -> int:
457
+ """Insert main object record and return object_id."""
458
+ known_keys = {
459
+ "digest",
460
+ "description",
461
+ "metadata",
462
+ "inlets",
463
+ "outlets",
464
+ "objargs",
465
+ "methods",
466
+ "attributes",
467
+ "examples",
468
+ "seealso",
469
+ "misc",
470
+ "palette",
471
+ "parameter",
472
+ "name",
473
+ "category",
474
+ "module_pathname",
475
+ "autofit",
476
+ }
477
+ root_attrs = {k: v for k, v in data.items() if k not in known_keys}
478
+
479
+ cursor.execute(
480
+ """
481
+ INSERT OR REPLACE INTO objects
482
+ (name, digest, description, category, module_pathname, autofit, root_attrs)
483
+ VALUES (?, ?, ?, ?, ?, ?, ?)
484
+ """,
485
+ (
486
+ name,
487
+ data.get("digest"),
488
+ data.get("description"),
489
+ data.get("category"),
490
+ data.get("module_pathname"),
491
+ 1 if data.get("autofit") == "1" else 0,
492
+ json.dumps(root_attrs) if root_attrs else None,
493
+ ),
494
+ )
495
+ return cursor.lastrowid # type: ignore[return-value]
496
+
497
+ def _delete_related_records(self, cursor: sqlite3.Cursor, object_id: int) -> None:
498
+ """Delete all related records for an object (for REPLACE operations)."""
499
+ tables = [
500
+ "metadata",
501
+ "inlets",
502
+ "outlets",
503
+ "objargs",
504
+ "methods",
505
+ "attributes",
506
+ "examples",
507
+ "seealso",
508
+ "misc",
509
+ "palette",
510
+ "parameter",
511
+ ]
512
+ for table in tables:
513
+ cursor.execute(f"DELETE FROM {table} WHERE object_id = ?", (object_id,))
514
+
515
+ def _insert_metadata(
516
+ self, cursor: sqlite3.Cursor, object_id: int, metadata: Dict[str, Any]
517
+ ) -> None:
518
+ """Insert metadata key-value pairs."""
519
+ for key, value in metadata.items():
520
+ values = value if isinstance(value, list) else [value]
521
+ for v in values:
522
+ cursor.execute(
523
+ "INSERT INTO metadata (object_id, key, value) VALUES (?, ?, ?)",
524
+ (object_id, key, v),
525
+ )
526
+
527
+ def _insert_inlets_outlets(
528
+ self, cursor: sqlite3.Cursor, object_id: int, items: List[Dict], table: str
529
+ ) -> None:
530
+ """Insert inlet or outlet records."""
531
+ id_col = "inlet_id" if table == "inlets" else "outlet_id"
532
+ type_col = "inlet_type" if table == "inlets" else "outlet_type"
533
+ exclude = ["id", "type", "digest", "description"]
534
+
535
+ for item in items:
536
+ cursor.execute(
537
+ f"""
538
+ INSERT INTO {table} (object_id, {id_col}, {type_col}, digest, description, attrs)
539
+ VALUES (?, ?, ?, ?, ?, ?)
540
+ """,
541
+ (
542
+ object_id,
543
+ item.get("id"),
544
+ item.get("type"),
545
+ item.get("digest"),
546
+ item.get("description"),
547
+ self._extract_extra_attrs(item, exclude),
548
+ ),
549
+ )
550
+
551
+ def _insert_objargs(
552
+ self, cursor: sqlite3.Cursor, object_id: int, objargs: List[Dict]
553
+ ) -> None:
554
+ """Insert object argument records."""
555
+ exclude = ["name", "optional", "type", "digest", "description"]
556
+ for objarg in objargs:
557
+ cursor.execute(
558
+ """
559
+ INSERT INTO objargs (object_id, name, optional, arg_type, digest, description, attrs)
560
+ VALUES (?, ?, ?, ?, ?, ?, ?)
561
+ """,
562
+ (
563
+ object_id,
564
+ objarg.get("name"),
565
+ 1 if objarg.get("optional") == "1" else 0,
566
+ objarg.get("type"),
567
+ objarg.get("digest"),
568
+ objarg.get("description"),
569
+ self._extract_extra_attrs(objarg, exclude),
570
+ ),
571
+ )
572
+
573
+ def _insert_methods(
574
+ self, cursor: sqlite3.Cursor, object_id: int, methods: Dict[str, Dict]
575
+ ) -> None:
576
+ """Insert method records and their arguments."""
577
+ method_exclude = ["digest", "description", "args", "attributes"]
578
+ arg_exclude = ["name", "optional", "type"]
579
+
580
+ for method_name, method_data in methods.items():
581
+ cursor.execute(
582
+ """
583
+ INSERT INTO methods (object_id, name, digest, description, attrs)
584
+ VALUES (?, ?, ?, ?, ?)
585
+ """,
586
+ (
587
+ object_id,
588
+ method_name,
589
+ method_data.get("digest"),
590
+ method_data.get("description"),
591
+ self._extract_extra_attrs(method_data, method_exclude),
592
+ ),
593
+ )
594
+ method_id = cursor.lastrowid
595
+
596
+ for arg in method_data.get("args", []):
597
+ cursor.execute(
598
+ """
599
+ INSERT INTO method_args (method_id, name, optional, arg_type, attrs)
600
+ VALUES (?, ?, ?, ?, ?)
601
+ """,
602
+ (
603
+ method_id,
604
+ arg.get("name"),
605
+ 1 if arg.get("optional") == "1" else 0,
606
+ arg.get("type"),
607
+ self._extract_extra_attrs(arg, arg_exclude),
608
+ ),
609
+ )
610
+
611
+ def _insert_attributes(
612
+ self, cursor: sqlite3.Cursor, object_id: int, attributes: Dict[str, Dict]
613
+ ) -> None:
614
+ """Insert attribute records and their enum values."""
615
+ attr_exclude = [
616
+ "get",
617
+ "set",
618
+ "type",
619
+ "size",
620
+ "digest",
621
+ "description",
622
+ "enumlist",
623
+ "attributes",
624
+ ]
625
+ enum_exclude = ["name", "digest", "description"]
626
+
627
+ for attr_name, attr_data in attributes.items():
628
+ size_val = attr_data.get("size")
629
+ cursor.execute(
630
+ """
631
+ INSERT INTO attributes (object_id, name, can_get, can_set, attr_type, size, digest, description, attrs)
632
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
633
+ """,
634
+ (
635
+ object_id,
636
+ attr_name,
637
+ 1 if attr_data.get("get") == "1" else 0,
638
+ 1 if attr_data.get("set") == "1" else 0,
639
+ attr_data.get("type"),
640
+ int(size_val) if size_val else None,
641
+ attr_data.get("digest"),
642
+ attr_data.get("description"),
643
+ self._extract_extra_attrs(attr_data, attr_exclude),
644
+ ),
645
+ )
646
+ attr_id = cursor.lastrowid
647
+
648
+ for enum in attr_data.get("enumlist", []):
649
+ cursor.execute(
650
+ """
651
+ INSERT INTO attribute_enums (attribute_id, name, digest, description, attrs)
652
+ VALUES (?, ?, ?, ?, ?)
653
+ """,
654
+ (
655
+ attr_id,
656
+ enum.get("name"),
657
+ enum.get("digest"),
658
+ enum.get("description"),
659
+ self._extract_extra_attrs(enum, enum_exclude),
660
+ ),
661
+ )
662
+
663
+ def _insert_examples(
664
+ self, cursor: sqlite3.Cursor, object_id: int, examples: List[Dict]
665
+ ) -> None:
666
+ """Insert example records."""
667
+ exclude = ["name", "caption"]
668
+ for example in examples:
669
+ cursor.execute(
670
+ """
671
+ INSERT INTO examples (object_id, name, caption, attrs)
672
+ VALUES (?, ?, ?, ?)
673
+ """,
674
+ (
675
+ object_id,
676
+ example.get("name"),
677
+ example.get("caption"),
678
+ self._extract_extra_attrs(example, exclude),
679
+ ),
680
+ )
681
+
682
+ def _insert_seealso(
683
+ self, cursor: sqlite3.Cursor, object_id: int, refs: List[str]
684
+ ) -> None:
685
+ """Insert see-also references."""
686
+ for ref in refs:
687
+ cursor.execute(
688
+ "INSERT INTO seealso (object_id, ref_name) VALUES (?, ?)",
689
+ (object_id, ref),
690
+ )
691
+
692
+ def _insert_misc(
693
+ self, cursor: sqlite3.Cursor, object_id: int, misc: Dict[str, Dict[str, str]]
694
+ ) -> None:
695
+ """Insert misc entries."""
696
+ for category, entries in misc.items():
697
+ for entry_name, description in entries.items():
698
+ cursor.execute(
699
+ """
700
+ INSERT INTO misc (object_id, category, entry_name, description)
701
+ VALUES (?, ?, ?, ?)
702
+ """,
703
+ (object_id, category, entry_name, description),
704
+ )
705
+
706
+ def _insert_palette(
707
+ self, cursor: sqlite3.Cursor, object_id: int, palette: Dict[str, Any]
708
+ ) -> None:
709
+ """Insert palette info."""
710
+ if not palette:
711
+ return
712
+ exclude = ["category", "palette_index"]
713
+ idx = palette.get("palette_index")
714
+ cursor.execute(
715
+ """
716
+ INSERT INTO palette (object_id, category, palette_index, attrs)
717
+ VALUES (?, ?, ?, ?)
718
+ """,
719
+ (
720
+ object_id,
721
+ palette.get("category"),
722
+ int(idx) if idx else None,
723
+ self._extract_extra_attrs(palette, exclude),
724
+ ),
725
+ )
726
+
727
+ def _insert_parameter(
728
+ self, cursor: sqlite3.Cursor, object_id: int, parameter: Dict[str, Any]
729
+ ) -> None:
730
+ """Insert parameter info."""
731
+ if not parameter:
732
+ return
733
+ cursor.execute(
734
+ "INSERT INTO parameter (object_id, attrs) VALUES (?, ?)",
735
+ (object_id, json.dumps(parameter)),
736
+ )
737
+
738
+ def insert_object(self, name: str, data: Dict[str, Any]) -> int:
739
+ """Insert object data into database
740
+
741
+ Args:
742
+ name: Object name
743
+ data: Object data dictionary from maxref
744
+
745
+ Returns:
746
+ Object ID
747
+ """
748
+ with self._get_cursor() as cursor:
749
+ object_id = self._insert_main_object(cursor, name, data)
750
+ self._delete_related_records(cursor, object_id)
751
+
752
+ self._insert_metadata(cursor, object_id, data.get("metadata", {}))
753
+ self._insert_inlets_outlets(
754
+ cursor, object_id, data.get("inlets", []), "inlets"
755
+ )
756
+ self._insert_inlets_outlets(
757
+ cursor, object_id, data.get("outlets", []), "outlets"
758
+ )
759
+ self._insert_objargs(cursor, object_id, data.get("objargs", []))
760
+ self._insert_methods(cursor, object_id, data.get("methods", {}))
761
+ self._insert_attributes(cursor, object_id, data.get("attributes", {}))
762
+ self._insert_examples(cursor, object_id, data.get("examples", []))
763
+ self._insert_seealso(cursor, object_id, data.get("seealso", []))
764
+ self._insert_misc(cursor, object_id, data.get("misc", {}))
765
+ self._insert_palette(cursor, object_id, data.get("palette", {}))
766
+ self._insert_parameter(cursor, object_id, data.get("parameter", {}))
767
+
768
+ return object_id
769
+
770
+ # -------------------------------------------------------------------------
771
+ # Get helpers
772
+ # -------------------------------------------------------------------------
773
+
774
+ def _get_simple_list(
775
+ self, cursor: sqlite3.Cursor, table: str, object_id: int
776
+ ) -> List[Dict[str, Any]]:
777
+ """Fetch all rows from a table as a list of dicts."""
778
+ cursor.execute(f"SELECT * FROM {table} WHERE object_id = ?", (object_id,))
779
+ return [dict(row) for row in cursor.fetchall()]
780
+
781
+ def _get_metadata(self, cursor: sqlite3.Cursor, object_id: int) -> Dict[str, Any]:
782
+ """Fetch metadata, collapsing duplicate keys into lists."""
783
+ cursor.execute(
784
+ "SELECT key, value FROM metadata WHERE object_id = ?", (object_id,)
785
+ )
786
+ metadata: Dict[str, Any] = {}
787
+ for row in cursor.fetchall():
788
+ key, value = row["key"], row["value"]
789
+ if key in metadata:
790
+ if not isinstance(metadata[key], list):
791
+ metadata[key] = [metadata[key]]
792
+ metadata[key].append(value)
793
+ else:
794
+ metadata[key] = value
795
+ return metadata
796
+
797
+ def _get_methods(
798
+ self, cursor: sqlite3.Cursor, object_id: int
799
+ ) -> Dict[str, Dict[str, Any]]:
800
+ """Fetch methods with their arguments."""
801
+ cursor.execute("SELECT * FROM methods WHERE object_id = ?", (object_id,))
802
+ methods: Dict[str, Dict[str, Any]] = {}
803
+ for method_row in cursor.fetchall():
804
+ method_data = dict(method_row)
805
+ method_id = method_data["id"]
806
+ cursor.execute(
807
+ "SELECT * FROM method_args WHERE method_id = ?", (method_id,)
808
+ )
809
+ method_data["args"] = [dict(row) for row in cursor.fetchall()]
810
+ methods[method_data["name"]] = method_data
811
+ return methods
812
+
813
+ def _get_attributes(
814
+ self, cursor: sqlite3.Cursor, object_id: int
815
+ ) -> Dict[str, Dict[str, Any]]:
816
+ """Fetch attributes with their enum values."""
817
+ cursor.execute("SELECT * FROM attributes WHERE object_id = ?", (object_id,))
818
+ attributes: Dict[str, Dict[str, Any]] = {}
819
+ for attr_row in cursor.fetchall():
820
+ attr_data = dict(attr_row)
821
+ attr_id = attr_data["id"]
822
+ cursor.execute(
823
+ "SELECT * FROM attribute_enums WHERE attribute_id = ?", (attr_id,)
824
+ )
825
+ attr_data["enumlist"] = [dict(row) for row in cursor.fetchall()]
826
+ attributes[attr_data["name"]] = attr_data
827
+ return attributes
828
+
829
+ def _get_seealso(self, cursor: sqlite3.Cursor, object_id: int) -> List[str]:
830
+ """Fetch see-also references as a list of names."""
831
+ cursor.execute("SELECT ref_name FROM seealso WHERE object_id = ?", (object_id,))
832
+ return [row["ref_name"] for row in cursor.fetchall()]
833
+
834
+ def _get_misc(
835
+ self, cursor: sqlite3.Cursor, object_id: int
836
+ ) -> Dict[str, Dict[str, str]]:
837
+ """Fetch misc entries grouped by category."""
838
+ cursor.execute("SELECT * FROM misc WHERE object_id = ?", (object_id,))
839
+ misc: Dict[str, Dict[str, str]] = {}
840
+ for row in cursor.fetchall():
841
+ category = row["category"]
842
+ if category not in misc:
843
+ misc[category] = {}
844
+ misc[category][row["entry_name"]] = row["description"]
845
+ return misc
846
+
847
+ def _get_palette(self, cursor: sqlite3.Cursor, object_id: int) -> Dict[str, Any]:
848
+ """Fetch palette info."""
849
+ cursor.execute("SELECT * FROM palette WHERE object_id = ?", (object_id,))
850
+ row = cursor.fetchone()
851
+ return dict(row) if row else {}
852
+
853
+ def _get_parameter(self, cursor: sqlite3.Cursor, object_id: int) -> Dict[str, Any]:
854
+ """Fetch parameter info."""
855
+ cursor.execute("SELECT attrs FROM parameter WHERE object_id = ?", (object_id,))
856
+ row = cursor.fetchone()
857
+ if row and row["attrs"]:
858
+ return json.loads(row["attrs"])
859
+ return {}
860
+
861
+ def get_object(self, name: str) -> Optional[Dict[str, Any]]:
862
+ """Get object data from database
863
+
864
+ Args:
865
+ name: Object name
866
+
867
+ Returns:
868
+ Object data dictionary or None if not found
869
+ """
870
+ with self._get_cursor() as cursor:
871
+ cursor.execute("SELECT * FROM objects WHERE name = ?", (name,))
872
+ row = cursor.fetchone()
873
+ if not row:
874
+ return None
875
+
876
+ data = dict(row)
877
+ object_id = data["id"]
878
+
879
+ # Parse and merge root_attrs
880
+ if data["root_attrs"]:
881
+ data.update(json.loads(data["root_attrs"]))
882
+ del data["root_attrs"]
883
+
884
+ # Fetch all related data
885
+ data["metadata"] = self._get_metadata(cursor, object_id)
886
+ data["inlets"] = self._get_simple_list(cursor, "inlets", object_id)
887
+ data["outlets"] = self._get_simple_list(cursor, "outlets", object_id)
888
+ data["objargs"] = self._get_simple_list(cursor, "objargs", object_id)
889
+ data["methods"] = self._get_methods(cursor, object_id)
890
+ data["attributes"] = self._get_attributes(cursor, object_id)
891
+ data["examples"] = self._get_simple_list(cursor, "examples", object_id)
892
+ data["seealso"] = self._get_seealso(cursor, object_id)
893
+ data["misc"] = self._get_misc(cursor, object_id)
894
+ data["palette"] = self._get_palette(cursor, object_id)
895
+ data["parameter"] = self._get_parameter(cursor, object_id)
896
+
897
+ return data
898
+
899
+ def search(self, query: str, fields: Optional[List[str]] = None) -> List[str]:
900
+ """Search for objects by name, digest, or description
901
+
902
+ Args:
903
+ query: Search query string
904
+ fields: List of fields to search in. Default: ['name', 'digest', 'description']
905
+
906
+ Returns:
907
+ List of matching object names
908
+ """
909
+ if fields is None:
910
+ fields = ["name", "digest", "description"]
911
+
912
+ with self._get_cursor() as cursor:
913
+ conditions = []
914
+ for field in fields:
915
+ if field in ["name", "digest", "description", "category"]:
916
+ conditions.append(f"{field} LIKE ?")
917
+
918
+ where_clause = " OR ".join(conditions)
919
+ sql = f"SELECT name FROM objects WHERE {where_clause} ORDER BY name"
920
+
921
+ cursor.execute(sql, tuple([f"%{query}%" for _ in conditions]))
922
+ return [row["name"] for row in cursor.fetchall()]
923
+
924
+ def search_objects(
925
+ self, query: str, fields: Optional[List[str]] = None
926
+ ) -> List[str]:
927
+ """Search for objects by name, digest, or description (deprecated: use search())
928
+
929
+ Args:
930
+ query: Search query string
931
+ fields: List of fields to search in. Default: ['name', 'digest', 'description']
932
+
933
+ Returns:
934
+ List of matching object names
935
+ """
936
+ return self.search(query, fields)
937
+
938
+ def by_category(self, category: str) -> List[str]:
939
+ """Get all objects in a category
940
+
941
+ Args:
942
+ category: Category name
943
+
944
+ Returns:
945
+ List of object names
946
+ """
947
+ with self._get_cursor() as cursor:
948
+ cursor.execute(
949
+ "SELECT name FROM objects WHERE category = ? ORDER BY name", (category,)
950
+ )
951
+ return [row["name"] for row in cursor.fetchall()]
952
+
953
+ def get_objects_by_category(self, category: str) -> List[str]:
954
+ """Get all objects in a category (deprecated: use by_category())
955
+
956
+ Args:
957
+ category: Category name
958
+
959
+ Returns:
960
+ List of object names
961
+ """
962
+ return self.by_category(category)
963
+
964
+ @property
965
+ def categories(self) -> List[str]:
966
+ """All unique categories in database"""
967
+ with self._get_cursor() as cursor:
968
+ cursor.execute(
969
+ "SELECT DISTINCT category FROM objects WHERE category IS NOT NULL ORDER BY category"
970
+ )
971
+ return [row["category"] for row in cursor.fetchall()]
972
+
973
+ def get_all_categories(self) -> List[str]:
974
+ """Get all unique categories (deprecated: use .categories property)
975
+
976
+ Returns:
977
+ List of category names
978
+ """
979
+ return self.categories
980
+
981
+ @property
982
+ def count(self) -> int:
983
+ """Total number of objects in database"""
984
+ with self._get_cursor() as cursor:
985
+ cursor.execute("SELECT COUNT(*) as count FROM objects")
986
+ return cursor.fetchone()["count"]
987
+
988
+ @property
989
+ def objects(self) -> List[str]:
990
+ """All object names in database"""
991
+ with self._get_cursor() as cursor:
992
+ cursor.execute("SELECT name FROM objects ORDER BY name")
993
+ return [row["name"] for row in cursor.fetchall()]
994
+
995
+ def get_object_count(self) -> int:
996
+ """Get total number of objects in database (deprecated: use .count property)
997
+
998
+ Returns:
999
+ Object count
1000
+ """
1001
+ return self.count
1002
+
1003
+ def export(self, output_path: Path):
1004
+ """Export entire database to JSON file
1005
+
1006
+ Args:
1007
+ output_path: Path to output JSON file
1008
+ """
1009
+ with self._get_cursor() as cursor:
1010
+ cursor.execute("SELECT name FROM objects ORDER BY name")
1011
+ all_objects = {}
1012
+
1013
+ for row in cursor.fetchall():
1014
+ name = row["name"]
1015
+ all_objects[name] = self.get_object(name)
1016
+
1017
+ output_path.write_text(json.dumps(all_objects, indent=2))
1018
+
1019
+ def export_to_json(self, output_path: Path):
1020
+ """Export entire database to JSON file (deprecated: use export())
1021
+
1022
+ Args:
1023
+ output_path: Path to output JSON file
1024
+ """
1025
+ self.export(output_path)
1026
+
1027
+ def load(self, input_path: Path):
1028
+ """Import objects from JSON file
1029
+
1030
+ Args:
1031
+ input_path: Path to input JSON file
1032
+ """
1033
+ data = json.loads(input_path.read_text())
1034
+ for name, obj_data in data.items():
1035
+ self.insert_object(name, obj_data)
1036
+
1037
+ def import_from_json(self, input_path: Path):
1038
+ """Import objects from JSON file (deprecated: use load())
1039
+
1040
+ Args:
1041
+ input_path: Path to input JSON file
1042
+ """
1043
+ self.load(input_path)
1044
+
1045
+ def summary(self) -> Dict[str, Any]:
1046
+ """Get database summary statistics
1047
+
1048
+ Returns:
1049
+ Dictionary with count, categories, and category counts
1050
+ """
1051
+ summary = {
1052
+ "total_objects": self.count,
1053
+ "categories": {},
1054
+ }
1055
+
1056
+ categories_dict: Dict[str, int] = {}
1057
+ for category in self.categories:
1058
+ categories_dict[category] = len(self.by_category(category))
1059
+ summary["categories"] = categories_dict
1060
+
1061
+ return summary
1062
+
1063
+ def __repr__(self) -> str:
1064
+ """String representation of database"""
1065
+ if self.db_path == ":memory:":
1066
+ location = "in-memory"
1067
+ else:
1068
+ location = str(self.db_path)
1069
+ return f"MaxRefDB({location}, {self.count} objects)"
1070
+
1071
+ def _auto_populate_cache(self):
1072
+ """Auto-populate cache database with all Max objects"""
1073
+ import sys
1074
+
1075
+ print("Initializing py2max cache (one-time setup)...", file=sys.stderr)
1076
+ print(f"Location: {self.db_path}", file=sys.stderr)
1077
+ print("Populating with all Max objects (1157 objects)...", file=sys.stderr)
1078
+ self.populate()
1079
+ print(f"Cache ready with {self.count} objects", file=sys.stderr)
1080
+
1081
+
1082
+ __all__ = ["MaxRefDB"]