# AI Assistant Context for MusicBrainz Picard > **Purpose:** Essential patterns, conventions, and gotchas for AI assistants. > For architecture, dependencies, and component details, explore the codebase > or read existing docs (`README.md`, `CONTRIBUTING.md`, `INSTALL.md`). **What is Picard?** Cross-platform audio tagger for MusicBrainz. Tags music files using the MusicBrainz database, supports fingerprinting (AcoustID), and has a powerful plugin system. --- ## Quick Facts - **Language:** Python 3.10+, PyQt6, Mutagen - **Size:** Event-driven MVC with plugin system - **Entry Point:** `picard/tagger.py:main()` → `Tagger` singleton - **Tests:** `test/` directory (pytest) - **Docs:** - **Tickets:** - **Chat:** [Matrix #musicbrainz-picard-dev](https://matrix.to/#/#musicbrainz-picard-dev:chatbrainz.org) --- ## Critical Patterns & Gotchas ### Threading Rules ```python # ❌ NEVER access UI from background threads def background_task(): self.label.setText("Done") # CRASH! # ✅ Use signals or run_task callback from picard.util.thread import run_task def callback(result): self.label.setText(result) # Safe - runs in main thread run_task(heavy_operation, callback=callback) ``` ### Metadata Changes ```python # ❌ Silent changes won't update UI metadata['artist'] = 'New Artist' # ✅ Always emit signals metadata['artist'] = 'New Artist' file.metadata_updated.emit() ``` ### Metadata Access ```python # Metadata is always stored internally as a list of strings. # ❌ Don't use get() + split on MULTI_VALUED_JOINER to get individual values values = metadata.get('artist', '').split(MULTI_VALUED_JOINER) # ✅ Use getall() to get individual values as a list values = metadata.getall('artist') # Returns: ['Artist 1', 'Artist 2'] # ✅ Use get() or [] only for display (returns joined string) display = metadata['artist'] # Returns: 'Artist 1; Artist 2' # ✅ Set multiple values by passing a list (don't join them) metadata['artist'] = ['Artist 1', 'Artist 2'] ``` ### Configuration Access ```python # ✅ Always use config.setting from picard import config value = config.setting['option_name'] # ❌ Don't access internal structures directly ``` ### Async Operations ```python # ✅ Use thread pool for I/O from picard.util.thread import run_task run_task(process_file, file, callback) # ❌ Don't block the main thread result = slow_network_call() # UI freezes! ``` ### Import Rules ```python # ❌ Don't use inline imports (unless breaking circular dependencies) def my_function(): from picard.some_module import something return something() # ❌ Don't put multiple imports on one line from picard.plugin3.validator import generate_uuid, validate_manifest_dict # ✅ Imports go at the top of the file, each imported name on its own line with trailing commas from picard.plugin3.validator import ( generate_uuid, validate_manifest_dict, ) ``` **Exception:** Inline imports are acceptable only to break circular dependencies. Place them as close to usage as possible with a comment explaining why. --- ## Plugin System (v3) ### Structure ```text my_plugin/ ├── __init__.py # Plugin code ├── MANIFEST.toml # Metadata (UPPERCASE!) └── ui_options.py # Optional settings ``` ### MANIFEST.toml ```toml uuid = "unique-uuid" name = "Plugin Name" authors = ["Your Name"] description = "Description" api = ["3.0"] categories = ["metadata"] license = "GPL-2.0-or-later" license_url = "https://www.gnu.org/licenses/gpl-2.0.html" ``` ### Plugin Code ```python from picard.plugin3.api import PluginApi def enable(api: PluginApi): """Called when plugin is enabled.""" api.plugin_config.register_option("my_option", "default") api.register_track_metadata_processor(process_metadata) def process_metadata(api, album, metadata, track, release): metadata['custom'] = api.plugin_config['my_option'] ``` ### Plugin Management CLI ```bash # List installed plugins picard-cli plugins list # Install plugin from registry picard-cli plugins install plugin-name # Install from URL or local path picard-cli plugins install https://github.com/user/plugin.git picard-cli plugins install /path/to/plugin # Update plugins picard-cli plugins update plugin-name # Remove plugin picard-cli plugins remove plugin-name # Search registry picard-cli plugins search keyword ``` **Key Files:** - API: `picard/plugin3/api_impl.py` - Manager: `picard/plugin3/manager/__init__.py` - CLI: `picard/plugin3/cli.py` - Example: **Documentation:** - Plugin v3 docs: `docs/PLUGINSV3/` (API, CLI, manifest, translations, etc.) - Migration guide: `docs/Plugin2to3MigrationGuide.md` - Plugin registry: --- ## Development Workflow ### Setup ```bash git clone https://github.com/metabrainz/picard.git cd picard # Preferred uv sync # Alternative pip install -r requirements.txt python setup.py build_ui # Compile Qt forms python tagger.py # Run from source ``` ### Before Committing ```bash # 1. Format code ruff format picard/ # 2. Check for issues ruff check picard/ # 3. Run tests pytest test/ # Or specific tests pytest test/test_metadata.py ``` **Important:** Always run `ruff format` after making code changes to ensure consistent formatting. ### AI Assistant Guidelines When making code changes: 1. **Imports go at the top of files** - local (inline) imports are acceptable only to break circular dependencies; in that case, place them as close to usage as possible with a comment explaining why 2. **Bug fixes: test first** - write a test that reproduces the bug before fixing it, then verify the fix makes the test pass; keep the test as a permanent regression test when feasible 3. **Run `ruff format` after all changes** - ensures code follows project style guidelines 4. **Run `ruff check` to catch issues** - fix any linting errors before committing 5. **Never commit editor or agent-specific files** - Do not `git add` files from AI tool directories (`.kiro/`, `.cursor/`, `.aider*`, etc.) or any files not part of the project. Review `git diff --cached` before committing to verify only intended files are staged. When in doubt, ask the user before committing. 6. **Don't push to remote** - without explicit user approval 7. **Never use `git commit --no-verify`** - pre-commit hooks run `ruff format` and `ruff check` automatically; skipping them leads to CI failures. If using fixup/rebase workflows, always run `ruff format` and `ruff check` manually before the final push. 8. **Run `ty check` on modified files** - verify no new type errors are introduced (pre-existing errors in the codebase can be ignored). 9. **`gh pr edit` may fail with a GraphQL Projects deprecation error** - use `gh api repos/OWNER/REPO/pulls/NUMBER -X PATCH -F "body=@file.md"` as a workaround to update PR descriptions. ### Contributing 1. **Create ticket first:** 2. Create feature branch 3. Make changes 4. Run pre-commit checks (above) 5. **PR title must start with:** `PICARD-XXXX:` (ticket number) 6. **Use the PR template** - fill in `.github/PULL_REQUEST_TEMPLATE.md` (Summary, Problem, Solution, AI Usage, Action sections) 7. **Don't auto-create tickets/PRs** - let humans handle this 8. **Don't push to remote** - without explicit user approval --- ## Code Style ### Conventions - **Standard:** PEP 8, 120 char lines (flexible) - **Linter:** Ruff - **Type hints:** Recommended for new code (existing code has limited coverage) - **Naming:** `PascalCase` classes, `snake_case` functions, `UPPER_SNAKE` constants ### Membership Tests and Control Flow ```python # Use a set literal (not a tuple/list) for membership tests against a fixed # set of constants. Python optimizes `in {...}`. See the ruff "literal-membership" rule. # ❌ Tuple/list literal for membership test if state in (State.CHANGED, State.NORMAL): ... assert suffix in ('.pyc', '.pyo') # ✅ Set literal if state in {State.CHANGED, State.NORMAL}: ... assert suffix in {'.pyc', '.pyo'} # Prefer a single membership test over chained equality checks. # ❌ Multiple equality checks if name == 'performer' or name == 'comment': ... # ✅ Single membership test if name in {'performer', 'comment'}: ... # Collapse a nested `else:` + `if` into a single `elif` to reduce indentation. # See the ruff "collapsible-else-if" rule. # ❌ Nested if inside else if a: do_x() else: if b: do_y() else: do_z() # ✅ elif if a: do_x() elif b: do_y() else: do_z() ``` The preview ruff rules can be checked with: ```bash uv run ruff check --preview --select literal-membership,collapsible-else-if ``` ### Internationalization (i18n) ```python # Import translation functions from picard.i18n import gettext as _ from picard.i18n import N_ # Translatable strings (runtime) message = _("Save file") error = _("Failed to load: %s") % filename # Mark for extraction only (constants, class attributes) TITLE = N_("Options") # Translated at usage: _(TITLE) # Plugin translations (Plugin v3) from picard.plugin3.api import t_ class ManifestTranslations: NAME = t_("manifest.name", "My Plugin") DESC = t_("manifest.description", "Plugin description") ``` **Translation workflow:** - Strings marked with `_()` or `N_()` are extracted to `po/picard.pot` - Regenerate: `python setup.py regen_pot_file` - Translations managed via [Weblate](https://translations.metabrainz.org/projects/picard/) - See `po/README.md` for details #### Gotcha: `picard/const/` uses a separate gettext domain Picard has **four** gettext domains (see `picard/i18n/gettext.py`), not one. Strings are extracted per source location, and each domain must be looked up with its **own** translation function at runtime: | Where the string is defined | `N_()`-marked into | Translate at runtime with | |---|---|---| | Anywhere **except** `picard/const/` | `po/picard.pot` (`main`) | `gettext()` / `_()` | | `picard/const/` (e.g. `tags.py`, `scripts.py`, `cover_processing.py`, `languages.py`, `locales.py`, `defaults.py`) | `po/constants/` (`constants`) | `gettext_constants()` | | `picard/const/countries.py` (`RELEASE_COUNTRIES`) | `po/countries/` (`countries`) | `gettext_countries()` | | `picard/const/attributes.py` (`MB_ATTRIBUTES`) | `po/attributes/` (`attributes`) | `pgettext_attributes()` / `gettext_attributes()` | `setup.py` deliberately excludes `const` from the main pot and extracts it into `po/constants/constants.pot`. So a `const/`-sourced string translated with the plain `_()` is looked up in the **wrong** domain, silently misses, and renders in English even though a translation exists. ```python from picard.i18n import gettext as _ from picard.i18n import gettext_constants # TagVar.longdesc / shortdesc, SCRIPTS[...], CoverResizeMode.title, # DEFAULT_SCRIPT_NAME, ... all originate in picard/const/ # ❌ Wrong domain — always English, even in fr/de/... UI title = _(tagvar.shortdesc) name = _(DEFAULT_SCRIPT_NAME) # or storing the raw N_() string # ✅ Constants domain title = gettext_constants(tagvar.shortdesc) name = gettext_constants(DEFAULT_SCRIPT_NAME) ``` Rule of thumb: **if the string's `N_()` lives in `picard/const/`, translate it with `gettext_constants()`** (or the countries/attributes helper). This bug is easy to reintroduce — it has recurred across PRs #3453 and #3457 (PICARD-3448). When touching translated `const/` strings, verify the msgid resolves against `po/constants/*.po` (or `po/countries/`, `po/attributes/`). ### Qt UI Files ```bash # ❌ Don't edit picard/ui/ui_*.py directly (auto-generated) # ✅ Edit .ui files with Qt Designer qt6-tools designer ui/options_metadata.ui # ✅ Regenerate Python files python setup.py build_ui ``` ### Qt Signals ```python from PyQt6.QtCore import pyqtSignal class MyWidget(QWidget): # Define signals value_changed = pyqtSignal(str) item_selected = pyqtSignal(object) def some_action(self): # Emit signals self.value_changed.emit("new value") ``` Use the `QSignalBlocker` context manager to suppress signals temporarily, rather than manual `blockSignals(True)`/`blockSignals(False)` calls. It is exception-safe (signals are always unblocked, even if an exception is raised) and more concise. ```python from PyQt6.QtCore import QSignalBlocker # ❌ Manual, not exception-safe widget.blockSignals(True) widget.setChecked(True) widget.blockSignals(False) # ✅ Context manager with QSignalBlocker(widget): widget.setChecked(True) ``` ### Readability - Use descriptive names - Comment complex logic - Break long functions - Follow existing style in the file --- ## Key Locations ### Core Components - **Main app:** `picard/tagger.py` - **File handling:** `picard/file.py` - **Album/Track:** `picard/album.py`, `picard/track.py` - **Metadata:** `picard/metadata.py` ### Format Handlers - **Registry:** `picard/formats/registry.py` - **ID3 (MP3):** `picard/formats/id3.py` - **Vorbis (FLAC/Ogg):** `picard/formats/vorbis.py` - **MP4:** `picard/formats/mp4.py` ### Plugin System - **API:** `picard/plugin3/api_impl.py` - **Manager:** `picard/plugin3/manager/__init__.py` - **Registry:** `picard/plugin3/registry.py` ### UI - **Main window:** `picard/ui/mainwindow/` - **Options:** `picard/ui/options/` - **Metadata box:** `picard/ui/metadatabox/` - **Script editor:** `picard/ui/scripteditor/` ### Scripting - **Parser:** `picard/script/parser.py` - **Functions:** `picard/script/functions.py` ### Web Services - **HTTP client:** `picard/webservice/__init__.py` - **APIs:** `picard/webservice/api_helpers.py` --- ## Common Tasks ### Add Audio Format 1. Create handler in `picard/formats/` 2. Inherit from `File` 3. Implement `_load()` and `_save()` 4. Define `EXTENSIONS` and `NAME` 5. Call `register_format(MyFormat)` ### Add Script Function ```python from picard.script import script_function @script_function def func_myfunction(parser, arg1, arg2='default'): """$myfunction(arg1,arg2) - Description""" return result ``` ### Add Option 1. Define in `picard/options.py` 2. Create UI page in `picard/ui/options/` 3. Implement `load()` and `save()` methods 4. Register with `register_options_page()` ### Add Metadata Processor (Core) ```python from picard.extension_points.metadata import register_track_metadata_processor def my_processor(album, metadata, track, release): metadata['custom'] = 'value' register_track_metadata_processor(my_processor) ``` **For plugins:** Use `api.register_track_metadata_processor()` instead (see Plugin System section). --- ## Testing How to run the suite and how to write tests — the two coexisting test styles, the `subtest_cases` decorator for table-driven tests, and the shared `qapp` fixture for Qt widgets — is documented in [test/README.md](test/README.md). That document is the source of truth; follow it rather than relying on a copy here. Points worth repeating because they are easy to get wrong: - Match the test style of the file you are editing. Do not convert a `PicardTestCase` class to plain pytest (or the reverse) as a side effect of an unrelated change. - `@pytest.mark.parametrize` does not work on `PicardTestCase` subclasses; the test errors with a missing-argument `TypeError`. Use the `subtest_cases` decorator (or `self.subTest()`) there. - Never create a local `QApplication`/`QCoreApplication`; it crashes under `pytest-randomly` ordering. --- ## Debugging ```bash # Enable debug logging picard --debug # Enable specific debug options (comma-separated) picard --debug-opts=option1,option2 ``` ```python # In code from picard import log log.debug('Debug: %s', value) log.error('Error', exc_info=True) ``` --- ## Resources - **User Docs:** - **Website:** - **GitHub:** - **Forum:** - **Contributing:** See `CONTRIBUTING.md` - **Installation:** See `INSTALL.md` --- **Note:** This file contains only essential patterns. For architecture, dependencies, and component details, explore the codebase directly or read existing documentation (`README.md`, `CONTRIBUTING.md`, `INSTALL.md`, user docs).