turbo-editors/turbo-pythonpublic Fork 0
v1.0.0
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-python.git
git clone ssh://git@rickub.com/turbo-editors/turbo-python.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

📦 Turbo Python 6fc62ea · on v1.0.0 · k33g · 9h ago
python-tools.md · 119 lines · 14.4 KBmarkdown
Blame HistoryOpen raw

Python tools — explanation

What is this about?

A Python menu whose commands come from a TOML file, each run in a terminal window, and the open files re-read afterwards. This page is about why each of those three is the way it is.

Why the output has three places to go, and a popup by default

The first version put every command in a terminal window, and it was the wrong default for five of the six.

A terminal is the right answer when the program is interactive or long: uv run on a script that calls input() has to be answerable, and a uv sync that turns out to resolve half of PyPI has to be interruptible with Ctrl-C. Neither is true of uv run ruff check ., which prints four lines and ends. Giving that a whole window — one you then have to close, on a desktop where windows overlap and are numbered — is more ceremony than the result deserves.

A popup is the right answer for a command you run, read and dismiss. It is modal, which is a real cost and is named in the how-to: a uv sync you did not expect to be slow holds the editor until it finishes or you press Escape. That cost was accepted on purpose, because the alternative — a dialog appearing unbidden three seconds later — swallows whatever was being typed at the moment it arrives.

So the popup opens immediately and fills in. You see progress, nothing surprises you, and Escape both closes it and stops the command, which is the only way to interrupt something whose output is not in a terminal.

An editing window is the right answer for output you are going to work through: a long uv run pytest -v, a coverage report. It is an ordinary buffer, so Ctrl-F searches it and Save as keeps it. It is filled once the command has ended rather than as it goes, because a buffer growing under the cursor while you search it is the opposite of what that mode is for.

None of those three is right for everything, which is why output is in the file rather than in the code. Run is the worked example: it is the one command in the starter file that says terminal, and the comment beside it says why.

Why a terminal window is still there

The editor already had one — a real pseudo-terminal with a VT emulator, built for the F8 windows — so output = "terminal" costs one field on its options and buys colours, paging, Ctrl-C, keyboard input and scrollback for nothing, because they are the same mechanisms every other terminal uses.

The window stays after the command exits, which is the point: the output is what you asked for, and a window that vanished with it would be useless.

That needed a fix of its own. A terminal view consumed every key it was given and wrote it to the shell; once the shell had gone the write failed silently and the key was consumed anyway, so Ctrl-W could never close a finished window and the mouse was the only way out. A finished view now takes only the scrolling keys and lets the rest through to the editor.

Why the exit code is always in the title

uv sync succeeding prints nothing at all. A popup with an empty body and a neutral title is indistinguishable from one whose command has not started, and the reader is left guessing at the one thing they wanted to know.

So the title carries the verdict — — ok or — exit 1 — and an empty body says (no output) once the command has ended. While it is still running the body stays blank, because "(no output)" is a verdict and a running command has not reached one.

Why the commands are in a file

Six commands hardwired into the editor would have answered the request. They would also have been wrong within a week.

Every command in the starter file goes through uv, which creates the environment, resolves the dependencies and runs the tools inside it — so none of them needs an environment to have been activated first. That is a defensible default and it is nobody's universal answer. A project on Poetry wants poetry run. One on pip and a hand-made .venv wants the bare command, with the environment already on PATH. One that has standardised on black and flake8 wants those rather than ruff. uv run pytest assumes pytest; a project on unittest wants python -m unittest. A project with a Makefile wants make check. None of that is knowable from here, and all of it is one line in a file.

So the six are defaults, not code: they are the contents of the starter file that Python ▸ Create tools file writes, and changing one is editing a file rather than rebuilding an editor. The file is read every time the menu opens, for the same reason the Snippets menu is: an edit should take effect at once, and the file is often open in the window behind the menu.

Commands go to sh -ccmd.exe /S /C on Windows — rather than being split into an argv here. The file is the user's own, so pipes, globs and && are features rather than hazards, and one entry can be uv run ruff format . && uv run ruff check . && uv run pytest. Splitting an argv would mean inventing quoting rules for a string somebody wrote by hand.

Why there is no user-level tools file

Snippets are read from two files — yours and the project's — because your snippets are your habits and should follow you between projects.

Tools are not like that. They belong to a project's own toolchain: a global tools file would offer uv run pytest in a repository that has never heard of uv, and a project pinned to Poetry would get somebody else's habits in its menu. The file is per-project, and that is the whole of the rule.

Why a tool may name its own menu

