Écrire une extension Python pour Modular Text Editor avec Claude¶
Public : un assistant IA (Claude / ChatGPT / etc.). Cette page est un jeu d'instructions autonome : donnez-la à l'assistant avec la tâche (« écris-moi une extension qui fait X »), et l'assistant devrait produire un dossier d'extension complet, correct et fonctionnel sans lire le reste de la documentation.
Si vous êtes un humain écrivant une extension à la main, commencez ici — cette page est optimisée pour la consommation par IA et saute les exemples pédagogiques.
1. Ce que vous produisez¶
Un dossier avec exactement deux fichiers (plus des extras facultatifs) :
Rendez le dossier sous forme d'un bloc de code par fichier. L'utilisateur
dépose le dossier dans le répertoire d'extensions de l'éditeur, ou zippe le
dossier, renomme l'archive <nom>.mteplugin et l'installe via
Préférences → Extensions → Installer…. Les deux chemins exigent un
redémarrage pour charger.
Les deux fichiers sont décrits exhaustivement ci-dessous. Suivez les règles à la lettre — l'hôte exécute une sonde statique et rejette les extensions qui les enfreignent.
2. plugin.json — modèle canonique¶
{
"id": "org.example.<short-name>",
"name": "<Human Display Name>",
"version": "1.0.0",
"vendor": "<Author or Team>",
"description": "<One sentence about what it does.>",
"apiVersion": 1,
"entry": "python",
"module": "main",
"order": 900,
"menus": [
{ "title": "Plugins", "barPriority": 570, "itemPriority": 100 }
]
}
Règles strictes¶
"apiVersion"doit être1(la version actuelle de l'hôte)."entry"doit être"python"— rien d'autre ne route vers ce backend."module"doit être"main"sauf raison explicite de nommer le module autrement — et jamais un chemin à points (la phase 1 ne prend en charge que les noms de modules plats)."id"doit être en style DNS inversé (org.<vendor>.<name>) et globalement unique parmi les extensions installées de l'utilisateur. Sans vendor précisé, utilisezorg.example..menus[]doit contenir au moins une entrée dont letitlecorrespond au segment le plus à gauche de chaque chemin de menu utilisé dansmain.py. Préférez"Plugins"pour tout ce qui est destiné à l'utilisateur et n'appartient pas manifestement à un menu du cœur.
Champs facultatifs¶
"pyRequires": []— un tableau de chaînes d'exigences pip (p. ex.["requests>=2.31"]). Non vide, l'éditeur provisionne un venv par extension au premier lancement (pip install— réseau nécessaire cette fois-là) et exécute l'extension dessous. Préférez la bibliothèque standard quand elle suffit (pas de délai de provisionnement, fonctionne hors ligne) ; déclarezpyRequiresquand la tâche exige vraiment un paquet tiers, et prévenez l'utilisateur que le premier lancement marquera une pause d'installation."permissions": []— déclarations grossières de capacités (p. ex.["network"]), affichées en lecture seule sur la page des Extensions. Déclarez"network"quand l'extension fait des appels sortants ; c'est un étiquetage honnête pour l'utilisateur, pas une contrainte.
Valeurs à NE PAS inventer¶
- Tout champ absent de la liste ci-dessus. L'hôte ignore les clés inconnues ; les ajouter est un risque de maintenance.
3. main.py — squelette canonique¶
"""<Short description of what this plugin does.>"""
import mte # noqa: F401 -- pulls in dataclasses for event payloads
def register(ctx):
# Register commands, menu items, and event subscriptions here.
# Everything below is illustrative -- delete what you don't need.
@ctx.commands.command(id="<id>.<verb>",
title="<Human Label>",
shortcut="Ctrl+Alt+<letter>", # optional
category="<Group>") # optional
def _handler():
ctx.status.show("hello", timeout_ms=2000)
ctx.log.info("<id>: handler ran")
ctx.menus.add_item("Plugins/<Human Label>/<Item>", "<id>.<verb>")
Règles strictes¶
register(ctx)DOIT être une fonction de niveau supérieur. La sonde statique exige une ligne commençant pardef register(en colonne 0. L'imbriquer dans une classe, la décorer au niveau du module ou en faire unasync deffait rejeter silencieusement l'extension à la découverte.- Importez depuis
mtequand vous vous abonnez aux événements — les dataclasses typées (mte.DocumentSaved, etc.) viennent de ce paquet.import mteen tête est sûr même inutilisé ; le# noqa: F401évite le bruit du linter. - Jamais
print(). Le stdout du worker est le canal RPC ; y écrire corrompt le protocole. Utilisezctx.log.info / warning / error. - N'importez jamais rien de non déclaré. Au-delà de la bibliothèque
standard et de
mte, chaque import tiers doit figurer danspyRequiresdeplugin.json(l'éditeur le pip-installe dans le venv de l'extension au premier lancement) ou être vendored dans le dossier. Un import non déclaré lèveModuleNotFoundErrorchez l'utilisateur. - Les gestionnaires de commandes / événements peuvent bloquer
brièvement — ils tournent sur le pool de threads du worker, les
appels synchrones vers
ctx.editor/ctx.settings/ctx.data_dirsont donc légaux. Mais les rappels de fournisseurs de complétion (@ctx.completions.provider(...)) NE DOIVENT PAS bloquer : ils tournent sur la boucle d'événements avec une échéance d'environ 30 ms. Pas d'E/S, pas d'appelsctx.editor.*, cache uniquement. Construisez les index ailleurs (p. ex. surdocument.opened) et répondez depuis un dict. - N'importez pas Qt, PySide, PyQt ni Scintilla. Ils ne sont pas disponibles dans le worker.
- Les ids de commandes doivent être préfixés par l'espace de noms de
votre extension pour éviter les collisions avec le cœur / d'autres
extensions.
<id>.<verb>ci-dessus ; p. ex.wordcount.count,hello.sayHi. - Les chemins de menu doivent commencer par un menu de premier
niveau aussi déclaré dans
menus[]deplugin.json. Convention :"Plugins/<Nom>/<Verbe>".
4. API complète — tout ce qu'il y a sur ctx¶
Vous avez douze services (phases 1 + 2 + 3). Tout ce qui n'est pas
listé ici n'existe pas — n'inventez pas de services. (Les venvs par
extension se déclarent via pyRequires, pas un service ctx ; le
substrat streaming/cancel n'a pas encore d'API côté extension.)
ctx.commands¶
Décorateur. Enregistre une commande et lie _handler (fonction sans
argument renvoyant None). Les exceptions dans _handler sont
interceptées par le worker et écrites sur stderr ; elles ne font pas
planter l'éditeur.
ctx.menus¶
ctx.menus.add_item(path, command_id) # -> handler_id (str)
ctx.menus.add_separator(path) # -> handler_id (str)
ctx.menus.set_item_checked(handler_id, checked) # -> None
path est "Top/Sub/…/Libellé". Les sous-menus intermédiaires sont créés
à la demande.
set_item_checked rend l'entrée cochable et pose sa coche. Pour les
bascules marche/arrêt persistantes. Le premier appel promeut ; les
suivants ne font que basculer. No-op silencieux sur les ids inconnus.
ctx.events¶
Six sujets, chacun avec un décorateur sucré et une forme brute.
| Sucre | Sujet brut | Charge utile |
|---|---|---|
@ctx.events.on_document_opened |
document.opened |
mte.DocumentOpened(document: int, path: str) |
@ctx.events.on_document_closed |
document.closed |
mte.DocumentClosed(document: int) |
@ctx.events.on_document_saved |
document.saved |
mte.DocumentSaved(document: int) |
@ctx.events.on_selection_changed |
selection.changed |
mte.SelectionChanged(document: int) |
@ctx.events.on_active_document_changed |
document.activeChanged |
mte.ActiveDocumentChanged(document: int) |
@ctx.events.on_app_about_to_quit |
app.aboutToQuit |
mte.AppAboutToQuit() |
Forme brute :
handler_id = ctx.events.subscribe("<topic>", callback)
ctx.events.unsubscribe(handler_id) # to unbind
N'inventez pas de nouveaux sujets — la forme brute les valide et lève
ValueError pour tout ce qui n'est pas dans la table.
ctx.log¶
Fire-and-forget. Les lignes portent le préfixe [python:<plugin.id>]
dans le journal de diagnostics de l'hôte.
ctx.status¶
level doit être "info" / "warning" / "error". timeout_ms=0
laisse l'hôte choisir un défaut.
ctx.editor (phase 2)¶
Chaque méthode est synchrone : elle bloque le thread appelant.
Acceptable depuis un rappel def de commande / d'événement (le worker
répartit les gestionnaires sync sur un pool de threads) ; illégal
depuis un gestionnaire async def et depuis les fournisseurs
ctx.completions (qui tournent directement sur la boucle d'événements).
Les documents sont référencés par des ids int opaques (0 = aucun
document).
doc = ctx.editor.active_document()
docs = ctx.editor.open_documents()
ctx.editor.set_active_document(doc)
path = ctx.editor.file_path(doc) # str or None
text = ctx.editor.text(doc)
slice = ctx.editor.text_range(doc, start, end) # [start, end) bytes
ctx.editor.set_text(doc, "new text")
n = ctx.editor.length(doc)
sel = ctx.editor.selection_range(doc) # mte.MatchRange
s = ctx.editor.selected_text(doc)
ctx.editor.replace_selection(doc, "hi")
ctx.editor.set_selection(doc, mte.MatchRange(4, 9))
pos = ctx.editor.caret(doc) # mte.EditorPosition
ctx.editor.set_caret(doc, mte.EditorPosition(line=5, column=0))
new_doc = ctx.editor.open_file("/path/to/file") # 0 on failure
ok = ctx.editor.save_document(doc)
dirty = ctx.editor.is_modified(doc)
hit = ctx.editor.find_in_range(doc, "TODO", 0,
ctx.editor.length(doc),
mte.SearchOptions(match_case=True))
new_end = ctx.editor.replace_target(doc, hit, "DONE",
mte.SearchOptions())
style = ctx.editor.indentation(doc) # IndentationStyle
ctx.editor.set_indentation(doc,
mte.IndentationStyle(use_tabs=False, width=2))
lang = ctx.editor.colorizer_id(doc) # "python", "cpp", ...
mte.SearchOptions.mode : "normal" / "extended" / "regex".
mte.SearchOptions.direction : "forward" / "backward".
ctx.settings / ctx.app_settings (phase 2)¶
Magasin clé/valeur typé par extension (ctx.settings) plus une vue en
lecture seule des paramètres de l'application (ctx.app_settings). Le
type est inféré du type Python de la valeur par défaut.
ctx.settings.set("wrap", True)
ctx.settings.set("count", 42)
ctx.settings.set("theme", "dark")
wrap = ctx.settings.get("wrap", False) # bool
count = ctx.settings.get("count", 0) # int
theme = ctx.settings.get("theme", "light") # str
ctx.settings.contains("wrap")
ctx.settings.remove("stale")
# App-wide (read-only)
tw = ctx.app_settings.get("editor.tabWidth", 4)
ctx.app_settings.set/remove/sync lèvent RuntimeError. Dans votre
propre logique de lecture, testez bool avant int (en Python,
isinstance(True, int) == True).
ctx.completions (phase 2)¶
@ctx.completions.provider(languages=["python"])
def _complete(req: mte.CompletionRequest):
return [mte.CompletionItem(text=w, kind=mte.KIND_KEYWORD)
for w in ("hello", "hello_world")
if w.startswith(req.prefix)]
Champs de req : document, prefix, position, language_id,
manual. Genres : KIND_KEYWORD / KIND_WORD / KIND_SYMBOL /
KIND_SNIPPET / KIND_UNKNOWN. Renvoyez list[CompletionItem], des
chaînes simples ou des dicts. Le rappel DOIT revenir en ~30 ms, sinon
l'hôte abandonne vos résultats pour cette frappe — pas d'E/S, pas de
ctx.editor.text(...), cache uniquement. Construisez les index ailleurs
(p. ex. sur document.opened).
ctx.data_dir() / ctx.diagnostics_dir() (phase 2)¶
data_dir() pour les caches et index. diagnostics_dir() est en lecture
seule (le rapporteur de plantages y écrit).
ctx.docks — panneaux ancrables déclaratifs (phase 3)¶
from mte.ui import Column, Row, Label, LineEdit, Checkbox, Button
def _on_needle_changed(value: str) -> None:
ctx.log.info(f"needle -> {value!r}")
needle_handler = ctx.docks.on_change(_on_needle_changed)
panel_id = ctx.docks.add_panel(
id="myplugin.panel", # returned; use it for ctx.ui.get / ctx.ui.set
title="My Plugin",
area="right", # "left" / "right" / "top" / "bottom"
form=Column([
Label(text="Search:"),
LineEdit(id="needle", placeholder="text...",
binds="settings:lastNeedle",
on_change=needle_handler),
Checkbox(id="cs", label="Case sensitive",
binds="settings:caseSensitive"),
Row([Button(text="Run", on_click="myplugin.run")]),
]),
)
Construisez la spécification du formulaire avec les aides
mte.ui. Le vocabulaire des contrôles est fixe : Label /
Button / Checkbox / LineEdit / NumberSpin / ComboBox / List dans
Column / Row / Group.
binds="settings:<clé>"— la valeur du contrôle persiste automatiquement viactx.settings(même magasin quectx.settings.set).on_click="<cmd.id>"— Button uniquement ; invoque une commande enregistrée.on_change=<handler>— alloué viactx.docks.on_change(callback); se déclenche sur les modifications de l'utilisateur.
Les rappels on_change tournent sur la boucle d'événements du worker
(PAS le pool de threads). Restez léger — pas d'appels éditeur, pas de
long travail. Modifiez un petit état en mémoire, journalisez ou
déclenchez une commande ; le gros travail se fait dans la commande.
ctx.settings_page — pages de Préférences déclaratives (phase 3)¶
from mte.ui import Column, Checkbox, NumberSpin
ctx.settings_page.register(
category="Plugins/My Plugin", # slash-separated tree path
title="My Plugin",
form=Column([
Checkbox(id="verbose", label="Verbose logging",
binds="settings:verbose"),
NumberSpin(id="cap", default=100, min=1, max=10_000,
binds="settings:cap"),
]),
)
Mêmes constructeurs, même sémantique de binds. Deux contrôles partageant
un binds="settings:<clé>" se reflètent en temps réel — le panneau
ancrable et la page de Préférences peuvent tous deux porter une case pour
le même paramètre, et basculer l'un bascule l'autre.
Réserve de la phase 3
Les valeurs persistent au fil des modifications de l'utilisateur — Annuler ne revient pas en arrière. Cela s'améliorera dans un raffinement futur.
ctx.ui.get / ctx.ui.set — accès à l'exécution aux contrôles (phase 3)¶
# Read the current value; returns str or None.
current = ctx.ui.get(panel_id, "needle")
# Write. Returns bool (False when panel/control unknown, or when the
# value can't be coerced to the target type).
ctx.ui.set(panel_id, "needle", "hello")
ctx.ui.set(panel_id, "cs", True) # bool -> "true"/"false"
get renvoie la valeur principale sous forme de chaîne :
- Label / Button / LineEdit / ComboBox → texte
- Checkbox →
"true"/"false" - NumberSpin → entier en décimal
- List → texte de l'élément sélectionné (chaîne vide sinon)
Un set piloté par l'hôte est silencieux : il ne redéclenche PAS le
on_change du contrôle, vous pouvez donc mettre à jour vos propres
widgets depuis un gestionnaire de commande sans boucles infinies.
5. Exemple complet — extension wordcount¶
Copiez tel quel et changez id, name et les title pour votre cas.
Il exerce chaque service de la phase 1.
{
"id": "org.example.wordcount",
"name": "Word Count",
"version": "1.0.0",
"vendor": "Example, Inc.",
"description": "Counts words the user has typed since launch and logs saves.",
"apiVersion": 1,
"entry": "python",
"module": "main",
"order": 900,
"menus": [
{ "title": "Plugins", "barPriority": 570, "itemPriority": 100 }
]
}
"""Reports a session word-count total via a menu command and logs saves."""
import mte # noqa: F401
# Module-level state is fine -- one worker per plugin, one register() call.
_state = {"saves": 0}
def register(ctx):
ctx.log.info("wordcount: register() ran")
@ctx.commands.command(id="wordcount.report",
title="Report save count",
shortcut="Ctrl+Alt+W",
category="Text")
def _report():
n = _state["saves"]
ctx.status.show(f"{n} save(s) this session", timeout_ms=2500)
ctx.log.info(f"wordcount: user asked for count; {n} saves so far")
ctx.menus.add_item("Plugins/Word Count/Report save count",
"wordcount.report")
@ctx.events.on_document_saved
def _saved(ev: mte.DocumentSaved):
_state["saves"] += 1
ctx.log.info(f"wordcount: doc {ev.document} saved -- total now {_state['saves']}")
@ctx.events.on_app_about_to_quit
def _quit(_ev):
ctx.log.info(f"wordcount: shutting down after {_state['saves']} saves")
Déroulé attendu : ouvrir l'éditeur, enregistrer un fichier trois fois,
puis cliquer Plugins → Word Count → Report save count. La barre d'état
montre 3 save(s) this session. Le journal consigne chaque étape.
6. Tâches courantes — recettes à copier-coller¶
Chaque recette est un extrait de corps seul : à coller dans
def register(ctx):.
Ajouter une commande de menu¶
@ctx.commands.command(id="mine.hello", title="Say Hi", shortcut="Ctrl+Alt+Y")
def _hi():
ctx.status.show("hi")
ctx.menus.add_item("Plugins/Mine/Say Hi", "mine.hello")
Réagir à l'enregistrement¶
Avertir à la fermeture¶
@ctx.events.on_app_about_to_quit
def _on_quit(_ev):
ctx.log.warning("mine: last chance to persist state")
Journaliser à un niveau précis¶
ctx.log.trace ("very fine")
ctx.log.debug ("dev")
ctx.log.info ("routine")
ctx.log.warning("odd")
ctx.log.error ("wrong")
Séparateur de menu¶
Plusieurs commandes liées¶
@ctx.commands.command(id="mine.a", title="Do A")
def _a(): ...
@ctx.commands.command(id="mine.b", title="Do B")
def _b(): ...
ctx.menus.add_item("Plugins/Mine/Do A", "mine.a")
ctx.menus.add_item("Plugins/Mine/Do B", "mine.b")
Bascule marche/arrêt persistante avec coche visible¶
KEY = "verbose"
item = ctx.menus.add_item("View/Verbose Logging", "mine.toggleVerbose")
# Seed the mark from the persisted state so it matches on launch.
ctx.menus.set_item_checked(item, ctx.settings.get(KEY, False))
@ctx.commands.command(id="mine.toggleVerbose", title="Verbose Logging")
def _toggle():
on = not ctx.settings.get(KEY, False)
ctx.settings.set(KEY, on)
ctx.menus.set_item_checked(item, on)
Panneau latéral avec saisie, case et deux boutons¶
from mte.ui import Column, Row, Label, LineEdit, Checkbox, Button
def _on_needle(value): ctx.log.info(f"needle={value!r}")
needle_h = ctx.docks.on_change(_on_needle)
panel = ctx.docks.add_panel(
id="mine.panel", title="Mine", area="right",
form=Column([
Label(text="Search:"),
LineEdit(id="needle", placeholder="text...",
binds="settings:needle", on_change=needle_h),
Checkbox(id="cs", label="Case", binds="settings:cs"),
Row([Button(text="Run", on_click="mine.run"),
Button(text="Clear", on_click="mine.clear")]),
]),
)
@ctx.commands.command(id="mine.run", title="Run")
def _run():
needle = ctx.ui.get(panel, "needle") or ""
cs = ctx.ui.get(panel, "cs") == "true"
ctx.status.show(f"searching for {needle!r} (case={cs})")
@ctx.commands.command(id="mine.clear", title="Clear")
def _clear():
ctx.ui.set(panel, "needle", "")
Page de Préférences miroir des paramètres d'un dock¶
from mte.ui import Column, Checkbox
# Both controls bind to the SAME settings key -- toggling one
# flips the other in real time.
ctx.docks.add_panel(id="mine.panel", title="Mine", area="right",
form=Column([Checkbox(id="cs", label="Case",
binds="settings:cs")]))
ctx.settings_page.register(category="Plugins/Mine", title="Mine",
form=Column([Checkbox(id="cs", label="Case sensitive",
binds="settings:cs")]))
7. Anti-patrons — à NE PAS faire¶
print() pour le diagnostic¶
Faux :
Juste :
register dans une classe¶
Faux :
Juste :
async def register¶
Faux :
Juste :
Imports tiers sans livrer la wheel¶
Faux :
Juste (phase 1) : restez sur la bibliothèque standard. S'il vous faut
vraiment du tiers, livrez la wheel dans le dossier de l'extension et
étendez sys.path dans les premières lignes de main.py ; voir
Empaquetage.
Imports Qt¶
Faux :
Juste : pas de Qt du tout. Toute l'interface que vous pouvez apporter
passe par ctx.menus, ctx.status et (depuis la phase 3) les panneaux
déclaratifs dock/paramètres. Jamais un widget brut.
Bloquer dans un gestionnaire¶
Faux :
def _handler():
time.sleep(30) # <-- stalls the worker for 30s
result = requests.get("https://slow.example")
Juste (phase 1) : déléguez à un thread, communiquez via ctx.log :
def _handler():
import threading
def _work():
time.sleep(30)
ctx.log.info("done")
threading.Thread(target=_work, daemon=True).start()
ctx.status.show("started")
Chemin de menu dont le segment de tête n'est pas dans plugin.json¶
Faux :
Juste : alignez le segment de tête du chemin avec menus[]. Utilisez
"Plugins/Mine/Do" ci-dessus.
Stocker ctx pour plus tard¶
Faux :
_ctx = None
def register(ctx):
global _ctx
_ctx = ctx # <-- fine for the lifetime of this worker,
# but confusing and unnecessary
def some_other_function():
_ctx.log.info("...") # <-- called from where?
Juste : capturez les services nécessaires dans la fermeture qui les
utilise. register est le lieu du câblage ; tout le reste est un
gestionnaire qui a déjà accès par fermeture.
8. Liste de débogage pour l'utilisateur¶
Joignez ceci à la fin de votre réponse quand vous produisez une extension, pour que l'utilisateur sache quoi vérifier en cas de panne :
- Lancer l'éditeur depuis un shell où un interpréteur Python 3.9+ est
dans le
PATH(ouMTE_PYTHONpointe dessus). - Chercher
PluginHost: Python plugin backend enabled; interpreter=…dans le journal. S'il manque, aller au Dépannage. - Chercher
[python:<votre.id>] register() ran(si vous avez suivi l'exemplewordcount) — preuve queregisters'est exécuté. - Si l'entrée de menu manque, chercher dans le journal les explications
PythonPluginBackend: skipping '<id>': …. - Si cliquer le menu ne fait rien, chercher les lignes
[worker stderr] …— les tracebacks Python y atterrissent.
9. Contraintes à répéter à la fin de chaque réponse¶
Quand vous produisez une extension, dites toujours à l'utilisateur :
- Installer d'une des deux façons :
- zipper le contenu du dossier en
<nom>.mtepluginet utiliser Préférences → Extensions → Installer… (valide le paquet avant de toucher quoi que ce soit), ou - déposer le dossier dans le répertoire d'extensions par utilisateur
(
%LOCALAPPDATA%/MTE/Plugins/sous Windows ;~/Library/Application Support/MTE/Plugins/sous macOS ;~/.local/share/MTE/Plugins/sous Linux).
- zipper le contenu du dossier en
- Redémarrer l'éditeur.
- Définir
MTE_PYTHONsi le journal dit « no Python interpreter found ». - Renvoyer la ligne de journal
[python:<id>] …pour confirmer l'exécution.
10. Source de vérité¶
Si un énoncé de cette page contredit ce que le paquet client Python
implémente réellement, le paquet gagne. Il est livré à côté de l'éditeur
dans <exe>/Python/mte/ — lisez cette source avant de trancher un
comportement. Le côté C++ du protocole est dans
Components/PluginHost/Src/PyServiceBridge.cpp et la spécification de fil
dans Python/PROTOCOL.md.