5 Commits

30 changed files with 3663 additions and 45 deletions

5
.gitignore vendored
View File

@@ -12,3 +12,8 @@ wheels/
# Development and testing
.idea
t
# Docs
docs/_build/
docs/_static/
docs/_templates/

14
.readthedocs.yaml Normal file
View File

@@ -0,0 +1,14 @@
version: 2
build:
os: ubuntu-24.04
tools:
python: "3.13"
sphinx:
configuration: docs/conf.py
python:
install:
- path: .
- requirements: docs/requirements.txt

View File

@@ -3,32 +3,83 @@
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](LICENSE.md)
[![Commercial License](https://img.shields.io/badge/License-Commercial-green.svg)](LICENSE-COMMERCIAL.md)
[![PyPI version](https://badge.fury.io/py/logic-plugin-manager.svg)](https://pypi.org/project/logic-plugin-manager/)
[![Python Version](https://img.shields.io/badge/python-3.13+-blue.svg)](https://www.python.org/downloads/)
A utility for parsing and managing plugins in Logic Pro.
Programmatic management of Logic Pro audio plugins on macOS.
## 📦 Installation
## Overview
Logic Plugin Manager is a Python library that provides programmatic access to Logic Pro's plugin management system. It enables automated discovery, categorization, and organization of macOS Audio Unit plugins through Logic's internal tag database.
**Key Capabilities:**
- Automated plugin discovery and indexing
- Hierarchical category management
- Advanced search with fuzzy matching
- Bulk operations on plugin collections
- Programmatic metadata manipulation
## Requirements
- **Python**: 3.13 or higher
- **Operating System**: macOS
- **Dependencies**: None (core functionality), `rapidfuzz>=3.14.3` (optional, for fuzzy search)
## Installation
```bash
uv add logic-plugin-manager
pip install logic-plugin-manager
```
### With search functionality (includes rapidfuzz):
For fuzzy search functionality:
```bash
uv add logic-plugin-manager[search]
pip install logic-plugin-manager[search]
```
## 🚀 Quick Start
## Usage
```python
from logic_plugin_manager import Logic
# Initialize and discover plugins
logic = Logic()
# Access plugin collection
for plugin in logic.plugins.all():
print(f"{plugin.full_name} - {plugin.type_name.display_name}")
# Search with scoring
results = logic.plugins.search("reverb", use_fuzzy=True, max_results=10)
# Category management
category = logic.introduce_category("Production Tools")
plugin = logic.plugins.get_by_full_name("fabfilter: pro-q 3")
plugin.add_to_category(category)
```
## 📄 License
## Architecture
The library is organized into three primary modules:
- **`components`**: Audio Unit component and bundle parsing
- **`logic`**: High-level plugin management interface
- **`tags`**: Category system and tag database operations
## Documentation
Full documentation available at: https://logic-plugin-manager.readthedocs.io
## License
This project is dual-licensed:
#### 🆓 Open Source (AGPL-3.0)
Free for open source projects. See [LICENSE.md](LICENSE.md).
#### 💼 Commercial License
For closed-source/commercial use. See [COMMERCIAL.md](LICENSE-COMMERCIAL.md).
**Open Source (AGPL-3.0)**: Free for open source projects. See [LICENSE.md](LICENSE.md).
**Commercial License**: Required for closed-source or commercial applications. See [LICENSE-COMMERCIAL.md](LICENSE-COMMERCIAL.md).
Contact: h@kotikot.com
## Links
- **Repository**: https://github.com/kotikotprojects/logic-plugin-manager
- **PyPI**: https://pypi.org/project/logic-plugin-manager/
- **Documentation**: https://logic-plugin-manager.readthedocs.io

20
docs/Makefile Normal file
View File

@@ -0,0 +1,20 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = .
BUILDDIR = _build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

58
docs/conf.py Normal file
View File

@@ -0,0 +1,58 @@
# Configuration file for the Sphinx documentation builder.
#
# For the full list of built-in configuration values, see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
import os
import sys
import tomllib
sys.path.insert(0, os.path.abspath("."))
sys.path.insert(0, os.path.abspath("../."))
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
project = "logic-plugin-manager"
copyright = "2025, h"
author = "h"
release = tomllib.load(open("../pyproject.toml", "rb"))["project"]["version"]
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
extensions = [
"sphinx.ext.autodoc",
"sphinx.ext.viewcode",
"sphinx.ext.napoleon",
"sphinx_autodoc_typehints",
]
templates_path = ["_templates"]
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
autodoc_default_options = {
"members": True,
"member-order": "bysource",
"special-members": "__init__",
"undoc-members": True,
"exclude-members": "__weakref__",
"inherited-members": False,
}
autodoc_typehints = "description"
typehints_use_signature = True
typehints_use_signature_return = True
autodoc_member_order = "bysource"
# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
html_theme = "furo"
html_static_path = ["_static"]
html_title = "Logic Plugin Manager"
html_theme_options = {
"sidebar_hide_name": False,
"navigation_with_keys": True,
}

467
docs/core_concepts.rst Normal file
View File

@@ -0,0 +1,467 @@
Core Concepts
=============
This page explains the key concepts and architecture of Logic Plugin Manager.
Architecture Overview
---------------------
Logic Plugin Manager is structured around three main areas:
1. **Components**: Audio Unit plugin bundles and their metadata
2. **Logic Management**: High-level interface for plugin discovery and organization
3. **Tags & Categories**: Logic Pro's categorization system
The Library's Structure
~~~~~~~~~~~~~~~~~~~~~~~~
::
logic_plugin_manager/
├── components/ # Audio Unit components
│ ├── AudioComponent # Individual plugin representation
│ ├── Component # .component bundle parser
│ └── AudioUnitType # Audio Unit type enumeration
├── logic/ # Main management interface
│ ├── Logic # Primary entry point
│ ├── Plugins # Plugin collection with search
│ └── SearchResult # Search result with scoring
└── tags/ # Category and tag management
├── Category # Plugin category management
├── MusicApps # Database interface
├── Tagpool # Plugin count tracking
├── Properties # Category sorting
└── Tagset # Plugin tag files
Understanding Audio Components
------------------------------
Audio Component Bundle Structure
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
macOS Audio Unit plugins are distributed as ``.component`` bundles:
::
PluginName.component/
└── Contents/
├── Info.plist # Metadata and component definitions
├── MacOS/
│ └── PluginName # Executable binary
└── Resources/ # UI resources, presets, etc.
The ``Info.plist`` file contains an ``AudioComponents`` array defining one or more Audio Units within the bundle.
AudioComponent Class
~~~~~~~~~~~~~~~~~~~~
Each Audio Unit is represented by an ``AudioComponent`` instance with:
- **Identification**: Type, subtype, and manufacturer codes
- **Metadata**: Name, description, version
- **Categories**: Tags assigned in Logic Pro
- **Tags ID**: Unique identifier derived from codes
.. code-block:: python
from logic_plugin_manager import Logic
logic = Logic()
plugin = logic.plugins.get_by_name("Pro-Q 3")
# Access component information
print(f"Full Name: {plugin.full_name}")
print(f"Manufacturer: {plugin.manufacturer}")
print(f"Type Code: {plugin.type_code}")
print(f"Subtype Code: {plugin.subtype_code}")
print(f"Manufacturer Code: {plugin.manufacturer_code}")
print(f"Tags ID: {plugin.tags_id}")
Audio Unit Types
~~~~~~~~~~~~~~~~
Logic Plugin Manager recognizes five Audio Unit types:
.. list-table::
:header-rows: 1
:widths: 15 25 20 40
* - Code
- Display Name
- Alternative Name
- Description
* - ``aufx``
- Audio FX
- Effect
- Audio effect processors
* - ``aumu``
- Instrument
- Music Device
- Software instruments and synthesizers
* - ``aumf``
- MIDI-controlled Effects
- Music Effect
- Effects controlled by MIDI input
* - ``aumi``
- MIDI FX
- MIDI Generator
- MIDI processors and generators
* - ``augn``
- Generator
- Generator
- Audio generators
.. code-block:: python
from logic_plugin_manager import AudioUnitType
# Get type by code
instrument_type = AudioUnitType.from_code("aumu")
print(instrument_type.display_name) # "Instrument"
# Search for types
effect_types = AudioUnitType.search("effect")
The Logic Class
---------------
The ``Logic`` class is the main entry point for plugin management.
Initialization Process
~~~~~~~~~~~~~~~~~~~~~~
When you create a ``Logic`` instance (with ``lazy=False``, the default):
1. Loads MusicApps database (tagpool and properties files)
2. Scans ``/Library/Audio/Plug-Ins/Components`` directory
3. Parses each ``.component`` bundle's ``Info.plist``
4. Creates ``AudioComponent`` instances
5. Loads tagsets and categories for each plugin
6. Indexes plugins for fast lookups
.. code-block:: python
from logic_plugin_manager import Logic
# Full initialization
logic = Logic() # Discovers everything
# Lazy initialization (manual control)
logic = Logic(lazy=True)
logic.discover_plugins() # When ready to load plugins
logic.discover_categories() # When ready to load categories
Logic Instance Attributes
~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: python
logic = Logic()
# Access discovered data
logic.plugins # Plugins collection
logic.components # Set of Component bundles
logic.categories # Dict of category_name -> Category
logic.musicapps # MusicApps database interface
# Configuration paths
logic.components_path # Path to Components directory
logic.tags_path # Path to tags database
Plugin Collection & Search
---------------------------
The Plugins Class
~~~~~~~~~~~~~~~~~
The ``Plugins`` class provides a searchable collection with multiple indexes:
- **By full name**: Exact manufacturer + plugin name match
- **By manufacturer**: All plugins from a vendor
- **By name**: Plugin name only
- **By codes**: Type, subtype, manufacturer codes
- **By category**: All plugins in a category
- **By tags_id**: Unique component identifier
.. code-block:: python
logic = Logic()
# Exact lookups (fast)
plugin = logic.plugins.get_by_full_name("fabfilter: pro-q 3")
plugin = logic.plugins.get_by_tags_id("61756678-65517033-46466")
# Set lookups
fabfilter = logic.plugins.get_by_manufacturer("fabfilter")
effects = logic.plugins.get_by_type_code("aufx")
eq_plugins = logic.plugins.get_by_category("Effects:EQ")
Search Algorithm
~~~~~~~~~~~~~~~~
The ``search()`` method implements a sophisticated scoring system:
**Priority Levels** (highest to lowest):
1. **Tags ID exact match** (score: 1000)
2. **Name substring match** (850-900)
3. **Full name substring match** (750-800)
4. **Fuzzy name match** (650-700, requires rapidfuzz)
5. **Manufacturer match** (580-650)
6. **Category match** (480-550)
7. **Type code match** (380-450)
8. **Subtype code match** (290-350)
9. **Manufacturer code match** (190-250)
.. code-block:: python
logic = Logic()
# Fuzzy search with scoring
results = logic.plugins.search(
"serum",
use_fuzzy=True,
fuzzy_threshold=80,
max_results=10
)
for result in results:
print(f"{result.plugin.full_name}")
print(f" Score: {result.score}")
print(f" Matched: {result.match_field}")
Categories & Tags
-----------------
Hierarchical Structure
~~~~~~~~~~~~~~~~~~~~~~
Categories in Logic Pro use colon-separated hierarchies:
::
Effects
Effects:Dynamics
Effects:Dynamics:Compressor
Effects:EQ
Instruments
Instruments:Synth
.. code-block:: python
logic = Logic()
# Navigate hierarchy
dynamics = logic.categories["Effects:Dynamics"]
parent = dynamics.parent # "Effects" category
child = parent.child("EQ") # "Effects:EQ" category
# Check properties
print(dynamics.plugin_amount)
print(dynamics.is_root)
Category Operations
~~~~~~~~~~~~~~~~~~~
.. code-block:: python
logic = Logic()
# Create new category
my_cat = logic.introduce_category("Studio Essentials")
# Manage plugins
plugin = logic.plugins.get_by_name("Pro-Q 3")
plugin.add_to_category(my_cat)
plugin.remove_from_category(my_cat)
plugin.move_to_category(my_cat) # Removes from all others
# Bulk operations
plugins_set = {plugin1, plugin2, plugin3}
logic.add_plugins_to_category(my_cat, plugins_set)
# Update plugin count
logic.sync_category_plugin_amount(my_cat)
Category Sorting
~~~~~~~~~~~~~~~~
Categories have a sort order managed by the ``Properties`` database:
.. code-block:: python
category = logic.categories["Effects:EQ"]
# Check position
print(category.index)
print(category.is_first)
print(category.is_last)
prev, next = category.neighbors
# Reorder
category.move_up(steps=2)
category.move_down()
category.move_to_top()
category.move_to_bottom()
category.move_before(other_category)
category.move_after(other_category)
category.swap(other_category)
Tagsets
-------
What are Tagsets?
~~~~~~~~~~~~~~~~~
Each ``AudioComponent`` has an associated ``.tagset`` file stored at:
``~/Music/Audio Music Apps/Databases/Tags/<tags_id>.tagset``
These XML plist files store:
- **nickname**: Custom display name
- **shortname**: Abbreviated name for UI
- **tags**: Dictionary of category assignments
.. code-block:: python
logic = Logic()
plugin = logic.plugins.get_by_name("Pro-Q 3")
# Access tagset
print(plugin.tagset.nickname)
print(plugin.tagset.shortname)
print(plugin.tagset.tags) # {"Effects:EQ": "user"}
# Modify tagset
plugin.set_nickname("My Favorite EQ")
plugin.set_shortname("PQ3")
Tag Values
~~~~~~~~~~
Category tags typically have the value ``"user"`` indicating user assignment, but can have other values from Logic Pro's internal management.
MusicApps Database
------------------
Database Files
~~~~~~~~~~~~~~
Logic Pro stores category information in two files:
**MusicApps.tagpool**
Maps category names to plugin counts:
.. code-block:: python
{
"Effects": 245,
"Effects:EQ": 18,
"Instruments": 89,
...
}
**MusicApps.properties**
Stores category sort order and preferences:
.. code-block:: python
{
"sorting": ["Effects", "Effects:Dynamics", ...],
"user_sorted": "property" # or absent for alphabetical
}
Accessing the Database
~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: python
logic = Logic()
musicapps = logic.musicapps
# Access tagpool
print(musicapps.tagpool.categories)
# Access properties
print(musicapps.properties.sorting)
print(musicapps.properties.user_sorted)
# Modify database
musicapps.introduce_category("New Category")
musicapps.remove_category("Old Category")
Thread Safety
-------------
Logic Plugin Manager is **not thread-safe**. The library performs file I/O operations on Logic Pro's database files without locking mechanisms.
**Recommendations:**
- Use a single ``Logic`` instance per process
- Avoid concurrent writes to categories or tagsets
- Reload data after external changes (e.g., Logic Pro modifying categories)
Performance Considerations
--------------------------
Initial Discovery
~~~~~~~~~~~~~~~~~
Loading all plugins can take several seconds depending on the number of installed components. Use ``lazy=True`` for faster startup when you don't need immediate access.
Indexing
~~~~~~~~
The ``Plugins`` collection maintains multiple indexes. Reindexing is required if you:
- Modify plugin properties externally
- Add plugins after initialization
.. code-block:: python
logic = Logic(lazy=True)
logic.discover_plugins()
# ... modify plugins externally ...
logic.plugins.reindex_all()
Search Performance
~~~~~~~~~~~~~~~~~~
- Exact lookups by indexes are O(1)
- Fuzzy search with ``rapidfuzz`` is O(n) but optimized
- Use ``max_results`` to limit computation for large result sets
Best Practices
--------------
1. **Single Instance**: Create one ``Logic`` instance and reuse it
2. **Error Handling**: Wrap operations in try-except blocks for specific exceptions
3. **Lazy Loading**: Use for CLI tools or services that don't need immediate access
4. **Reloading**: Call ``load()`` methods after external modifications to databases
.. code-block:: python
from logic_plugin_manager import (
Logic,
CategoryValidationError,
PluginLoadError
)
try:
logic = Logic()
except PluginLoadError as e:
print(f"Failed to load plugins: {e}")
# Handle gracefully
# Batch operation example
favorites = {
logic.plugins.get_by_name(name)
for name in ["Pro-Q 3", "Serum", "Valhalla VintageVerb"]
if logic.plugins.get_by_name(name)
}
if favorites:
fav_category = logic.introduce_category("Favorites")
logic.add_plugins_to_category(fav_category, favorites)
logic.sync_category_plugin_amount(fav_category)

712
docs/examples.rst Normal file
View File

@@ -0,0 +1,712 @@
Usage Examples
==============
This page provides practical examples for common use cases.
Plugin Discovery & Inspection
------------------------------
List All Plugins
~~~~~~~~~~~~~~~~
.. code-block:: python
from logic_plugin_manager import Logic
logic = Logic()
print(f"Total plugins: {len(logic.plugins.all())}")
for plugin in logic.plugins.all():
print(f"{plugin.full_name}")
print(f" Type: {plugin.type_name.display_name}")
print(f" Version: {plugin.version}")
print(f" Categories: {', '.join(c.name for c in plugin.categories)}")
print()
Filter by Type
~~~~~~~~~~~~~~
.. code-block:: python
from logic_plugin_manager import Logic
logic = Logic()
# Get all instruments
instruments = logic.plugins.get_by_type_code("aumu")
print(f"Found {len(instruments)} instruments")
# Get all effects
effects = logic.plugins.get_by_type_code("aufx")
print(f"Found {len(effects)} effects")
Find Plugins by Manufacturer
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: python
from logic_plugin_manager import Logic
logic = Logic()
# List all manufacturers
manufacturers = set()
for plugin in logic.plugins.all():
manufacturers.add(plugin.manufacturer)
for mfr in sorted(manufacturers):
plugins = logic.plugins.get_by_manufacturer(mfr)
print(f"{mfr}: {len(plugins)} plugins")
# Get specific manufacturer's plugins
fabfilter = logic.plugins.get_by_manufacturer("fabfilter")
for plugin in fabfilter:
print(f" - {plugin.name}")
Inspect Plugin Details
~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: python
from logic_plugin_manager import Logic
logic = Logic()
plugin = logic.plugins.get_by_full_name("fabfilter: pro-q 3")
if plugin:
print(f"Full Name: {plugin.full_name}")
print(f"Manufacturer: {plugin.manufacturer}")
print(f"Name: {plugin.name}")
print(f"Description: {plugin.description}")
print(f"Type: {plugin.type_name.display_name} ({plugin.type_code})")
print(f"Subtype: {plugin.subtype_code}")
print(f"Manufacturer Code: {plugin.manufacturer_code}")
print(f"Version: {plugin.version}")
print(f"Factory Function: {plugin.factory_function}")
print(f"Tags ID: {plugin.tags_id}")
print(f"Tagset Path: {plugin.tagset.path}")
if plugin.tagset.nickname:
print(f"Nickname: {plugin.tagset.nickname}")
if plugin.tagset.shortname:
print(f"Short Name: {plugin.tagset.shortname}")
print(f"Categories: {', '.join(c.name for c in plugin.categories)}")
Search & Discovery
------------------
Simple Text Search
~~~~~~~~~~~~~~~~~~
.. code-block:: python
from logic_plugin_manager import Logic
logic = Logic()
# Simple substring search
reverb_plugins = logic.plugins.search_simple("reverb")
print(f"Found {len(reverb_plugins)} plugins with 'reverb' in name")
for plugin in reverb_plugins:
print(f" - {plugin.full_name}")
Advanced Fuzzy Search
~~~~~~~~~~~~~~~~~~~~~
.. code-block:: python
from logic_plugin_manager import Logic
logic = Logic()
# Fuzzy search with scoring
results = logic.plugins.search(
query="compressor",
use_fuzzy=True,
fuzzy_threshold=80,
max_results=10
)
for i, result in enumerate(results, 1):
print(f"{i}. {result.plugin.full_name}")
print(f" Score: {result.score:.1f}")
print(f" Matched field: {result.match_field}")
print()
Search by Multiple Criteria
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: python
from logic_plugin_manager import Logic
logic = Logic()
def find_plugins(search_term, plugin_type=None, category=None):
"""Search plugins with optional filters."""
results = logic.plugins.search(search_term, use_fuzzy=True)
plugins = [r.plugin for r in results]
# Filter by type if specified
if plugin_type:
plugins = [p for p in plugins if p.type_code == plugin_type]
# Filter by category if specified
if category:
plugins = [
p for p in plugins
if any(c.name == category for c in p.categories)
]
return plugins
# Find reverb effects (not instruments)
reverbs = find_plugins("reverb", plugin_type="aufx")
# Find synthesizers in Instruments category
synths = find_plugins("synth", plugin_type="aumu", category="Instruments")
Category Management
-------------------
List All Categories
~~~~~~~~~~~~~~~~~~~
.. code-block:: python
from logic_plugin_manager import Logic
logic = Logic()
print("Categories:")
for name, category in sorted(logic.categories.items()):
print(f" {name} ({category.plugin_amount} plugins)")
Create Category Hierarchy
~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: python
from logic_plugin_manager import Logic
logic = Logic()
# Create nested categories
categories_to_create = [
"My Plugins",
"My Plugins:Favorites",
"My Plugins:Favorites:Mixing",
"My Plugins:Favorites:Mastering",
]
for cat_name in categories_to_create:
if cat_name not in logic.categories:
category = logic.introduce_category(cat_name)
print(f"Created: {cat_name}")
else:
print(f"Already exists: {cat_name}")
Add Plugins to Category
~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: python
from logic_plugin_manager import Logic
logic = Logic()
# Create or get category
favorites = logic.categories.get("Favorites")
if not favorites:
favorites = logic.introduce_category("Favorites")
# Add single plugin
plugin = logic.plugins.get_by_full_name("fabfilter: pro-q 3")
if plugin:
plugin.add_to_category(favorites)
print(f"Added {plugin.full_name} to {favorites.name}")
# Update category count
logic.sync_category_plugin_amount(favorites)
Bulk Category Assignment
~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: python
from logic_plugin_manager import Logic
logic = Logic()
# Find all FabFilter plugins
fabfilter_plugins = logic.plugins.get_by_manufacturer("fabfilter")
# Create category
fabfilter_cat = logic.categories.get("FabFilter")
if not fabfilter_cat:
fabfilter_cat = logic.introduce_category("FabFilter")
# Bulk add
logic.add_plugins_to_category(fabfilter_cat, fabfilter_plugins)
print(f"Added {len(fabfilter_plugins)} FabFilter plugins")
Move Plugins Between Categories
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: python
from logic_plugin_manager import Logic
logic = Logic()
# Get plugins from one category
dynamics_plugins = logic.plugins.get_by_category("Effects:Dynamics")
# Filter for compressors
compressors = {
p for p in dynamics_plugins
if "compress" in p.name.lower()
}
# Move to specific category
comp_category = logic.categories.get("Effects:Dynamics:Compressor")
if comp_category:
logic.move_plugins_to_category(comp_category, compressors)
logic.sync_category_plugin_amount(comp_category)
Organize Uncategorized Plugins
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: python
from logic_plugin_manager import Logic
logic = Logic()
# Get plugins without categories
uncategorized = logic.plugins.get_by_category(None)
print(f"Found {len(uncategorized)} uncategorized plugins")
# Auto-categorize by manufacturer
for plugin in uncategorized:
manufacturer = plugin.manufacturer.strip()
category_name = f"By Manufacturer:{manufacturer}"
# Create category if needed
if category_name not in logic.categories:
logic.introduce_category(category_name)
category = logic.categories[category_name]
plugin.add_to_category(category)
# Update all category counts
logic.sync_all_categories_plugin_amount()
Category Sorting
~~~~~~~~~~~~~~~~
.. code-block:: python
from logic_plugin_manager import Logic
logic = Logic()
# Get category
category = logic.categories["Effects:EQ"]
# Check current position
print(f"Current index: {category.index}")
prev, next_cat = category.neighbors
if prev:
print(f"Previous: {prev.name}")
if next_cat:
print(f"Next: {next_cat.name}")
# Move category
category.move_to_top()
category.move_up(steps=5)
category.move_down()
# Move relative to another category
target = logic.categories["Effects:Delay"]
category.move_before(target)
Custom Plugin Metadata
----------------------
Set Nicknames
~~~~~~~~~~~~~
.. code-block:: python
from logic_plugin_manager import Logic
logic = Logic()
# Set custom nicknames for easier identification
plugins_to_rename = {
"fabfilter: pro-q 3": "PQ3 - Main EQ",
"fabfilter: pro-c 2": "PC2 - Main Compressor",
"valhalla shimmer": "Shimmer - Ambient Verb",
}
for full_name, nickname in plugins_to_rename.items():
plugin = logic.plugins.get_by_full_name(full_name)
if plugin:
plugin.set_nickname(nickname)
print(f"Renamed: {full_name} -> {nickname}")
Set Short Names
~~~~~~~~~~~~~~~
.. code-block:: python
from logic_plugin_manager import Logic
logic = Logic()
# Set short names for UI display
shortnames = {
"fabfilter: pro-q 3": "PQ3",
"fabfilter: pro-c 2": "PC2",
"serum": "SRM",
}
for full_name, shortname in shortnames.items():
plugin = logic.plugins.get_by_full_name(full_name)
if plugin:
plugin.set_shortname(shortname)
Batch Metadata Updates
~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: python
from logic_plugin_manager import Logic
logic = Logic()
# Add manufacturer prefix to all plugin nicknames
for plugin in logic.plugins.all():
if not plugin.tagset.nickname:
manufacturer = plugin.manufacturer.upper()
nickname = f"[{manufacturer}] {plugin.name}"
plugin.set_nickname(nickname)
print(f"Set nickname for {plugin.full_name}")
Advanced Operations
-------------------
Export Plugin Inventory
~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: python
import csv
from logic_plugin_manager import Logic
logic = Logic()
# Export to CSV
with open("plugin_inventory.csv", "w", newline="") as f:
writer = csv.writer(f)
writer.writerow([
"Full Name", "Manufacturer", "Type", "Version",
"Categories", "Subtype Code", "Tags ID"
])
for plugin in sorted(logic.plugins.all(), key=lambda p: p.full_name):
writer.writerow([
plugin.full_name,
plugin.manufacturer,
plugin.type_name.display_name,
plugin.version,
"; ".join(c.name for c in plugin.categories),
plugin.subtype_code,
plugin.tags_id,
])
print("Exported plugin inventory to plugin_inventory.csv")
Clone Category Structure
~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: python
from logic_plugin_manager import Logic
def clone_category(source: str, target: str, logic: Logic):
"""Clone plugins from source category to target category."""
# Get plugins in source
source_plugins = logic.plugins.get_by_category(source)
# Create target if needed
if target not in logic.categories:
logic.introduce_category(target)
target_category = logic.categories[target]
# Add plugins to target
logic.add_plugins_to_category(target_category, source_plugins)
print(f"Cloned {len(source_plugins)} plugins from {source} to {target}")
logic = Logic()
clone_category("Effects:EQ", "My Plugins:EQ", logic)
Find Duplicate Plugins
~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: python
from collections import defaultdict
from logic_plugin_manager import Logic
logic = Logic()
# Group by name (ignoring manufacturer)
by_name = defaultdict(list)
for plugin in logic.plugins.all():
by_name[plugin.name.lower()].append(plugin)
# Find duplicates
duplicates = {
name: plugins
for name, plugins in by_name.items()
if len(plugins) > 1
}
print(f"Found {len(duplicates)} plugin names with multiple versions:\n")
for name, plugins in sorted(duplicates.items()):
print(f"{name}:")
for plugin in plugins:
print(f" - {plugin.full_name} (v{plugin.version})")
print()
Backup and Restore Categories
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: python
import json
from pathlib import Path
from logic_plugin_manager import Logic
def backup_categories(output_file: Path):
"""Backup all plugin category assignments."""
logic = Logic()
backup_data = {}
for plugin in logic.plugins.all():
backup_data[plugin.tags_id] = {
"full_name": plugin.full_name,
"categories": [c.name for c in plugin.categories],
}
with open(output_file, "w") as f:
json.dump(backup_data, f, indent=2)
print(f"Backed up {len(backup_data)} plugin assignments")
def restore_categories(backup_file: Path):
"""Restore plugin category assignments from backup."""
logic = Logic()
with open(backup_file) as f:
backup_data = json.load(f)
restored = 0
for tags_id, data in backup_data.items():
plugin = logic.plugins.get_by_tags_id(tags_id)
if plugin:
# Restore categories
categories = [
logic.categories[name]
for name in data["categories"]
if name in logic.categories
]
if categories:
plugin.set_categories(categories)
restored += 1
print(f"Restored {restored} plugin assignments")
# Usage
backup_categories(Path("categories_backup.json"))
# restore_categories(Path("categories_backup.json"))
Generate Category Report
~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: python
from logic_plugin_manager import Logic
logic = Logic()
print("=" * 60)
print("CATEGORY REPORT")
print("=" * 60)
print()
# Sort categories by hierarchy
sorted_categories = sorted(logic.categories.items())
for name, category in sorted_categories:
# Calculate depth based on colons
depth = name.count(":")
indent = " " * depth
# Get plugins in this exact category
plugins = logic.plugins.get_by_category(name)
print(f"{indent}├─ {name.split(':')[-1]}")
print(f"{indent}│ Count: {category.plugin_amount}")
print(f"{indent}│ Index: {category.index}")
if plugins:
print(f"{indent}│ Plugins:")
for plugin in sorted(plugins, key=lambda p: p.full_name)[:5]:
print(f"{indent}│ - {plugin.full_name}")
if len(plugins) > 5:
print(f"{indent}│ ... and {len(plugins) - 5} more")
print()
Working with Component Bundles
-------------------------------
Inspect Component Bundles
~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: python
from logic_plugin_manager import Logic
logic = Logic()
print(f"Total component bundles: {len(logic.components)}\n")
for component in logic.components:
print(f"Bundle: {component.name}")
print(f" ID: {component.bundle_id}")
print(f" Version: {component.version} ({component.short_version})")
print(f" Audio Components: {len(component.audio_components)}")
for audio_comp in component.audio_components:
print(f" - {audio_comp.full_name}")
print()
Find Multi-Plugin Bundles
~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: python
from logic_plugin_manager import Logic
logic = Logic()
# Find bundles with multiple plugins
multi_plugin_bundles = [
comp for comp in logic.components
if len(comp.audio_components) > 1
]
print(f"Found {len(multi_plugin_bundles)} bundles with multiple plugins:\n")
for component in multi_plugin_bundles:
print(f"{component.name} - {len(component.audio_components)} plugins:")
for plugin in component.audio_components:
print(f" - {plugin.name} ({plugin.type_name.display_name})")
print()
Error Handling Examples
-----------------------
Robust Plugin Search
~~~~~~~~~~~~~~~~~~~~
.. code-block:: python
from logic_plugin_manager import Logic, PluginLoadError
def safe_find_plugin(plugin_name: str) -> None:
"""Safely search for a plugin with error handling."""
try:
logic = Logic()
except PluginLoadError as e:
print(f"Error loading plugins: {e}")
return
plugin = logic.plugins.get_by_full_name(plugin_name)
if plugin:
print(f"Found: {plugin.full_name}")
print(f"Type: {plugin.type_name.display_name}")
# Try to load tagset
try:
print(f"Categories: {', '.join(c.name for c in plugin.categories)}")
except Exception as e:
print(f"Could not load categories: {e}")
else:
print(f"Plugin '{plugin_name}' not found")
# Try fuzzy search
results = logic.plugins.search(plugin_name)
if results:
print(f"\nDid you mean:")
for result in results[:3]:
print(f" - {result.plugin.full_name}")
safe_find_plugin("fabfilter: pro-q 3")
Graceful Category Operations
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: python
from logic_plugin_manager import (
Logic,
CategoryValidationError,
MusicAppsLoadError
)
def safe_add_to_category(plugin_name: str, category_name: str):
"""Add plugin to category with error handling."""
try:
logic = Logic()
except MusicAppsLoadError as e:
print(f"Database error: {e}")
return
# Find plugin
plugin = logic.plugins.get_by_full_name(plugin_name)
if not plugin:
print(f"Plugin '{plugin_name}' not found")
return
# Get or create category
try:
category = logic.categories[category_name]
except KeyError:
try:
category = logic.introduce_category(category_name)
print(f"Created new category: {category_name}")
except Exception as e:
print(f"Could not create category: {e}")
return
# Add to category
try:
plugin.add_to_category(category)
logic.sync_category_plugin_amount(category)
print(f"Added {plugin.full_name} to {category_name}")
except Exception as e:
print(f"Error adding to category: {e}")
safe_add_to_category("fabfilter: pro-q 3", "My Favorites")

240
docs/getting_started.rst Normal file
View File

@@ -0,0 +1,240 @@
Getting Started
===============
This guide will help you get started with Logic Plugin Manager, a Python library for programmatically managing Logic Pro's audio plugins.
Installation
------------
Basic Installation
~~~~~~~~~~~~~~~~~~
Install the package using pip or uv:
.. code-block:: bash
pip install logic-plugin-manager
Or with uv:
.. code-block:: bash
uv add logic-plugin-manager
With Search Functionality
~~~~~~~~~~~~~~~~~~~~~~~~~~
For fuzzy search capabilities, install with the ``search`` extra:
.. code-block:: bash
pip install logic-plugin-manager[search]
# or
uv add logic-plugin-manager[search]
This includes the ``rapidfuzz`` dependency for advanced plugin searching.
Requirements
------------
- **Python**: 3.13 or higher
- **Operating System**: macOS only (Logic Pro specific)
- **Logic Pro**: Installed with audio plugins
The library accesses:
- Audio Components directory: ``/Library/Audio/Plug-Ins/Components``
- Tags database: ``~/Music/Audio Music Apps/Databases/Tags``
Quick Start
-----------
Basic Usage
~~~~~~~~~~~
The simplest way to start is by creating a ``Logic`` instance:
.. code-block:: python
from logic_plugin_manager import Logic
# Initialize and discover all plugins
logic = Logic()
# Access all plugins
for plugin in logic.plugins.all():
print(f"{plugin.full_name} - {plugin.type_name.display_name}")
# Access categories
for category_name, category in logic.categories.items():
print(f"{category_name}: {category.plugin_amount} plugins")
Lazy Loading
~~~~~~~~~~~~
For faster initialization when you don't need immediate access to all plugins:
.. code-block:: python
from logic_plugin_manager import Logic
# Initialize without loading plugins
logic = Logic(lazy=True)
# Manually discover plugins when needed
logic.discover_plugins()
logic.discover_categories()
Searching Plugins
~~~~~~~~~~~~~~~~~
Search for plugins by name, manufacturer, or category:
.. code-block:: python
from logic_plugin_manager import Logic
logic = Logic()
# Simple substring search
results = logic.plugins.search_simple("reverb")
# Advanced fuzzy search with scoring
results = logic.plugins.search("serum", use_fuzzy=True)
for result in results[:5]: # Top 5 results
print(f"{result.plugin.full_name} (score: {result.score})")
Working with Categories
~~~~~~~~~~~~~~~~~~~~~~~
Organize plugins into categories:
.. code-block:: python
from logic_plugin_manager import Logic
logic = Logic()
# Get plugins in a specific category
effects = logic.plugins.get_by_category("Effects")
# Get or create a category
my_category = logic.categories.get("My Favorites")
if not my_category:
my_category = logic.introduce_category("My Favorites")
# Add plugin to category
plugin = logic.plugins.get_by_full_name("fabfilter: pro-q 3")
if plugin:
plugin.add_to_category(my_category)
Custom Paths
~~~~~~~~~~~~
If your Logic Pro or components are in non-standard locations:
.. code-block:: python
from pathlib import Path
from logic_plugin_manager import Logic
logic = Logic(
components_path=Path("/custom/path/to/Components"),
tags_path=Path("~/custom/path/to/Tags").expanduser()
)
Next Steps
----------
- Learn about :doc:`core_concepts` to understand the library's architecture
- Explore :doc:`examples` for common use cases
- Check the :doc:`logic_plugin_manager` for detailed API reference
Common Patterns
---------------
Finding a Specific Plugin
~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: python
from logic_plugin_manager import Logic
logic = Logic()
# By full name (exact match)
plugin = logic.plugins.get_by_full_name("apple: logic eq")
# By manufacturer
fabfilter_plugins = logic.plugins.get_by_manufacturer("fabfilter")
# By audio unit type
instruments = logic.plugins.get_by_type_code("aumu")
Batch Category Operations
~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: python
from logic_plugin_manager import Logic
logic = Logic()
# Get all synthesizer plugins
synths = logic.plugins.search("synth", use_fuzzy=True)
synth_plugins = {result.plugin for result in synths[:20]}
# Move them to a custom category
synth_category = logic.introduce_category("Synthesizers")
logic.move_plugins_to_category(synth_category, synth_plugins)
Working with Plugin Metadata
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: python
from logic_plugin_manager import Logic
logic = Logic()
plugin = logic.plugins.get_by_name("Pro-Q 3")
if plugin:
# Access metadata
print(f"Manufacturer: {plugin.manufacturer}")
print(f"Type: {plugin.type_name.display_name}")
print(f"Version: {plugin.version}")
print(f"Categories: {[c.name for c in plugin.categories]}")
# Set custom nickname
plugin.set_nickname("My Favorite EQ")
# Set short name for UI display
plugin.set_shortname("PQ3")
Error Handling
--------------
The library raises specific exceptions for different error conditions:
.. code-block:: python
from logic_plugin_manager import (
Logic,
PluginLoadError,
MusicAppsLoadError,
CategoryValidationError
)
try:
logic = Logic()
except MusicAppsLoadError as e:
print(f"Could not load Logic's database: {e}")
except PluginLoadError as e:
print(f"Error loading plugins: {e}")
try:
category = logic.categories["Nonexistent"]
except KeyError:
print("Category not found")
See :doc:`logic_plugin_manager` for all exception types.

91
docs/index.rst Normal file
View File

@@ -0,0 +1,91 @@
Logic Plugin Manager
====================
**Programmatic management of Logic Pro audio plugins**
Logic Plugin Manager is a Python library for discovering, organizing, and managing macOS Audio Unit plugins used by Logic Pro. It provides programmatic access to Logic's internal tag database, enabling automated plugin organization, bulk categorization, and advanced search capabilities.
----
Features
--------
- **Plugin Discovery**: Automatically scan and index all installed Audio Unit plugins
- **Category Management**: Create, modify, and organize plugin categories programmatically
- **Advanced Search**: Fuzzy search with scoring across multiple attributes
- **Bulk Operations**: Efficiently manage large plugin collections
- **Metadata Control**: Set custom nicknames, short names, and categories
- **Type-Safe**: Fully typed Python API with comprehensive documentation
Quick Example
-------------
.. code-block:: python
from logic_plugin_manager import Logic
# Initialize and discover all plugins
logic = Logic()
# Search for plugins
results = logic.plugins.search("reverb", use_fuzzy=True)
for result in results[:5]:
print(f"{result.plugin.full_name} (score: {result.score})")
# Organize into categories
favorites = logic.introduce_category("Favorites")
plugin = logic.plugins.get_by_full_name("fabfilter: pro-q 3")
plugin.add_to_category(favorites)
Installation
------------
.. code-block:: bash
pip install logic-plugin-manager
# With search functionality
pip install logic-plugin-manager[search]
**Requirements**: Python 3.13+, macOS, Logic Pro
----
Documentation
=============
.. toctree::
:maxdepth: 2
:caption: User Guide
getting_started
core_concepts
examples
.. toctree::
:maxdepth: 2
:caption: API Reference
logic_plugin_manager
----
Indices
=======
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`
----
License
=======
This project is dual-licensed:
- **Open Source (AGPL-3.0)**: Free for open source projects
- **Commercial License**: Available for closed-source/commercial use
Contact: h@kotikot.com

View File

@@ -0,0 +1,20 @@
Components
==========
The components package provides classes for working with macOS Audio Unit components and bundles.
AudioComponent
--------------
.. automodule:: logic_plugin_manager.components.audiocomponent
:members:
:show-inheritance:
:undoc-members:
Component
---------
.. automodule:: logic_plugin_manager.components.component
:members:
:show-inheritance:
:undoc-members:

View File

@@ -0,0 +1,20 @@
Logic Management
================
The logic package provides the main interface for plugin management and search functionality.
Logic Class
-----------
.. automodule:: logic_plugin_manager.logic.logic
:members:
:show-inheritance:
:undoc-members:
Plugins Collection
------------------
.. automodule:: logic_plugin_manager.logic.plugins
:members:
:show-inheritance:
:undoc-members:

View File

@@ -0,0 +1,28 @@
API Reference
=============
Core Modules
------------
.. toctree::
:maxdepth: 2
logic_plugin_manager.components
logic_plugin_manager.logic
logic_plugin_manager.tags
Defaults
--------
.. automodule:: logic_plugin_manager.defaults
:members:
:show-inheritance:
:undoc-members:
Exceptions
----------
.. automodule:: logic_plugin_manager.exceptions
:members:
:show-inheritance:
:undoc-members:

View File

@@ -0,0 +1,28 @@
Tags & Categories
=================
The tags package manages Logic Pro's category system and tag database.
Category Management
-------------------
.. automodule:: logic_plugin_manager.tags.category
:members:
:show-inheritance:
:undoc-members:
MusicApps Database
------------------
.. automodule:: logic_plugin_manager.tags.musicapps
:members:
:show-inheritance:
:undoc-members:
Tagset Files
------------
.. automodule:: logic_plugin_manager.tags.tagset
:members:
:show-inheritance:
:undoc-members:

35
docs/make.bat Normal file
View File

@@ -0,0 +1,35 @@
@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=.
set BUILDDIR=_build
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.https://www.sphinx-doc.org/
exit /b 1
)
if "%1" == "" goto help
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
:end
popd

3
docs/requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
sphinx
furo
sphinx-autodoc-typehints

View File

@@ -1,6 +1,6 @@
[project]
name = "logic-plugin-manager"
version = "0.0.1"
version = "1.0.0"
description = "A utility for parsing and managing plugins in Logic Pro"
readme = "README.md"
authors = [
@@ -47,6 +47,9 @@ build-backend = "uv_build"
dev = [
"rich>=14.2.0",
"rapidfuzz>=3.14.3",
"sphinx>=8.2.3",
"sphinx-autodoc-typehints>=3.5.2",
"furo>=2025.9.25",
]
[tool.isort]

View File

@@ -1,11 +1,36 @@
"""Logic Plugin Manager - Programmatic management of Logic Pro audio plugins.
This library provides tools for discovering, categorizing, and managing
macOS Audio Unit plugins used by Logic Pro. It interfaces with Logic's
internal tag database to enable automated plugin organization.
Main Classes:
Logic: Primary interface for plugin discovery and management.
AudioComponent: Represents a single Audio Unit plugin.
Component: Represents a .component bundle.
Category: Represents a Logic Pro plugin category.
Plugins: Collection with indexed search capabilities.
Example:
>>> from logic_plugin_manager import Logic
>>> logic = Logic()
>>> for plugin in logic.plugins.all():
... print(plugin.full_name)
"""
import logging
from .components import AudioComponent, AudioUnitType, Component
from .exceptions import MusicAppsLoadError, PluginLoadError, TagsetLoadError
from .logic import Logic, Plugins, SearchResult
from .tags import MusicApps, Properties, Tagpool, Tagset
from .tags import Category, MusicApps, Properties, Tagpool, Tagset
logging.getLogger(__name__).addHandler(logging.NullHandler())
__all__ = [
"AudioComponent",
"AudioUnitType",
"Category",
"Component",
"Logic",
"MusicApps",

View File

@@ -1,3 +1,9 @@
"""Components package for Audio Unit management.
This package provides classes for working with macOS Audio Component bundles
and their associated Audio Units.
"""
from .audiocomponent import AudioComponent, AudioUnitType
from .component import Component

View File

@@ -1,13 +1,27 @@
from dataclasses import dataclass
"""Audio Component representation and management.
This module provides classes for working with macOS Audio Unit components,
including parsing component metadata and managing their tags and categories.
"""
import logging
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from .. import defaults
from ..exceptions import CannotParseComponentError
from ..tags import Tagset
from ..tags import Category, MusicApps, Tagset
logger = logging.getLogger(__name__)
class AudioUnitType(Enum):
"""Enumeration of Audio Unit types supported by macOS.
Each enum value contains a tuple of (code, display_name, alt_name).
"""
AUFX = ("aufx", "Audio FX", "Effect")
AUMU = ("aumu", "Instrument", "Music Device")
AUMF = ("aumf", "MIDI-controlled Effects", "Music Effect")
@@ -16,18 +30,41 @@ class AudioUnitType(Enum):
@property
def code(self) -> str:
"""Get the four-character code for this Audio Unit type.
Returns:
str: Four-character type code (e.g., 'aufx', 'aumu').
"""
return self.value[0]
@property
def display_name(self) -> str:
"""Get the human-readable display name for this Audio Unit type.
Returns:
str: Display name (e.g., 'Audio FX', 'Instrument').
"""
return self.value[1]
@property
def alt_name(self) -> str:
"""Get the alternative name for this Audio Unit type.
Returns:
str: Alternative name (e.g., 'Effect', 'Music Device').
"""
return self.value[2]
@classmethod
def from_code(cls, code: str) -> "AudioUnitType | None":
"""Find an AudioUnitType by its four-character code.
Args:
code: Four-character type code (case-insensitive).
Returns:
AudioUnitType | None: Matching AudioUnitType or None if not found.
"""
code_lower = code.lower()
for unit_type in cls:
if unit_type.code == code_lower:
@@ -36,6 +73,16 @@ class AudioUnitType(Enum):
@classmethod
def search(cls, query: str) -> list["AudioUnitType"]:
"""Search for AudioUnitTypes matching a query string.
Searches across code, display_name, and alt_name fields.
Args:
query: Search query string (case-insensitive).
Returns:
list[AudioUnitType]: List of matching AudioUnitType values.
"""
query_lower = query.lower()
results = []
for unit_type in cls:
@@ -50,6 +97,27 @@ class AudioUnitType(Enum):
@dataclass
class AudioComponent:
"""Represents a single Audio Unit component.
An AudioComponent encapsulates metadata about an Audio Unit plugin,
including its type, manufacturer, version, and associated tags/categories.
Attributes:
full_name: Full name in format 'Manufacturer: Plugin Name'.
manufacturer: Manufacturer/vendor name.
name: Plugin name (without manufacturer prefix).
manufacturer_code: Four-character manufacturer code.
description: Plugin description text.
factory_function: Name of the factory function.
type_code: Four-character Audio Unit type code.
type_name: AudioUnitType enum value.
subtype_code: Four-character subtype code.
version: Plugin version number.
tags_id: Unique identifier for tagset lookup.
tagset: Associated Tagset containing tags and metadata.
categories: List of Category objects this plugin belongs to.
"""
full_name: str
manufacturer: str
name: str
@@ -62,12 +130,32 @@ class AudioComponent:
version: int
tags_id: str
tagset: Tagset
categories: list[Category] = field(default_factory=list)
def __init__(
self, data: dict, *, lazy: bool = False, tags_path: Path = defaults.tags_path
self,
data: dict,
*,
lazy: bool = False,
tags_path: Path = defaults.tags_path,
musicapps: MusicApps = None,
):
"""Initialize an AudioComponent from component data dictionary.
Args:
data: Dictionary containing component metadata from Info.plist.
lazy: If True, defer loading tagset and categories until needed.
tags_path: Path to tags database directory.
musicapps: Shared MusicApps instance for category management.
Raises:
CannotParseComponentError: If required fields are missing or malformed.
This can wrap KeyError, IndexError, AttributeError, UnicodeEncodeError,
or ValueError from data extraction operations.
"""
self.tags_path = tags_path
self.lazy = lazy
self.musicapps = musicapps or MusicApps(tags_path=self.tags_path, lazy=lazy)
try:
self.full_name = data.get("name")
@@ -85,23 +173,196 @@ class AudioComponent:
f"{self.subtype_code.encode('ascii').hex()}-"
f"{self.manufacturer_code.encode('ascii').hex()}"
)
logger.debug(f"Created AudioComponent {self.full_name} from data")
except Exception as e:
raise CannotParseComponentError(f"An error occurred while parsing: {e}")
raise CannotParseComponentError(
f"An error occurred while parsing: {e}"
) from e
if not lazy:
self.load()
def load(self) -> "AudioComponent":
"""Load tagset and categories for this component.
Loads the component's tagset from disk and initializes Category objects
for all tags. Invalid categories are logged as warnings and skipped.
Returns:
AudioComponent: Self for method chaining.
Raises:
NonexistentTagsetError: If tagset file doesn't exist (from Tagset).
CannotParseTagsetError: If tagset file cannot be parsed (from Tagset).
MusicAppsLoadError: If MusicApps database files cannot be loaded (from Category).
"""
logger.debug(f"Loading AudioComponent {self.full_name}")
self.tagset = Tagset(self.tags_path / self.tags_id, lazy=self.lazy)
logger.debug(f"Loaded Tagset for {self.full_name}")
self.categories = []
for name in self.tagset.tags.keys():
try:
logger.debug(f"Loading category {name} for {self.full_name}")
self.categories.append(
Category(name, musicapps=self.musicapps, lazy=self.lazy)
)
except Exception as e:
logger.warning(
f"Failed to load category {name} for {self.full_name}: {e}"
)
logger.debug(f"Loaded {len(self.categories)} categories for {self.full_name}")
return self
def __eq__(self, other):
def __eq__(self, other) -> bool:
"""Check equality based on tags_id.
Args:
other: Object to compare with.
Returns:
bool: True if both have the same tags_id, NotImplemented otherwise.
"""
if not isinstance(other, AudioComponent):
return NotImplemented
return self.tags_id == other.tags_id
def __hash__(self):
"""Return hash based on tags_id for use in sets and dicts.
Returns:
int: Hash value.
"""
return hash(self.tags_id)
def set_nickname(self, nickname: str) -> "AudioComponent":
"""Set a custom nickname for this component.
Args:
nickname: Custom nickname string.
Returns:
AudioComponent: Self for method chaining.
Raises:
NonexistentTagsetError: If tagset file doesn't exist (from Tagset.set_nickname).
CannotParseTagsetError: If tagset file cannot be parsed (from Tagset.set_nickname).
TagsetWriteError: If writing tagset fails (from Tagset.set_nickname).
"""
self.tagset.set_nickname(nickname)
self.load()
return self
def set_shortname(self, shortname: str) -> "AudioComponent":
"""Set a custom short name for this component.
Args:
shortname: Custom short name string.
Returns:
AudioComponent: Self for method chaining.
Raises:
NonexistentTagsetError: If tagset file doesn't exist (from Tagset.set_shortname).
CannotParseTagsetError: If tagset file cannot be parsed (from Tagset.set_shortname).
TagsetWriteError: If writing tagset fails (from Tagset.set_shortname).
"""
self.tagset.set_shortname(shortname)
self.load()
return self
def set_categories(self, categories: list[Category]) -> "AudioComponent":
"""Replace all categories with the provided list.
Args:
categories: List of Category objects to assign.
Returns:
AudioComponent: Self for method chaining.
Raises:
NonexistentTagsetError: If tagset file doesn't exist (from Tagset.set_tags).
CannotParseTagsetError: If tagset file cannot be parsed (from Tagset.set_tags).
TagsetWriteError: If writing tagset fails (from Tagset.set_tags).
"""
self.tagset.set_tags({category.name: "user" for category in categories})
self.load()
return self
def add_to_category(self, category: Category) -> "AudioComponent":
"""Add this component to a category.
Args:
category: Category to add this component to.
Returns:
AudioComponent: Self for method chaining.
Raises:
NonexistentTagsetError: If tagset file doesn't exist (from Tagset.add_tag).
CannotParseTagsetError: If tagset file cannot be parsed (from Tagset.add_tag).
TagsetWriteError: If writing tagset fails (from Tagset.add_tag).
"""
self.tagset.add_tag(category.name, "user")
self.load()
return self
def remove_from_category(self, category: Category) -> "AudioComponent":
"""Remove this component from a category.
Args:
category: Category to remove this component from.
Returns:
AudioComponent: Self for method chaining.
Raises:
NonexistentTagsetError: If tagset file doesn't exist (from Tagset.remove_tag).
CannotParseTagsetError: If tagset file cannot be parsed (from Tagset.remove_tag).
KeyError: If tag doesn't exist (from Tagset.remove_tag).
TagsetWriteError: If writing tagset fails (from Tagset.remove_tag).
"""
self.tagset.remove_tag(category.name)
self.load()
return self
def move_to_category(self, category: Category) -> "AudioComponent":
"""Move this component to a single category, removing all others.
Args:
category: Category to move this component to exclusively.
Returns:
AudioComponent: Self for method chaining.
Raises:
NonexistentTagsetError: If tagset file doesn't exist (from Tagset.move_to_tag).
CannotParseTagsetError: If tagset file cannot be parsed (from Tagset.move_to_tag).
TagsetWriteError: If writing tagset fails (from Tagset.move_to_tag).
"""
self.tagset.move_to_tag(category.name, "user")
self.load()
return self
def move_to_parents(self) -> "AudioComponent":
"""Move this component to the parent categories of all current categories.
For each category this component belongs to, adds it to the parent category
and removes it from the child category.
Returns:
AudioComponent: Self for method chaining.
Raises:
NonexistentTagsetError: If tagset file doesn't exist (from Tagset operations).
CannotParseTagsetError: If tagset file cannot be parsed (from Tagset operations).
TagsetWriteError: If writing tagset fails (from Tagset operations).
KeyError: If a category tag doesn't exist during removal (from Tagset.remove_tag).
"""
for category in self.categories:
self.tagset.add_tag(category.parent.name, "user")
self.tagset.remove_tag(category.name)
self.load()
return self
__all__ = ["AudioComponent", "AudioUnitType"]

View File

@@ -1,3 +1,10 @@
"""Component bundle representation and parsing.
This module provides the Component class for loading and parsing macOS
Audio Component bundles (.component directories) and their Info.plist files.
"""
import logging
import plistlib
from dataclasses import dataclass
from pathlib import Path
@@ -9,11 +16,28 @@ from ..exceptions import (
NonexistentPlistError,
OldComponentFormatError,
)
from ..tags import MusicApps
from .audiocomponent import AudioComponent
logger = logging.getLogger(__name__)
@dataclass
class Component:
"""Represents a macOS Audio Component bundle.
A Component bundle (.component) can contain one or more AudioComponents.
This class parses the bundle's Info.plist and instantiates AudioComponent
objects for each Audio Unit defined within.
Attributes:
name: Component bundle name (without .component extension).
bundle_id: CFBundleIdentifier from Info.plist.
short_version: CFBundleShortVersionString from Info.plist.
version: CFBundleVersion from Info.plist.
audio_components: List of AudioComponent instances from this bundle.
"""
name: str
bundle_id: str
short_version: str
@@ -21,16 +45,48 @@ class Component:
audio_components: list[AudioComponent]
def __init__(
self, path: Path, *, lazy: bool = False, tags_path: Path = defaults.tags_path
self,
path: Path,
*,
lazy: bool = False,
tags_path: Path = defaults.tags_path,
musicapps: MusicApps = None,
):
"""Initialize a Component from a bundle path.
Args:
path: Path to .component bundle (with or without .component extension).
lazy: If True, defer loading Info.plist and AudioComponents.
tags_path: Path to tags database directory.
musicapps: Shared MusicApps instance for category management.
Note:
If lazy=False, raises can occur from load() method during initialization.
"""
self.path = path if path.suffix == ".component" else Path(f"{path}.component")
self.lazy = lazy
self.tags_path = tags_path
self.musicapps = musicapps or MusicApps(tags_path=self.tags_path, lazy=lazy)
logger.debug(f"Created Component from {self.path}")
if not lazy:
self.load()
def _parse_plist(self):
"""Parse the Info.plist file from the component bundle.
Returns:
dict: Parsed plist data.
Raises:
NonexistentPlistError: If Info.plist file doesn't exist.
CannotParsePlistError: If plist cannot be parsed. This wraps:
- plistlib.InvalidFileException: Invalid plist format.
- OSError, IOError: File read errors.
- UnicodeDecodeError: Encoding issues.
"""
info_plist_path = self.path / "Contents" / "Info.plist"
logger.debug(f"Parsing Info.plist at {info_plist_path}")
if not info_plist_path.exists():
raise NonexistentPlistError(f"Info.plist not found at {info_plist_path}")
@@ -39,25 +95,52 @@ class Component:
plist_data = plistlib.load(fp)
return plist_data
except Exception as e:
raise CannotParsePlistError(f"An error occurred: {e}")
raise CannotParsePlistError(f"An error occurred: {e}") from e
def load(self) -> "Component":
"""Load and parse the component bundle and its AudioComponents.
Parses Info.plist, extracts bundle metadata, and creates AudioComponent
instances for each Audio Unit defined in the AudioComponents array.
Returns:
Component: Self for method chaining.
Raises:
NonexistentPlistError: If Info.plist doesn't exist (from _parse_plist).
CannotParsePlistError: If plist parsing or metadata extraction fails.
This wraps AttributeError, TypeError from dict.get() operations.
OldComponentFormatError: If AudioComponents key is missing (legacy format).
CannotParseComponentError: If AudioComponent instantiation fails.
This wraps CannotParseComponentError from AudioComponent.__init__.
"""
plist_data = self._parse_plist()
logger.debug(f"Loaded Info.plist for {self.path}")
try:
self.name = self.path.name.removesuffix(".component")
self.bundle_id = plist_data["CFBundleIdentifier"]
self.version = plist_data["CFBundleVersion"]
self.short_version = plist_data["CFBundleShortVersionString"]
self.bundle_id = plist_data.get("CFBundleIdentifier")
self.version = plist_data.get("CFBundleVersion")
self.short_version = plist_data.get("CFBundleShortVersionString")
logger.debug(f"Loaded component info for {self.bundle_id}")
except Exception as e:
raise CannotParsePlistError(
f"An error occurred while extracting: {e}"
) from e
try:
logger.debug(f"Loading components for {self.bundle_id}")
self.audio_components = [
AudioComponent(name, lazy=self.lazy, tags_path=self.tags_path)
AudioComponent(
name,
lazy=self.lazy,
tags_path=self.tags_path,
musicapps=self.musicapps,
)
for name in plist_data["AudioComponents"]
]
logger.debug(
f"Loaded {len(self.audio_components)} components for {self.bundle_id}"
)
except KeyError as e:
raise OldComponentFormatError(
"This component is in an old format and cannot be loaded"
@@ -67,9 +150,15 @@ class Component:
"An error occurred while loading components"
) from e
logger.debug(f"Loaded {self.name} from {self.path}")
return self
def __hash__(self):
"""Return hash based on bundle_id for use in sets and dicts.
Returns:
int: Hash value.
"""
return hash(self.bundle_id)

View File

@@ -1,7 +1,16 @@
"""Default paths for Logic Plugin Manager.
This module provides default filesystem paths used throughout the library
for locating Audio Components and tag databases.
"""
from pathlib import Path
components_path: Path = Path("/Library/Audio/Plug-Ins/Components")
"""Path: Default location for macOS Audio Components (.component bundles)."""
tags_path: Path = Path("~/Music/Audio Music Apps/Databases/Tags").expanduser()
"""Path: Default location for Logic Pro's tag and category database files."""
__all__ = ["components_path", "tags_path"]

View File

@@ -1,34 +1,83 @@
"""Exception classes for Logic Plugin Manager.
This module defines all custom exceptions used throughout the library for
error handling related to plugin loading, tag management, and category operations.
"""
class PluginLoadError(Exception):
"""Base exception for errors occurring during plugin/component loading."""
pass
class NonexistentPlistError(PluginLoadError):
"""Raised when a required Info.plist file cannot be found."""
pass
class CannotParsePlistError(PluginLoadError):
"""Raised when a plist file exists but cannot be parsed or decoded."""
pass
class CannotParseComponentError(PluginLoadError):
"""Raised when component data is malformed or cannot be extracted."""
pass
class OldComponentFormatError(PluginLoadError):
"""Raised when a component uses a legacy format that is not supported."""
pass
class TagsetLoadError(Exception):
"""Base exception for errors occurring during tagset operations."""
pass
class NonexistentTagsetError(TagsetLoadError):
"""Raised when a .tagset file cannot be found at the expected path."""
pass
class CannotParseTagsetError(TagsetLoadError):
"""Raised when a tagset file exists but cannot be parsed."""
pass
class TagsetWriteError(TagsetLoadError):
"""Raised when writing to a tagset file fails."""
pass
class MusicAppsLoadError(Exception):
"""Raised when MusicApps database files cannot be loaded or parsed."""
pass
class MusicAppsWriteError(Exception):
"""Raised when writing to MusicApps database files fails."""
pass
class CategoryValidationError(Exception):
"""Raised when a category name is invalid or not found in the database."""
pass
class CategoryExistsError(Exception):
"""Raised when attempting to create a category that already exists."""
pass

View File

@@ -1,3 +1,9 @@
"""Logic package for plugin management and search.
This package provides the main Logic interface and Plugins collection
for managing Logic Pro's audio plugins.
"""
from .logic import Logic
from .plugins import Plugins, SearchResult

View File

@@ -1,17 +1,41 @@
"""Main Logic Pro plugin management interface.
This module provides the Logic class, the primary entry point for discovering
and managing Logic Pro's audio plugins and their categorization.
"""
import logging
from dataclasses import dataclass
from pathlib import Path
from .. import defaults
from ..components import Component
from ..tags import MusicApps
from ..components import AudioComponent, Component
from ..tags import Category, MusicApps
from .plugins import Plugins
logger = logging.getLogger(__name__)
@dataclass
class Logic:
"""Main interface for Logic Pro plugin and category management.
Provides high-level operations for discovering plugins, managing categories,
and bulk category assignments.
Attributes:
musicapps: MusicApps instance for database access.
plugins: Plugins collection with search capabilities.
components: Set of discovered Component bundles.
categories: Dictionary of category name to Category instance.
components_path: Path to Audio Components directory.
tags_path: Path to tags database directory.
"""
musicapps: MusicApps
plugins: Plugins
components: set[Component]
categories: dict[str, Category]
components_path: Path = defaults.components_path
tags_path: Path = defaults.tags_path
@@ -22,6 +46,17 @@ class Logic:
tags_path: Path | str = None,
lazy: bool = False,
):
"""Initialize Logic plugin manager.
Args:
components_path: Custom path to Components directory.
tags_path: Custom path to tags database.
lazy: If True, skip automatic discovery.
Note:
If lazy=False, automatically calls discover_plugins() and
discover_categories() which may raise various exceptions.
"""
self.components_path = (
Path(components_path) if components_path else defaults.components_path
)
@@ -33,22 +68,108 @@ class Logic:
self.musicapps = MusicApps(tags_path=self.tags_path, lazy=lazy)
self.plugins = Plugins()
self.components = set()
self.categories = {}
self.lazy = lazy
logger.debug("Created Logic instance")
if not lazy:
self.discover_plugins()
self.discover_categories()
def discover_plugins(self):
def discover_plugins(self) -> "Logic":
"""Scan components directory and load all plugins.
Iterates through .component bundles, loading their AudioComponents
into the plugins collection. Failed components are logged as warnings.
Returns:
Logic: Self for method chaining.
"""
for component_path in self.components_path.glob("*.component"):
try:
component = Component(component_path, lazy=self.lazy)
logger.debug(f"Loading component {component_path}")
component = Component(
component_path, lazy=self.lazy, musicapps=self.musicapps
)
self.components.add(component)
logger.debug(f"Loading plugins for {component.name}")
for plugin in component.audio_components:
self.plugins.add(plugin, lazy=self.lazy)
except Exception as e:
assert e
continue
logger.warning(f"Failed to load component {component_path}: {e}")
return self
def discover_categories(self) -> "Logic":
"""Load all categories from the MusicApps database.
Returns:
Logic: Self for method chaining.
Raises:
MusicAppsLoadError: If database files cannot be loaded.
"""
for category in self.musicapps.tagpool.categories.keys():
logger.debug(f"Loading category {category}")
self.categories[category] = Category(
category, musicapps=self.musicapps, lazy=self.lazy
)
return self
def sync_category_plugin_amount(self, category: Category | str) -> "Logic":
if isinstance(category, str):
category = self.categories[category]
logger.debug(f"Syncing plugin amount for {category.name}")
category.update_plugin_amount(
len(
self.plugins.get_by_category(
category.name if isinstance(category, Category) else category
)
)
)
return self
def sync_all_categories_plugin_amount(self) -> "Logic":
for category in self.categories.values():
self.sync_category_plugin_amount(category)
return self
def search_categories(self, query: str) -> set[Category]:
return {
category
for category in self.categories.values()
if query in category.name.lower()
}
def introduce_category(self, name: str) -> Category:
return Category.introduce(name, musicapps=self.musicapps, lazy=self.lazy)
def add_plugins_to_category(
self, category: Category, plugins: set[AudioComponent]
) -> "Logic":
for plugin in plugins:
plugin.add_to_category(category)
self.sync_category_plugin_amount(category)
return self
def move_plugins_to_category(
self, category: Category, plugins: set[AudioComponent]
) -> "Logic":
for plugin in plugins:
plugin.move_to_category(category)
self.sync_category_plugin_amount(category)
return self
def remove_plugins_from_category(
self, category: Category, plugins: set[AudioComponent]
) -> "Logic":
for plugin in plugins:
plugin.remove_from_category(category)
self.sync_category_plugin_amount(category)
return self
__all__ = ["Logic"]

View File

@@ -1,17 +1,40 @@
"""Plugin collection management and search functionality.
This module provides the Plugins class for storing, indexing, and searching
AudioComponent instances with various query strategies including fuzzy matching.
"""
import logging
from collections import defaultdict
from dataclasses import dataclass
from ..components import AudioComponent, AudioUnitType
logger = logging.getLogger(__name__)
@dataclass
class SearchResult:
"""Result from a plugin search with relevance scoring.
Attributes:
plugin: The matched AudioComponent instance.
score: Relevance score (higher is better).
match_field: Field that matched (e.g., 'name', 'manufacturer').
"""
plugin: AudioComponent
score: float
match_field: str
class Plugins:
"""Collection of plugins with indexed search capabilities.
Maintains multiple indexes for fast lookups by various attributes
and provides fuzzy search functionality using rapidfuzz.
"""
def __init__(self):
self._plugins: set[AudioComponent] = set()
@@ -26,13 +49,25 @@ class Plugins:
self._by_category: dict[str | None, set[AudioComponent]] = defaultdict(set)
def add(self, plugin: AudioComponent, *, lazy: bool = False) -> "Plugins":
"""Add a plugin to the collection.
Args:
plugin: AudioComponent to add.
lazy: If True, skip indexing (call reindex_all later).
Returns:
Plugins: Self for method chaining.
"""
logger.debug(f"Adding plugin {plugin.full_name}")
self._plugins.add(plugin)
if not lazy:
self._index_plugin(plugin)
return self
def _index_plugin(self, plugin: AudioComponent):
logger.debug(f"Indexing plugin {plugin.full_name}")
if plugin.lazy:
logger.debug(f"{plugin.full_name} is lazy, loading first")
plugin.load()
plugin.tagset.load()
@@ -48,8 +83,10 @@ class Plugins:
self._by_category[tag.lower()].add(plugin)
if not plugin.tagset.tags.keys():
self._by_category[None].add(plugin)
logger.debug(f"Indexed plugin {plugin.full_name}")
def reindex_all(self):
logger.debug("Reindexing all plugins")
self._by_full_name.clear()
self._by_manufacturer.clear()
self._by_name.clear()
@@ -59,9 +96,11 @@ class Plugins:
self._by_subtype_code.clear()
self._by_tags_id.clear()
self._by_category.clear()
logger.debug("Cleared all indexes")
for plugin in self._plugins:
self._index_plugin(plugin)
logger.debug("Reindexed all plugins")
def all(self):
return self._plugins.copy()
@@ -108,6 +147,23 @@ class Plugins:
fuzzy_threshold: int = 80,
max_results: int | None = None,
) -> list[SearchResult]:
"""Search for plugins with scoring and fuzzy matching.
Searches across multiple fields (name, manufacturer, category, type codes)
with relevance scoring. Higher scores indicate better matches.
Args:
query: Search query string.
use_fuzzy: Enable fuzzy matching (requires rapidfuzz package).
fuzzy_threshold: Minimum fuzzy match score (0-100).
max_results: Limit number of results (None for unlimited).
Returns:
list[SearchResult]: Sorted search results (highest score first).
Raises:
ImportError: If use_fuzzy=True but rapidfuzz is not installed.
"""
if not query or not query.strip():
return []

View File

@@ -1,4 +1,11 @@
"""Tags package for category and metadata management.
This package provides classes for managing Logic Pro's tag database system,
including categories, plugin metadata, and sorting preferences.
"""
from .category import Category
from .musicapps import MusicApps, Properties, Tagpool
from .tagset import Tagset
__all__ = ["MusicApps", "Properties", "Tagpool", "Tagset"]
__all__ = ["Category", "MusicApps", "Properties", "Tagpool", "Tagset"]

View File

@@ -0,0 +1,222 @@
"""Category management for plugin organization.
This module provides the Category class for working with Logic Pro's hierarchical
category system used to organize and filter audio plugins.
"""
import logging
from dataclasses import dataclass, field
from ..exceptions import CategoryExistsError, CategoryValidationError
from .musicapps import MusicApps
logger = logging.getLogger(__name__)
@dataclass
class Category:
"""Represents a Logic Pro plugin category.
Categories form a hierarchical structure using colon-separated names
(e.g., 'Effects:EQ'). Each category tracks plugin count and sorting order.
Attributes:
name: Category name (colon-separated for hierarchy).
musicapps: MusicApps instance for database access.
is_root: True if this is the root category (empty name).
plugin_amount: Number of plugins in this category.
lazy: Whether lazy loading is enabled.
"""
name: str
musicapps: MusicApps = field(repr=False)
is_root: bool
plugin_amount: int
lazy: bool
def __init__(self, name: str, *, musicapps: MusicApps = None, lazy: bool = False):
"""Initialize a Category.
Args:
name: Category name.
musicapps: MusicApps instance (created if None).
lazy: If True, defer validation.
Note:
If lazy=False, raises can occur from load() during initialization.
"""
self.name = name
self.musicapps = musicapps or MusicApps(lazy=lazy)
self.is_root = False
self.plugin_amount = 0
self.lazy = lazy
if not lazy:
self.load()
def load(self):
"""Validate and load category data from MusicApps database.
Raises:
CategoryValidationError: If category doesn't exist in database.
MusicAppsLoadError: If database files cannot be loaded.
"""
logger.debug(f"Validating category {self.name}")
if self.name not in self.musicapps.tagpool.categories.keys():
raise CategoryValidationError(f"Category {self.name} not found in tagpool")
self.plugin_amount = self.musicapps.tagpool.categories[self.name]
logger.debug(f"Loaded plugin amount for {self.name} - {self.plugin_amount}")
if self.name == "":
self.is_root = True
logger.debug("This is the root category")
return
if self.name not in self.musicapps.properties.sorting:
raise CategoryValidationError(f"Category {self.name} not found in sorting")
logger.debug(f"Valid category {self.name}")
@classmethod
def introduce(cls, name: str, *, musicapps: MusicApps = None, lazy: bool = False):
"""Create a new category in the database.
Args:
name: Name for the new category.
musicapps: MusicApps instance (created if None).
lazy: Whether to use lazy loading.
Returns:
Category: Newly created category instance.
Raises:
CategoryExistsError: If category already exists.
MusicAppsLoadError: If database files cannot be loaded.
MusicAppsWriteError: If database files cannot be written.
"""
logger.debug(f"Introducing category {name}")
if musicapps is None:
musicapps = MusicApps()
try:
cls(name, musicapps=musicapps, lazy=lazy)
raise CategoryExistsError(f"Category {name} already exists")
except CategoryValidationError:
logger.debug(f"Category {name} doesn't exist, proceeding")
pass
musicapps.introduce_category(name)
logger.debug(f"Introduced category {name}")
return cls(name, musicapps=musicapps)
@property
def parent(self) -> "Category":
"""Get the parent category in the hierarchy.
Returns:
Category: Parent category, or self if this is root.
"""
if self.is_root:
return self
return self.__class__(
":".join(self.name.split(":")[:-1]),
musicapps=self.musicapps,
lazy=self.lazy,
)
def child(self, name: str) -> "Category":
"""Create a child category reference.
Args:
name: Child category name (without parent prefix).
Returns:
Category: Child category instance.
"""
return self.__class__(
f"{self.name}:{name}", musicapps=self.musicapps, lazy=self.lazy
)
def delete(self):
if self.is_root:
return
self.musicapps.tagpool.remove_category(self.name)
self.musicapps.properties.remove_category(self.name)
def update_plugin_amount(self, amount: int):
if self.is_root:
return
self.musicapps.tagpool.write_category(self.name, amount)
self.load()
def move_up(self, steps: int = 1):
if self.is_root:
return
self.musicapps.properties.move_up(self.name, steps)
self.load()
def move_down(self, steps: int = 1):
if self.is_root:
return
self.musicapps.properties.move_down(self.name, steps)
self.load()
def move_to_top(self):
if self.is_root:
return
self.musicapps.properties.move_to_top(self.name)
self.load()
def move_to_bottom(self):
if self.is_root:
return
self.musicapps.properties.move_to_bottom(self.name)
self.load()
def move_before(self, other: "Category"):
if self.is_root:
return
self.musicapps.properties.move_before(self.name, other.name)
self.load()
def move_after(self, other: "Category"):
if self.is_root:
return
self.musicapps.properties.move_after(self.name, other.name)
self.load()
def move_to(self, index: int):
if self.is_root:
return
self.musicapps.properties.move_to_index(self.name, index)
self.load()
def swap(self, other: "Category"):
if self.is_root:
return
self.musicapps.properties.swap(self.name, other.name)
self.load()
@property
def index(self):
return self.musicapps.properties.get_index(self.name)
@property
def neighbors(self):
if self.is_root:
return None, None
neighbors = self.musicapps.properties.get_neighbors(self.name)
if neighbors is None or len(neighbors) != 2:
return None, None
return (
self.__class__(name=neighbors[0], musicapps=self.musicapps),
self.__class__(name=neighbors[1], musicapps=self.musicapps),
)
@property
def is_first(self):
return self.musicapps.properties.is_first(self.name)
@property
def is_last(self):
return self.musicapps.properties.is_last(self.name)
__all__ = ["Category"]

View File

@@ -1,73 +1,487 @@
"""MusicApps database management for Logic Pro tags and categories.
This module provides classes for reading and writing Logic Pro's MusicApps database
files (MusicApps.tagpool and MusicApps.properties) which store category definitions,
plugin counts, and sorting information.
"""
import logging
import plistlib
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from .. import defaults
from ..exceptions import MusicAppsLoadError
from ..exceptions import MusicAppsLoadError, MusicAppsWriteError
logger = logging.getLogger(__name__)
def _parse_plist(path: Path):
"""Parse a plist file from the MusicApps database.
Args:
path: Path to plist file.
Returns:
dict: Parsed plist data.
Raises:
MusicAppsLoadError: If file doesn't exist or cannot be parsed.
This wraps plistlib.InvalidFileException, OSError, IOError, UnicodeDecodeError.
"""
logger.debug(f"Parsing plist at {path}")
if not path.exists():
raise MusicAppsLoadError(f"File not found at {path}")
try:
with open(path, "rb") as fp:
plist_data = plistlib.load(fp)
logger.debug(f"Parsed plist for {path}")
return plist_data
except Exception as e:
raise MusicAppsLoadError(f"An error occurred: {e}")
raise MusicAppsLoadError(f"An error occurred: {e}") from e
def _save_plist(path: Path, data: dict):
"""Save data to a plist file in the MusicApps database.
Args:
path: Path to plist file.
data: Dictionary to serialize and write.
Raises:
MusicAppsWriteError: If writing fails.
This wraps OSError, IOError, TypeError.
"""
logger.debug(f"Saving plist to {path}")
try:
with open(path, "wb") as fp:
plistlib.dump(data, fp)
logger.debug(f"Saved plist to {path}")
except Exception as e:
raise MusicAppsWriteError(f"An error occurred: {e}") from e
@dataclass
class Tagpool:
"""Represents MusicApps.tagpool - category to plugin count mapping.
The tagpool file stores a dictionary mapping category names to the number
of plugins assigned to each category.
Attributes:
categories: Dictionary mapping category names to plugin counts.
"""
categories: dict[str, int]
def __init__(self, tags_path: Path, *, lazy: bool = False):
"""Initialize Tagpool from database path.
Args:
tags_path: Path to tags database directory.
lazy: If True, defer loading the file.
"""
self.path = tags_path / "MusicApps.tagpool"
self.lazy = lazy
logger.debug(f"Created Tagpool from {self.path}")
if not lazy:
self.load()
def load(self) -> "Tagpool":
"""Load tagpool data from disk.
Returns:
Tagpool: Self for method chaining.
Raises:
MusicAppsLoadError: If file cannot be loaded (from _parse_plist).
"""
logger.debug(f"Loading Tagpool data from {self.path}")
self.categories = _parse_plist(self.path)
logger.debug(f"Loaded Tagpool data from {self.path}")
return self
def write_category(self, name: str, plugin_count: int = 0):
"""Write or update a category with its plugin count.
Args:
name: Category name.
plugin_count: Number of plugins in this category.
Raises:
MusicAppsLoadError: If file cannot be loaded (from load).
MusicAppsWriteError: If file cannot be written (from _save_plist).
"""
self.load()
self.categories[name] = plugin_count
_save_plist(self.path, self.categories)
self.load()
def introduce_category(self, name: str):
"""Add a new category if it doesn't exist.
Args:
name: Category name to introduce.
Raises:
MusicAppsLoadError: If file cannot be loaded (from load).
MusicAppsWriteError: If file cannot be written (from write_category).
"""
self.load()
if name in self.categories:
return
self.write_category(name)
def remove_category(self, name: str):
"""Remove a category from the tagpool.
Args:
name: Category name to remove.
Raises:
MusicAppsLoadError: If file cannot be loaded (from load).
MusicAppsWriteError: If file cannot be written (from _save_plist).
"""
self.load()
self.categories.pop(name, None)
_save_plist(self.path, self.categories)
self.load()
@dataclass
class Properties:
"""Represents MusicApps.properties - category sorting and preferences.
The properties file stores the category sorting order and whether user
sorting is enabled (vs. alphabetical sorting).
Attributes:
sorting: Ordered list of category names.
user_sorted: True if user sorting is enabled, False for alphabetical.
"""
sorting: list[str]
user_sorted: bool
__raw_data: dict[str, str | list[str] | bool] = field(repr=False)
def __init__(self, tags_path: Path, *, lazy: bool = False):
"""Initialize Properties from database path.
Args:
tags_path: Path to tags database directory.
lazy: If True, defer loading the file.
"""
self.path = tags_path / "MusicApps.properties"
self.lazy = lazy
logger.debug(f"Created Properties from {self.path}")
if not lazy:
self.load()
def load(self) -> "Properties":
properties_data = _parse_plist(self.path)
self.sorting = properties_data.get("sorting", [])
self.user_sorted = bool(properties_data.get("user_sorted", False))
"""Load properties data from disk.
Returns:
Properties: Self for method chaining.
Raises:
MusicAppsLoadError: If file cannot be loaded (from _parse_plist).
"""
logger.debug(f"Loading Properties data from {self.path}")
self.__raw_data = _parse_plist(self.path)
logger.debug(f"Loaded Properties data from {self.path}")
self.sorting = self.__raw_data.get("sorting", [])
self.user_sorted = bool(self.__raw_data.get("user_sorted", False))
logger.debug(f"Parsed Properties data from {self.path}")
return self
def introduce_category(self, name: str):
self.load()
if name in self.sorting:
return
self.__raw_data["sorting"].append(name)
_save_plist(self.path, self.__raw_data)
self.load()
def enable_user_sorting(self):
self.load()
self.__raw_data["user_sorted"] = "property"
_save_plist(self.path, self.__raw_data)
self.load()
def enable_alphabetical_sorting(self):
self.load()
del self.__raw_data["user_sorted"]
_save_plist(self.path, self.__raw_data)
self.load()
def remove_category(self, name: str):
self.load()
self.__raw_data["sorting"].remove(name)
_save_plist(self.path, self.__raw_data)
self.load()
def move_up(self, category: str, steps: int = 1):
"""Move a category up in the sorting order.
Args:
category: Category name to move.
steps: Number of positions to move up.
Raises:
ValueError: If category not found in sorting.
MusicAppsLoadError: If file cannot be loaded (from load).
MusicAppsWriteError: If file cannot be written (from _save_plist).
"""
self.load()
sorting = self.sorting.copy()
if category not in sorting:
raise ValueError(f"Category '{category}' not found in sorting")
current_idx = sorting.index(category)
new_idx = max(0, current_idx - steps)
sorting.pop(current_idx)
sorting.insert(new_idx, category)
self.__raw_data["sorting"] = sorting
_save_plist(self.path, self.__raw_data)
self.load()
def move_down(self, category: str, steps: int = 1):
self.load()
sorting = self.sorting.copy()
if category not in sorting:
raise ValueError(f"Category '{category}' not found in sorting")
current_idx = sorting.index(category)
new_idx = min(len(sorting) - 1, current_idx + steps)
sorting.pop(current_idx)
sorting.insert(new_idx, category)
self.__raw_data["sorting"] = sorting
_save_plist(self.path, self.__raw_data)
self.load()
def move_to_top(self, category: str):
self.load()
sorting = self.sorting.copy()
if category not in sorting:
raise ValueError(f"Category '{category}' not found in sorting")
sorting.remove(category)
sorting.insert(0, category)
self.__raw_data["sorting"] = sorting
_save_plist(self.path, self.__raw_data)
self.load()
def move_to_bottom(self, category: str):
self.load()
sorting = self.sorting.copy()
if category not in sorting:
raise ValueError(f"Category '{category}' not found in sorting")
sorting.remove(category)
sorting.append(category)
self.__raw_data["sorting"] = sorting
_save_plist(self.path, self.__raw_data)
self.load()
def move_before(self, category: str, target: str):
self.load()
sorting = self.sorting.copy()
if category not in sorting:
raise ValueError(f"Category '{category}' not found")
if target not in sorting:
raise ValueError(f"Target category '{target}' not found")
sorting.remove(category)
target_idx = sorting.index(target)
sorting.insert(target_idx, category)
self.__raw_data["sorting"] = sorting
_save_plist(self.path, self.__raw_data)
self.load()
def move_after(self, category: str, target: str):
self.load()
sorting = self.sorting.copy()
if category not in sorting:
raise ValueError(f"Category '{category}' not found")
if target not in sorting:
raise ValueError(f"Target category '{target}' not found")
sorting.remove(category)
target_idx = sorting.index(target)
sorting.insert(target_idx + 1, category)
self.__raw_data["sorting"] = sorting
_save_plist(self.path, self.__raw_data)
self.load()
def move_to_index(self, category: str, index: int):
self.load()
sorting = self.sorting.copy()
if category not in sorting:
raise ValueError(f"Category '{category}' not found")
if index < 0:
index = len(sorting) + index
index = max(0, min(len(sorting) - 1, index))
sorting.remove(category)
sorting.insert(index, category)
self.__raw_data["sorting"] = sorting
_save_plist(self.path, self.__raw_data)
self.load()
def swap(self, category1: str, category2: str):
self.load()
sorting = self.sorting.copy()
if category1 not in sorting or category2 not in sorting:
raise ValueError("Both categories must exist")
idx1 = sorting.index(category1)
idx2 = sorting.index(category2)
sorting[idx1], sorting[idx2] = sorting[idx2], sorting[idx1]
self.__raw_data["sorting"] = sorting
_save_plist(self.path, self.__raw_data)
self.load()
def set_order(self, categories: list[str]):
self.load()
current = set(self.sorting)
new = set(categories)
if new != current:
missing = current - new
extra = new - current
raise ValueError(f"Category mismatch. Missing: {missing}, Extra: {extra}")
self.__raw_data["sorting"] = categories
_save_plist(self.path, self.__raw_data)
self.load()
def reorder(self, key_func=None, reverse: bool = False):
self.load()
sorting = self.sorting.copy()
if key_func is None:
sorting.sort(reverse=reverse)
else:
sorting.sort(key=key_func, reverse=reverse)
self.__raw_data["sorting"] = sorting
_save_plist(self.path, self.__raw_data)
self.load()
def get_index(self, category: str) -> int:
return self.sorting.index(category)
def get_at_index(self, index: int) -> str:
return self.sorting[index]
def get_neighbors(self, category: str) -> tuple[str | None, str | None]:
idx = self.get_index(category)
prev_cat = self.sorting[idx - 1] if idx > 0 else None
next_cat = self.sorting[idx + 1] if idx < len(self.sorting) - 1 else None
return prev_cat, next_cat
def is_first(self, category: str) -> bool:
return self.get_index(category) == 0
def is_last(self, category: str) -> bool:
return self.get_index(category) == len(self.sorting) - 1
@dataclass
class MusicApps:
"""Main interface to Logic Pro's MusicApps database.
Provides unified access to both tagpool and properties files, managing
category definitions, plugin counts, and sorting preferences.
Attributes:
tagpool: Tagpool instance managing category/plugin counts.
properties: Properties instance managing sorting and preferences.
"""
tagpool: Tagpool
properties: Properties
def __init__(self, tags_path: Path = defaults.tags_path, *, lazy: bool = False):
"""Initialize MusicApps from database path.
Args:
tags_path: Path to tags database directory.
lazy: If True, defer loading files.
"""
self.path = tags_path
self.lazy = lazy
logger.debug(f"Created MusicApps from {self.path}")
if not lazy:
self.load()
def load(self) -> "MusicApps":
"""Load both tagpool and properties files.
Returns:
MusicApps: Self for method chaining.
Raises:
MusicAppsLoadError: If files cannot be loaded (from Tagpool/Properties).
"""
self.tagpool = Tagpool(self.path, lazy=self.lazy)
self.properties = Properties(self.path, lazy=self.lazy)
logger.debug(f"Loaded MusicApps from {self.path}")
return self
def introduce_category(self, name: str):
"""Add a new category to both tagpool and properties.
Args:
name: Category name to introduce.
Raises:
MusicAppsLoadError: If files cannot be loaded.
MusicAppsWriteError: If files cannot be written.
"""
self.tagpool.introduce_category(name)
self.properties.introduce_category(name)
def remove_category(self, name: str):
"""Remove a category from both tagpool and properties.
Args:
name: Category name to remove.
Raises:
MusicAppsLoadError: If files cannot be loaded.
MusicAppsWriteError: If files cannot be written.
"""
self.tagpool.remove_category(name)
self.properties.remove_category(name)
__all__ = ["MusicApps", "Properties", "Tagpool"]

View File

@@ -1,18 +1,51 @@
"""Tagset file management for Audio Components.
This module provides the Tagset class for reading and writing .tagset files
that store plugin metadata like nicknames, short names, and category tags.
"""
import plistlib
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from ..exceptions import CannotParseTagsetError, NonexistentTagsetError
from ..exceptions import (
CannotParseTagsetError,
NonexistentTagsetError,
TagsetWriteError,
)
@dataclass
class Tagset:
"""Represents a .tagset file containing plugin metadata and tags.
Tagset files store custom metadata and category tags for Audio Components.
Each tagset is identified by a unique tags_id derived from the component's
type, subtype, and manufacturer codes.
Attributes:
tags_id: Unique identifier (hex-encoded type-subtype-manufacturer).
nickname: Custom nickname for the plugin.
shortname: Custom short name for the plugin.
tags: Dictionary mapping category names to tag values (e.g., 'user').
"""
tags_id: str
nickname: str
shortname: str
tags: dict[str, str]
__raw_data: dict[str, str | dict[str, str]] = field(repr=False)
def __init__(self, path: Path, *, lazy: bool = False):
"""Initialize a Tagset from a file path.
Args:
path: Path to .tagset file (extension added automatically if missing).
lazy: If True, defer loading the file until needed.
Note:
If lazy=False, raises can occur from load() method during initialization.
"""
self.path = path.with_suffix(".tagset")
self.lazy = lazy
@@ -20,6 +53,18 @@ class Tagset:
self.load()
def _parse_plist(self):
"""Parse the .tagset plist file.
Returns:
dict: Parsed plist data.
Raises:
NonexistentTagsetError: If .tagset file doesn't exist.
CannotParseTagsetError: If plist cannot be parsed. This wraps:
- plistlib.InvalidFileException: Invalid plist format.
- OSError, IOError: File read errors.
- UnicodeDecodeError: Encoding issues.
"""
if not self.path.exists():
raise NonexistentTagsetError(f".tagset not found at {self.path}")
try:
@@ -27,17 +72,140 @@ class Tagset:
plist_data = plistlib.load(fp)
return plist_data
except Exception as e:
raise CannotParseTagsetError(f"An error occurred: {e}")
raise CannotParseTagsetError(f"An error occurred: {e}") from e
def _write_plist(self):
"""Write the tagset data to the .tagset plist file.
Raises:
TagsetWriteError: If writing fails. This wraps:
- OSError, IOError: File write errors.
- TypeError: If data contains non-serializable types.
"""
try:
with open(self.path, "wb") as fp:
plistlib.dump(self.__raw_data, fp)
except Exception as e:
raise TagsetWriteError(f"An error occurred: {e}") from e
def load(self) -> "Tagset":
tagset_data = self._parse_plist()
"""Load and parse the tagset file from disk.
Returns:
Tagset: Self for method chaining.
Raises:
NonexistentTagsetError: If .tagset file doesn't exist (from _parse_plist).
CannotParseTagsetError: If plist cannot be parsed (from _parse_plist).
"""
self.__raw_data = self._parse_plist()
self.tags_id = self.path.name.removesuffix(".tagset")
self.nickname = tagset_data.get("nickname")
self.shortname = tagset_data.get("shortname")
self.tags = tagset_data.get("tags") or {}
self.nickname = self.__raw_data.get("nickname")
self.shortname = self.__raw_data.get("shortname")
self.tags = self.__raw_data.get("tags") or {}
return self
def set_nickname(self, nickname: str):
"""Set the nickname field in the tagset.
Args:
nickname: New nickname value.
Raises:
NonexistentTagsetError: If .tagset file doesn't exist (from load).
CannotParseTagsetError: If plist cannot be parsed (from load).
TagsetWriteError: If writing fails (from _write_plist).
"""
self.load()
self.__raw_data["nickname"] = nickname
self._write_plist()
self.load()
def set_shortname(self, shortname: str):
"""Set the shortname field in the tagset.
Args:
shortname: New short name value.
Raises:
NonexistentTagsetError: If .tagset file doesn't exist (from load).
CannotParseTagsetError: If plist cannot be parsed (from load).
TagsetWriteError: If writing fails (from _write_plist).
"""
self.load()
self.__raw_data["shortname"] = shortname
self._write_plist()
self.load()
def set_tags(self, tags: dict[str, str]):
"""Replace all tags with the provided dictionary.
Args:
tags: Dictionary mapping category names to tag values.
Raises:
NonexistentTagsetError: If .tagset file doesn't exist (from load).
CannotParseTagsetError: If plist cannot be parsed (from load).
TagsetWriteError: If writing fails (from _write_plist).
"""
self.load()
self.__raw_data["tags"] = tags
self._write_plist()
self.load()
def add_tag(self, tag: str, value: str):
"""Add or update a single tag.
Args:
tag: Category name.
value: Tag value (typically 'user').
Raises:
NonexistentTagsetError: If .tagset file doesn't exist (from load).
CannotParseTagsetError: If plist cannot be parsed (from load).
TagsetWriteError: If writing fails (from _write_plist).
"""
self.load()
self.tags[tag] = value
self._write_plist()
self.load()
def remove_tag(self, tag: str):
"""Remove a tag from the tagset.
Args:
tag: Category name to remove.
Raises:
NonexistentTagsetError: If .tagset file doesn't exist (from load).
CannotParseTagsetError: If plist cannot be parsed (from load).
KeyError: If tag doesn't exist in the tagset.
TagsetWriteError: If writing fails (from _write_plist).
"""
self.load()
del self.tags[tag]
self._write_plist()
self.load()
def move_to_tag(self, tag: str, value: str):
"""Clear all tags and set a single tag.
Args:
tag: Category name.
value: Tag value (typically 'user').
Raises:
NonexistentTagsetError: If .tagset file doesn't exist (from load).
CannotParseTagsetError: If plist cannot be parsed (from load).
TagsetWriteError: If writing fails (from _write_plist).
"""
self.load()
self.tags.clear()
self.tags[tag] = value
self._write_plist()
self.load()
__all__ = ["Tagset"]

392
uv.lock generated
View File

@@ -2,9 +2,166 @@ version = 1
revision = 3
requires-python = ">=3.13"
[[package]]
name = "accessible-pygments"
version = "0.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bc/c1/bbac6a50d02774f91572938964c582fff4270eee73ab822a4aeea4d8b11b/accessible_pygments-0.0.5.tar.gz", hash = "sha256:40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872", size = 1377899, upload-time = "2024-05-10T11:23:10.216Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7", size = 1395903, upload-time = "2024-05-10T11:23:08.421Z" },
]
[[package]]
name = "alabaster"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" },
]
[[package]]
name = "babel"
version = "2.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" },
]
[[package]]
name = "beautifulsoup4"
version = "4.14.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "soupsieve" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/77/e9/df2358efd7659577435e2177bfa69cba6c33216681af51a707193dec162a/beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e", size = 625822, upload-time = "2025-09-29T10:05:42.613Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" },
]
[[package]]
name = "certifi"
version = "2025.10.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "docutils"
version = "0.21.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" },
]
[[package]]
name = "furo"
version = "2025.9.25"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "accessible-pygments" },
{ name = "beautifulsoup4" },
{ name = "pygments" },
{ name = "sphinx" },
{ name = "sphinx-basic-ng" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4e/29/ff3b83a1ffce74676043ab3e7540d398e0b1ce7660917a00d7c4958b93da/furo-2025.9.25.tar.gz", hash = "sha256:3eac05582768fdbbc2bdfa1cdbcdd5d33cfc8b4bd2051729ff4e026a1d7e0a98", size = 1662007, upload-time = "2025-09-25T21:37:19.221Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/69/964b55f389c289e16ba2a5dfe587c3c462aac09e24123f09ddf703889584/furo-2025.9.25-py3-none-any.whl", hash = "sha256:2937f68e823b8e37b410c972c371bc2b1d88026709534927158e0cb3fac95afe", size = 340409, upload-time = "2025-09-25T21:37:17.244Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "imagesize"
version = "1.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a7/84/62473fb57d61e31fef6e36d64a179c8781605429fd927b5dd608c997be31/imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a", size = 1280026, upload-time = "2022-07-01T12:21:05.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769, upload-time = "2022-07-01T12:21:02.467Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "logic-plugin-manager"
version = "0.1.0"
version = "0.0.1"
source = { editable = "." }
[package.optional-dependencies]
@@ -14,8 +171,11 @@ search = [
[package.dev-dependencies]
dev = [
{ name = "furo" },
{ name = "rapidfuzz" },
{ name = "rich" },
{ name = "sphinx" },
{ name = "sphinx-autodoc-typehints" },
]
[package.metadata]
@@ -24,8 +184,11 @@ provides-extras = ["search"]
[package.metadata.requires-dev]
dev = [
{ name = "furo", specifier = ">=2025.9.25" },
{ name = "rapidfuzz", specifier = ">=3.14.3" },
{ name = "rich", specifier = ">=14.2.0" },
{ name = "sphinx", specifier = ">=8.2.3" },
{ name = "sphinx-autodoc-typehints", specifier = ">=3.5.2" },
]
[[package]]
@@ -40,6 +203,58 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
@@ -49,6 +264,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
@@ -110,6 +334,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e2/f6/e2176eb94f94892441bce3ddc514c179facb65db245e7ce3356965595b19/rapidfuzz-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:e805e52322ae29aa945baf7168b6c898120fbc16d2b8f940b658a5e9e3999253", size = 851487, upload-time = "2025-11-01T11:54:40.176Z" },
]
[[package]]
name = "requests"
version = "2.32.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
[[package]]
name = "rich"
version = "14.2.0"
@@ -122,3 +361,154 @@ sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7b
wheels = [
{ url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" },
]
[[package]]
name = "roman-numerals-py"
version = "3.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/30/76/48fd56d17c5bdbdf65609abbc67288728a98ed4c02919428d4f52d23b24b/roman_numerals_py-3.1.0.tar.gz", hash = "sha256:be4bf804f083a4ce001b5eb7e3c0862479d10f94c936f6c4e5f250aa5ff5bd2d", size = 9017, upload-time = "2025-02-22T07:34:54.333Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/53/97/d2cbbaa10c9b826af0e10fdf836e1bf344d9f0abb873ebc34d1f49642d3f/roman_numerals_py-3.1.0-py3-none-any.whl", hash = "sha256:9da2ad2fb670bcf24e81070ceb3be72f6c11c440d73bd579fbeca1e9f330954c", size = 7742, upload-time = "2025-02-22T07:34:52.422Z" },
]
[[package]]
name = "snowballstemmer"
version = "3.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" },
]
[[package]]
name = "soupsieve"
version = "2.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472, upload-time = "2025-08-27T15:39:51.78Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" },
]
[[package]]
name = "sphinx"
version = "8.2.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "alabaster" },
{ name = "babel" },
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "docutils" },
{ name = "imagesize" },
{ name = "jinja2" },
{ name = "packaging" },
{ name = "pygments" },
{ name = "requests" },
{ name = "roman-numerals-py" },
{ name = "snowballstemmer" },
{ name = "sphinxcontrib-applehelp" },
{ name = "sphinxcontrib-devhelp" },
{ name = "sphinxcontrib-htmlhelp" },
{ name = "sphinxcontrib-jsmath" },
{ name = "sphinxcontrib-qthelp" },
{ name = "sphinxcontrib-serializinghtml" },
]
sdist = { url = "https://files.pythonhosted.org/packages/38/ad/4360e50ed56cb483667b8e6dadf2d3fda62359593faabbe749a27c4eaca6/sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348", size = 8321876, upload-time = "2025-03-02T22:31:59.658Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/31/53/136e9eca6e0b9dc0e1962e2c908fbea2e5ac000c2a2fbd9a35797958c48b/sphinx-8.2.3-py3-none-any.whl", hash = "sha256:4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3", size = 3589741, upload-time = "2025-03-02T22:31:56.836Z" },
]
[[package]]
name = "sphinx-autodoc-typehints"
version = "3.5.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "sphinx" },
]
sdist = { url = "https://files.pythonhosted.org/packages/34/4f/4fd5583678bb7dc8afa69e9b309e6a99ee8d79ad3a4728f4e52fd7cb37c7/sphinx_autodoc_typehints-3.5.2.tar.gz", hash = "sha256:5fcd4a3eb7aa89424c1e2e32bedca66edc38367569c9169a80f4b3e934171fdb", size = 37839, upload-time = "2025-10-16T00:50:15.743Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/05/f2/9657c98a66973b7c35bfd48ba65d1922860de9598fbb535cd96e3f58a908/sphinx_autodoc_typehints-3.5.2-py3-none-any.whl", hash = "sha256:0accd043619f53c86705958e323b419e41667917045ac9215d7be1b493648d8c", size = 21184, upload-time = "2025-10-16T00:50:13.973Z" },
]
[[package]]
name = "sphinx-basic-ng"
version = "1.0.0b2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "sphinx" },
]
sdist = { url = "https://files.pythonhosted.org/packages/98/0b/a866924ded68efec7a1759587a4e478aec7559d8165fac8b2ad1c0e774d6/sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9", size = 20736, upload-time = "2023-07-08T18:40:54.166Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/dd/018ce05c532a22007ac58d4f45232514cd9d6dd0ee1dc374e309db830983/sphinx_basic_ng-1.0.0b2-py3-none-any.whl", hash = "sha256:eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b", size = 22496, upload-time = "2023-07-08T18:40:52.659Z" },
]
[[package]]
name = "sphinxcontrib-applehelp"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" },
]
[[package]]
name = "sphinxcontrib-devhelp"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" },
]
[[package]]
name = "sphinxcontrib-htmlhelp"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" },
]
[[package]]
name = "sphinxcontrib-jsmath"
version = "1.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" },
]
[[package]]
name = "sphinxcontrib-qthelp"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" },
]
[[package]]
name = "sphinxcontrib-serializinghtml"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "urllib3"
version = "2.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
]