A menu called Python holding docker compose up is a lie about what the menu is. The first tools file anybody writes outgrows Python, because a project's commands are not all about the language it is written in: containers, databases, deploys, a Makefile target somebody added in 2019.

Two shapes were considered. A fixed second menu called Tools — everything Python in Python, everything else in Tools — is one key in the format and no naming problem at all, but it only moves the lie: a Tools menu holding docker compose up, psql, and a deploy script is just as undifferentiated, and the moment there are ten entries nobody can find one. And a second file, menus.toml, keeps the tools file simple at the cost of two files that have to agree about which tools exist.

So the menu is a free-form name on the tool, in the one file: menu = "Docker". A name nothing else uses creates the menu; leaving the key out means Python. There is no list of allowed names, because a list would be a list of somebody else's projects.

Python itself stays fixed on the bar rather than becoming just another name from the file. Python ▸ Create tools file has to be reachable in a project that has no tools file at all — which is exactly the project that needs it — and a menu that only exists once the file exists cannot offer to write the file.

Why the hot key is not the file's to choose

The author of a tools file cannot know which letters are free. They can see File, Edit, Search, Run, Code, Options, Window, Snippets, Python and Help on the bar, but only by counting the underlines, and a project shared between people would then depend on nobody adding a menu that collides.

Collisions here are silent, which is what makes them worth designing against. The bar answers the first menu whose hot key matches; a second menu claiming the same letter is not an error and draws normally — it simply never opens. That trap has already been sprung once in this editor: Snippets and Search both wanted S, Snippets was the unreachable one, and every test passed. The fix then was to move Snippets to N by hand. Letting a file name menus makes that a permanent hazard rather than a one-off mistake, so the assignment is done by the editor: the first letter of the name nothing else claims.

Tildes written into the name are honoured when the letter is free, and quietly overridden when it is not. Refusing the file instead was the alternative, and it is worse: the clash depends on which menus exist, so a tools file that worked would break the day an editor release added a menu. Between a menu on a letter you did not ask for and a menu you cannot open, the first is the smaller loss.

When every letter of a name is taken, the menu gets no hot key at all. F10, the arrow keys and the mouse still reach it, and the alternative — reaching for a letter that is not in the name — would put an underline under nothing.

Why the bar is rebuilt from a stat

Menu.OnOpen refills a menu's items just before it drops down, which is how the Python and Snippets menus follow their files without a restart. It cannot help here: the set of menus is part of the bar, not part of any one menu, and adding menu = "Docker" to the file should put Docker on the bar.

Reading and parsing the file on every turn of the event loop would do it, and would also be work done for nothing on every keystroke of a file nobody has edited. So the bar carries the size and modification time of the tools file it was built from, and one stat per turn decides whether to rebuild. Editing the file in the window in front of you, saving it, and watching the bar change is the case this is for.

Why open files are re-read, and only some of them

Format rewrites files on disk — including the one you are looking at. Without anything further, the editor would sit on a stale copy, and the next F2 would write your unformatted version back over ruff format's work. That is not a rough edge; it is the feature quietly undoing itself.

So when a command finishes, the editor re-reads every open file. The interesting part is which ones it refuses to touch.

A file with unsaved changes is left alone, and the status bar says how many were skipped. Reloading it would throw away work the user has not saved, which no amount of convenience justifies. And the conflict is genuine: the formatter and the unsaved edit disagree about what the file should say, and the editor is not in a position to decide. Naming it and stopping is the honest outcome — the user can save and re-run, or keep editing and format later.

Two smaller decisions inside that:

  • The cursor stays where it was, clamped into whatever the file now holds. A formatter moves lines about; putting the cursor back at the top would lose the reader's place for no reason.
  • The undo history is discarded. Undoing back past a reload would restore text the file no longer has, which is worse than not being able to undo at all.

Why the reload happens on the event loop

The command's exit is noticed on the goroutine reading the terminal, which may not touch a buffer or the desktop. So it sets a flag, and the reload runs at the top of the next turn of the event loop.

This is the fourth thing in the library built that way — the language-server announcement, the terminal redraws, the autosave deadline, and now this. The rule they share is worth stating once more: the wake-up may be lost, so the state must not be. PostEvent drops what does not fit in its queue, so anything that depends on a message arriving is a bug waiting for a busy moment. A flag the loop checks for itself cannot go missing.

Why a command can ask for a value, and why it asks in double braces

uv venv needs a directory. uv run needs a script. uv add needs a package name, and pytest -k needs a pattern. None of those can live in the tools file as a fixed string, because the answer is different every time — and a tool that cannot ask is a tool that has to be edited before each use, which is not a tool.

