Define themes as JSON data, loadable from files and packages - #15356
Open
Carreau wants to merge 1 commit into
Open
Define themes as JSON data, loadable from files and packages#15356Carreau wants to merge 1 commit into
Carreau wants to merge 1 commit into
Conversation
Carreau
marked this pull request as ready for review
August 5, 2026 08:27
IPython's themes were Python dicts mapping pygments token objects to style strings, built eagerly when `IPython.utils.PyColorize` was imported. Adding one meant editing IPython's source. Store them as JSON in `IPython/utils/themes/` instead, with token types written as dotted paths (`Token.Prompt.Continuation.L1` becomes `"Prompt.Continuation.L1"`), and resolve them back to pygments tokens only when a theme is looked up. `Theme.from_dict` and `Theme.from_file` do the conversion. The shipped themes are byte for byte the same data as before; only how they are stored has changed. `theme_table` becomes a lazy Mapping that reads a theme's JSON on first access, and looks beyond the built-ins in two places: - `themes/` inside the IPython directory, so `~/.ipython/themes/my-theme.json` is the `my-theme` theme with nothing else to write; - the new `ipython.themes` entry point group, so a theme can be installed. The entry point is named after the theme and its value says where the JSON lives, `my_themes` or `my_themes:some.subdir`. Such a package contains no code: IPython reads its data and never imports an object from it or calls into it. Neither is consulted until a lookup misses the built-ins, so the `importlib.metadata` scan and the IPython directory both stay off the startup path. Built-in names win, then the IPython directory, then an installed package, so `%colors linux` always means the linux IPython ships. The former module level theme objects (`linux_theme`, ...) remain reachable through a module `__getattr__`. Because a theme is now data that can arrive from elsewhere, it is checked before it loads, and refused with a warning if it fails: - Token paths must match `[A-Z]\w*` per segment, which is what pygments itself tests to decide whether an attribute access names a subtoken. Without that, a key like `"split.__globals__"` resolved to whatever `getattr` found and walked off the token graph. Not exploitable -- attribute reads alone give no call or subscript primitive -- but a theme file should be inert by construction rather than by argument. - Symbols, which are written straight to the terminal, must be at most 20 printable characters, so a theme cannot smuggle in an escape sequence and set the window title, move the cursor or read back the clipboard. `str.isprintable()` is the obvious check and is wrong here: it rejects the private use area, where Powerline separators and Nerd Font glyphs live, which is exactly what a theme wants an arrow head to be. Rejecting categories Cc, Cf, Cs, Cn, Zl and Zp covers what an escape sequence is built from, and Cf also blocks the bidi overrides. - A theme name becomes a file name, so it must not be able to name a directory: `files(pkg) / "../../../x.json"` resolves outside the package rather than failing. `theme_table` is a Mapping rather than a dict, so it can no longer be mutated with `__setitem__`; nothing in the tree did. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WpbtBfiDvcz1AgGXPiok3b
Carreau
force-pushed
the
claude/ipython-theme-json-mapping-njuyfx
branch
from
August 5, 2026 08:32
5ab0b5c to
454bbe7
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Themes were Python dicts mapping pygments token objects to style strings, built eagerly at
IPython.utils.PyColorizeimport. Adding one meant editing IPython's source — whichdocs/source/config/details.rstalready apologised for:This does that.
Themes are data
Each theme is a JSON file in
IPython/utils/themes/. Token types are dotted paths with the leadingToken.dropped:{ "name": "gruvbox-dark", "base": "gruvbox-dark", "extra_style": { "Prompt": "#689D6A", "Prompt.Continuation.L1": "#D79921" }, "symbols": { "top_line": "─", "arrow_body": "─", "arrow_head": "▶" } }Theme.from_dict/Theme.from_filedo the conversion, resolving tokens only when a theme is actually looked up.The shipped themes are unchanged. The JSON was generated from the existing definitions rather than transcribed, then all nine themes were diffed against a pre-change snapshot: zero differences.
Three places a theme can come from
~/.ipython/themes/my-theme.jsonand%colors my-themeworks, no code to write. Listed on each lookup, so a file added mid-session is found without restarting.ipython.themesentry point group. The entry point is named after the theme; its value says where the JSON lives:A theme package contains no code — JSON files and an empty
__init__.py. IPython reads the data and never imports an object from it or calls into it.Neither external source is consulted until a lookup misses the built-ins, so the ~25 ms
importlib.metadatascan and the IPython directory both stay off the startup path. Precedence is bundled → IPython directory → installed package, so%colors linuxalways means the linux IPython ships.Validation
A theme is now data that can arrive from elsewhere, so it is checked before it loads and refused with a warning if it fails.
Token paths must match
[A-Z]\w*per segment — what pygments itself tests to decide whether an attribute access names a subtoken. Without it, a key like"split.__globals__"or"__class__.__base__"resolved to whatevergetattrfound and walked off the token graph into the pygments module globals. Not exploitable (attribute reads alone give no call or subscript primitive), but a theme file should be inert by construction rather than by argument.Symbols are written straight to the terminal, and
make_arrowrepeatsarrow_bodybesides, so they must be at most 20 printable characters — otherwise a theme could smuggle in an escape sequence and set the window title, move the cursor, or read back the clipboard via OSC 52.str.isprintable()is the obvious check and is wrong here: it rejects the private use area, where Powerline separators and Nerd Font glyphs live, which is exactly what someone wants an arrow head to be. Rejecting unicode categoriesCc,Cf,Cs,Cn,Zl,Zpcovers what an escape sequence is built from while leaving private use alone;Cfalso blocks the bidi overrides.Theme names become file names, so they must not name a directory —
files(pkg) / "../../../x.json"resolves outside the package rather than failing.Notes for review
theme_tableis a lazyMappingrather than adict. Lookup, iteration and.keys()work as before; it can no longer be mutated with__setitem__, which nothing in the tree did.linux_theme,neutral_theme, …) stay reachable through a module__getattr__, without building every theme on import. Nothing in-tree used them.pygments.tokencosts 0.56 ms and building all nine themes well under 1 ms. The two expensive imports (pygments.stylesat 27 ms,pygments.formattersat 29 ms) were already deferred. This is for extensibility.pyproject.tomlgains"IPython.utils" = ["themes/*.json"];MANIFEST.in'sgraft IPythoncovers the sdist.Verified end to end against a genuinely pip-installed theme package and a real
IPYTHONDIR, not just stubs. Tests are intests/test_themes.py; the validation guards were checked by mutation — disabling any one of them fails between 3 and 16 tests.