# 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).