So a {{label}} in a command is a value the editor asks for first, in a box titled after the tool. Two of the six starter commands use it, which is deliberate: a feature demonstrated in the file everybody gets is a feature people find, and one described only in a comment is not.

The first of them is the reason this editor's tools file has six commands rather than five. Creating the virtual environment is the step in Python that has to happen before any of the others can, and the one a newcomer to a project most often has not done — so it is the first item in the menu, and it asks where to put it, because .venv is the usual answer and not the only one.

Single braces were the obvious spelling and are wrong. awk '{print $1}' and find . -exec rm {} + are ordinary things to put in a tools file, and reading the first as a placeholder turns a working command into a box asking for "print $1". Double braces collide with almost nothing, and the one construct they do collide with — a nested block in awk — is rare enough to be written down rather than designed around.

The value is quoted by default, because the alternative fails silently. A path with a space in it, substituted raw, becomes two arguments and the command reports something about a file that does not exist. Quoting makes that case work and makes the other case — "put these three flags on the end" — impossible, so ... inside the braces asks for the value verbatim. Two behaviours, both documented, rather than one that is wrong half the time.

Nothing is remembered on disk. The box starts from what was typed last time, for the session. Writing it into the project's own directory was considered and rejected: that directory holds what the project decided, and a filter somebody typed while chasing one test is not that. It would also be the first thing in there that changes without anybody editing it.

