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
Carreau
force-pushed
the
claude/ipython-theme-json-mapping-njuyfx
branch
from
August 5, 2026 08:32
5ab0b5c to
454bbe7
Compare
Carreau
force-pushed
the
claude/ipython-theme-json-mapping-njuyfx
branch
from
August 5, 2026 10:58
454bbe7 to
5363a84
Compare
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. 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, Zl and Zp covers what an escape sequence is built from, and Cf also blocks the bidi overrides. Cn (unassigned) is deliberately not rejected: it would make a symbol's acceptance depend on how old the running Python's unicode database is, since a new emoji is Cn until Python catches up. - A theme is always stored as `_theme_filename` of its name, both for the file a name is looked up in and for the name a file in the IPython directory is known by. That keeps the two agreeing, and stops a name from reaching outside its package: `files(pkg) / "../../../x.json"` resolves rather than failing. Two incompatibilities, neither of which anything in the tree relied on: `theme_table` is a Mapping rather than a dict, so it cannot be mutated with `__setitem__`, and the individual themes are no longer module level objects, so use `theme_table["linux"]` over `PyColorize.linux_theme`. Replace `functools.cache` on `Theme.as_pygments_style` and `_get_formatter` with caching on the instance. `cache` keys on `self`, so it kept every Theme ever built alive -- harmless while themes were nine module singletons, a leak now that anything can build one. 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 7, 2026 10:38
5363a84 to
cf230a3
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,Zl,Zpcovers what an escape sequence is built from while leaving private use alone;Cfalso blocks the bidi overrides.Cn(unassigned) is deliberately not rejected — it would make a symbol's acceptance depend on how old the running Python's unicode database is, since a new emoji isCnuntil Python catches up.Names: a theme is always stored as
_theme_filenameof its name, both for the file a name is looked up in and for the name a file in the IPython directory is known by. That keeps the two agreeing in either direction, and stops a name reaching outside its package —files(pkg) / "../../../x.json"resolves rather than failing.Incompatibilities
Neither is relied on anywhere in the tree:
theme_tableis a lazyMappingrather than adict. Lookup, iteration and.keys()work as before; it can no longer be mutated with__setitem__.theme_table["linux"]rather thanPyColorize.linux_theme.Notes for review
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.functools.cacheonTheme.as_pygments_style/_get_formatterkeyed onself, keeping everyThemealive forever. Harmless while themes were nine module singletons; a leak now that anything can build one. Now cached on the instance, with no call sites changed.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.