picard Agent Skills & AI Rules

GitHub

Picard is a cross-platform music tagger powered by the MusicBrainz database

RAW Rules
2 Discovered Rules โ€ข ~4,504 Estimated Tokens
Quick:
Loading skill content...

AI Agent Protocol & Instructions

AGENTS.md root-instruction ~4,221 tokens
Raw
# 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:** <https://picard-docs.musicbrainz.org/>
- **Tickets:** <https://tickets.metabrainz.org/projects/PICARD>
- **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: <https://github.com/rdswift/picard-plugin-format-performer-tags>

**Documentation:**
- Plugin v3 docs: `docs/PLUGINSV3/` (API, CLI, manifest, translations, etc.)
- Migration guide: `docs/Plugin2to3MigrationGuide.md`
- Plugin registry: <https://github.com/metabrainz/picard-plugins-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 "[email protected]"` as a workaround to update PR descriptions.

### Contributing
1. **Create ticket first:** <https://tickets.metabrainz.org/projects/PICARD>
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:** <https://picard-docs.musicbrainz.org/>
- **Website:** <https://picard.musicbrainz.org/>
- **GitHub:** <https://github.com/metabrainz/picard>
- **Forum:** <https://community.metabrainz.org/c/picard>
- **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).