A file that cannot be parsed is refused when it is read, not when the tool is chosen. An unclosed {{ reaching the shell is a command failing with braces in it, which names neither the tool nor the file; refusing at load names both. That is the same rule an unknown output value already follows.

The dialog is refused when it will not fit. A tool asking for more values than the terminal has rows would give a box whose OK button is below the bottom of the screen — answerable only by Escape, which cancels. Saying "this asks for twelve values and nine fit" is worse than nothing only if you would rather find out by trying.

How it relates to the rest

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# Python tools — explanation

## What is this about?

A **Python** menu whose commands come from a TOML file, each run in a terminal window, and the open files re-read afterwards. This page is about why each of those three is the way it is.

## Why the output has three places to go, and a popup by default

The first version put every command in a terminal window, and it was the wrong default for five of the six.

A terminal is the right answer when the program is *interactive or long*: `uv run` on a script that calls `input()` has to be answerable, and a `uv sync` that turns out to resolve half of PyPI has to be interruptible with `Ctrl-C`. Neither is true of `uv run ruff check .`, which prints four lines and ends. Giving that a whole window — one you then have to close, on a desktop where windows overlap and are numbered — is more ceremony than the result deserves.

A popup is the right answer for a command you run, read and dismiss. It is modal, which is a real cost and is named in the [how-to](../how-to/run-uv-commands.md): a `uv sync` you did not expect to be slow holds the editor until it finishes or you press Escape. That cost was accepted on purpose, because the alternative — a dialog appearing unbidden three seconds later — swallows whatever was being typed at the moment it arrives.

So the popup **opens immediately and fills in**. You see progress, nothing surprises you, and Escape both closes it and stops the command, which is the only way to interrupt something whose output is not in a terminal.

An editing window is the right answer for output you are going to work through: a long `uv run pytest -v`, a coverage report. It is an ordinary buffer, so `Ctrl-F` searches it and `Save as` keeps it. It is filled once the command has ended rather than as it goes, because a buffer growing under the cursor while you search it is the opposite of what that mode is for.

None of those three is right for everything, which is why `output` is in the file rather than in the code. `Run` is the worked example: it is the one command in the starter file that says `terminal`, and the comment beside it says why.

## Why a terminal window is still there

The editor already had one — a real pseudo-terminal with a VT emulator, built for the `F8` windows — so `output = "terminal"` costs one field on its options and buys colours, paging, `Ctrl-C`, keyboard input and scrollback for nothing, because they are the same mechanisms every other terminal uses.

The window stays after the command exits, which is the point: the output is what you asked for, and a window that vanished with it would be useless.

That needed a fix of its own. A terminal view consumed every key it was given and wrote it to the shell; once the shell had gone the write failed silently and the key was consumed anyway, so `Ctrl-W` could never close a finished window and the mouse was the only way out. A finished view now takes only the scrolling keys and lets the rest through to the editor.

## Why the exit code is always in the title

`uv sync` succeeding prints nothing at all. A popup with an empty body and a neutral title is indistinguishable from one whose command has not started, and the reader is left guessing at the one thing they wanted to know.

So the title carries the verdict — `— ok` or `— exit 1` — and an empty body says `(no output)` once the command has ended. While it is still running the body stays blank, because "(no output)" is a verdict and a running command has not reached one.

## Why the commands are in a file

Six commands hardwired into the editor would have answered the request. They would also have been wrong within a week.

Every command in the starter file goes through `uv`, which creates the environment, resolves the dependencies and runs the tools inside it — so none of them needs an environment to have been activated first. That is a defensible default and it is nobody's universal answer. A project on Poetry wants `poetry run`. One on pip and a hand-made `.venv` wants the bare command, with the environment already on PATH. One that has standardised on `black` and `flake8` wants those rather than `ruff`. `uv run pytest` assumes pytest; a project on `unittest` wants `python -m unittest`. A project with a `Makefile` wants `make check`. None of that is knowable from here, and all of it is one line in a file.

So the six are **defaults, not code**: they are the contents of the starter file that **Python ▸ Create tools file** writes, and changing one is editing a file rather than rebuilding an editor. The file is read every time the menu opens, for the same reason the Snippets menu is: an edit should take effect at once, and the file is often open in the window behind the menu.

Commands go to `sh -c``cmd.exe /S /C` on Windows — rather than being split into an argv here. The file is the user's own, so pipes, globs and `&&` are features rather than hazards, and one entry can be `uv run ruff format . && uv run ruff check . && uv run pytest`. Splitting an argv would mean inventing quoting rules for a string somebody wrote by hand.

## Why there is no user-level tools file

Snippets are read from two files — yours and the project's — because your snippets are your habits and should follow you between projects.

Tools are not like that. They belong to a project's own toolchain: a global tools file would offer `uv run pytest` in a repository that has never heard of uv, and a project pinned to Poetry would get somebody else's habits in its menu. The file is per-project, and that is the whole of the rule.

## Why a tool may name its own menu

A menu called **Python** holding `docker compose up` is a lie about what the menu is. The first tools file anybody writes outgrows Python, because a project's commands are not all about the language it is written in: containers, databases, deploys, a `Makefile` target somebody added in 2019.

Two shapes were considered. A **fixed second menu** called Tools — everything Python in Python, everything else in Tools — is one key in the format and no naming problem at all, but it only moves the lie: a Tools menu holding `docker compose up`, `psql`, and a deploy script is just as undifferentiated, and the moment there are ten entries nobody can find one. And a **second file**, `menus.toml`, keeps the tools file simple at the cost of two files that have to agree about which tools exist.

So the menu is a **free-form name on the tool**, in the one file: `menu = "Docker"`. A name nothing else uses creates the menu; leaving the key out means Python. There is no list of allowed names, because a list would be a list of somebody else's projects.

Python itself stays fixed on the bar rather than becoming just another name from the file. **Python ▸ Create tools file** has to be reachable in a project that has no tools file at all — which is exactly the project that needs it — and a menu that only exists once the file exists cannot offer to write the file.

## Why the hot key is not the file's to choose

The author of a tools file cannot know which letters are free. They can see `File`, `Edit`, `Search`, `Run`, `Code`, `Options`, `Window`, `Snippets`, `Python` and `Help` on the bar, but only by counting the underlines, and a project shared between people would then depend on nobody adding a menu that collides.

Collisions here are **silent**, which is what makes them worth designing against. The bar answers the first menu whose hot key matches; a second menu claiming the same letter is not an error and draws normally — it simply never opens. That trap has already been sprung once in this editor: `Snippets` and `Search` both wanted `S`, `Snippets` was the unreachable one, and every test passed. The fix then was to move Snippets to `N` by hand. Letting a file name menus makes that a permanent hazard rather than a one-off mistake, so the assignment is done by the editor: the first letter of the name nothing else claims.

Tildes written into the name are honoured **when the letter is free**, and quietly overridden when it is not. Refusing the file instead was the alternative, and it is worse: the clash depends on which menus exist, so a tools file that worked would break the day an editor release added a menu. Between a menu on a letter you did not ask for and a menu you cannot open, the first is the smaller loss.

When every letter of a name is taken, the menu gets no hot key at all. `F10`, the arrow keys and the mouse still reach it, and the alternative — reaching for a letter that is not in the name — would put an underline under nothing.

## Why the bar is rebuilt from a stat

`Menu.OnOpen` refills a menu's items just before it drops down, which is how the Python and Snippets menus follow their files without a restart. It cannot help here: the *set* of menus is part of the bar, not part of any one menu, and adding `menu = "Docker"` to the file should put Docker on the bar.

Reading and parsing the file on every turn of the event loop would do it, and would also be work done for nothing on every keystroke of a file nobody has edited. So the bar carries the size and modification time of the tools file it was built from, and one `stat` per turn decides whether to rebuild. Editing the file in the window in front of you, saving it, and watching the bar change is the case this is for.

## Why open files are re-read, and only some of them

`Format` rewrites files on disk — including the one you are looking at. Without anything further, the editor would sit on a stale copy, and the next `F2` would write your unformatted version back over `ruff format`'s work. That is not a rough edge; it is the feature quietly undoing itself.

So when a command finishes, the editor re-reads every open file. The interesting part is which ones it refuses to touch.

**A file with unsaved changes is left alone**, and the status bar says how many were skipped. Reloading it would throw away work the user has not saved, which no amount of convenience justifies. And the conflict is genuine: the formatter and the unsaved edit disagree about what the file should say, and the editor is not in a position to decide. Naming it and stopping is the honest outcome — the user can save and re-run, or keep editing and format later.

Two smaller decisions inside that:

- **The cursor stays where it was**, clamped into whatever the file now holds. A formatter moves lines about; putting the cursor back at the top would lose the reader's place for no reason.
- **The undo history is discarded.** Undoing back past a reload would restore text the file no longer has, which is worse than not being able to undo at all.

## Why the reload happens on the event loop

The command's exit is noticed on the goroutine reading the terminal, which may not touch a buffer or the desktop. So it sets a flag, and the reload runs at the top of the next turn of the event loop.

This is the fourth thing in the library built that way — the language-server announcement, the terminal redraws, the autosave deadline, and now this. The rule they share is worth stating once more: **the wake-up may be lost, so the state must not be.** `PostEvent` drops what does not fit in its queue, so anything that depends on a message arriving is a bug waiting for a busy moment. A flag the loop checks for itself cannot go missing.

## Why a command can ask for a value, and why it asks in double braces

`uv venv` needs a directory. `uv run` needs a script. `uv add` needs a package name, and `pytest -k` needs a pattern. None of those can live in the tools file as a fixed string, because the answer is different every time — and a tool that cannot ask is a tool that has to be edited before each use, which is not a tool.

So a `{{label}}` in a command is a value the editor asks for first, in a box titled after the tool. **Two of the six starter commands use it**, which is deliberate: a feature demonstrated in the file everybody gets is a feature people find, and one described only in a comment is not.

The first of them is the reason this editor's tools file has six commands rather than five. **Creating the virtual environment** is the step in Python that has to happen before any of the others can, and the one a newcomer to a project most often has not done — so it is the first item in the menu, and it asks where to put it, because `.venv` is the usual answer and not the only one.

**Single braces were the obvious spelling and are wrong.** `awk '{print $1}'` and `find . -exec rm {} +` are ordinary things to put in a tools file, and reading the first as a placeholder turns a working command into a box asking for "print $1". Double braces collide with almost nothing, and the one construct they do collide with — a nested block in awk — is rare enough to be written down rather than designed around.

**The value is quoted by default**, because the alternative fails silently. A path with a space in it, substituted raw, becomes two arguments and the command reports something about a file that does not exist. Quoting makes that case work and makes the other case — "put these three flags on the end" — impossible, so `...` inside the braces asks for the value verbatim. Two behaviours, both documented, rather than one that is wrong half the time.

**Nothing is remembered on disk.** The box starts from what was typed last time, for the session. Writing it into the project's own directory was considered and rejected: that directory holds what the project decided, and a filter somebody typed while chasing one test is not that. It would also be the first thing in there that changes without anybody editing it.

**A file that cannot be parsed is refused when it is read**, not when the tool is chosen. An unclosed `{{` reaching the shell is a command failing with braces in it, which names neither the tool nor the file; refusing at load names both. That is the same rule an unknown `output` value already follows.

**The dialog is refused when it will not fit.** A tool asking for more values than the terminal has rows would give a box whose OK button is below the bottom of the screen — answerable only by Escape, which cancels. Saying "this asks for twelve values and nine fit" is worse than nothing only if you would rather find out by trying.

## How it relates to the rest

- Every key of the file and every rule: [Python tools reference](../reference/python-tools.md)
- Using it: [How to run uv commands from the editor](../how-to/run-uv-commands.md)
- The windows `output = "terminal"` uses, and why they are real terminals: [Terminal windows](terminal-windows.md)
- The other menu built from a file: [Snippets](snippets.md)