patch-cc 0.2.2__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. {patch_cc-0.2.2 → patch_cc-0.2.3}/PKG-INFO +1 -1
  2. {patch_cc-0.2.2 → patch_cc-0.2.3}/docs/PLAYBOOK.md +41 -11
  3. {patch_cc-0.2.2 → patch_cc-0.2.3}/pyproject.toml +1 -1
  4. {patch_cc-0.2.2 → patch_cc-0.2.3}/src/patch_cc/__init__.py +1 -1
  5. {patch_cc-0.2.2 → patch_cc-0.2.3}/src/patch_cc/bun/__init__.py +1 -1
  6. {patch_cc-0.2.2 → patch_cc-0.2.3}/src/patch_cc/bun/container.py +2 -2
  7. {patch_cc-0.2.2 → patch_cc-0.2.3}/src/patch_cc/bun/elf.py +4 -4
  8. {patch_cc-0.2.2 → patch_cc-0.2.3}/src/patch_cc/bun/macho.py +2 -1
  9. {patch_cc-0.2.2 → patch_cc-0.2.3}/src/patch_cc/cli.py +3 -3
  10. {patch_cc-0.2.2 → patch_cc-0.2.3}/src/patch_cc/codex/gateway.py +4 -2
  11. {patch_cc-0.2.2 → patch_cc-0.2.3}/src/patch_cc/codex/oauth.py +1 -1
  12. {patch_cc-0.2.2 → patch_cc-0.2.3}/src/patch_cc/locate.py +1 -1
  13. {patch_cc-0.2.2 → patch_cc-0.2.3}/src/patch_cc/menu.py +9 -8
  14. {patch_cc-0.2.2 → patch_cc-0.2.3}/src/patch_cc/patcher.py +1 -1
  15. {patch_cc-0.2.2 → patch_cc-0.2.3}/src/patch_cc/patches/agents.py +1 -1
  16. {patch_cc-0.2.2 → patch_cc-0.2.3}/src/patch_cc/patches/base.py +5 -5
  17. {patch_cc-0.2.2 → patch_cc-0.2.3}/src/patch_cc/patches/codex.py +2 -6
  18. {patch_cc-0.2.2 → patch_cc-0.2.3}/src/patch_cc/patches/streaming.py +147 -296
  19. {patch_cc-0.2.2 → patch_cc-0.2.3}/src/patch_cc/ui.py +1 -1
  20. patch_cc-0.2.3/uv.lock +422 -0
  21. patch_cc-0.2.2/uv.lock +0 -358
  22. {patch_cc-0.2.2 → patch_cc-0.2.3}/.github/workflows/ci.yml +0 -0
  23. {patch_cc-0.2.2 → patch_cc-0.2.3}/.github/workflows/release.yml +0 -0
  24. {patch_cc-0.2.2 → patch_cc-0.2.3}/.gitignore +0 -0
  25. {patch_cc-0.2.2 → patch_cc-0.2.3}/AGENTS.md +0 -0
  26. {patch_cc-0.2.2 → patch_cc-0.2.3}/CLAUDE.md +0 -0
  27. {patch_cc-0.2.2 → patch_cc-0.2.3}/LICENSE +0 -0
  28. {patch_cc-0.2.2 → patch_cc-0.2.3}/README.md +0 -0
  29. {patch_cc-0.2.2 → patch_cc-0.2.3}/docs/CONDUCT.md +0 -0
  30. {patch_cc-0.2.2 → patch_cc-0.2.3}/docs/INTERNALS.md +0 -0
  31. {patch_cc-0.2.2 → patch_cc-0.2.3}/docs/demo.gif +0 -0
  32. {patch_cc-0.2.2 → patch_cc-0.2.3}/src/patch_cc/bun/blob.py +0 -0
  33. {patch_cc-0.2.2 → patch_cc-0.2.3}/src/patch_cc/bun/errors.py +0 -0
  34. {patch_cc-0.2.2 → patch_cc-0.2.3}/src/patch_cc/cache.py +0 -0
  35. {patch_cc-0.2.2 → patch_cc-0.2.3}/src/patch_cc/codex/__init__.py +0 -0
  36. {patch_cc-0.2.2 → patch_cc-0.2.3}/src/patch_cc/codex/models.py +0 -0
  37. {patch_cc-0.2.2 → patch_cc-0.2.3}/src/patch_cc/codex/translate.py +0 -0
  38. {patch_cc-0.2.2 → patch_cc-0.2.3}/src/patch_cc/doctor.py +0 -0
  39. {patch_cc-0.2.2 → patch_cc-0.2.3}/src/patch_cc/patches/__init__.py +3 -3
  40. {patch_cc-0.2.2 → patch_cc-0.2.3}/src/patch_cc/patches/chrome.py +0 -0
  41. {patch_cc-0.2.2 → patch_cc-0.2.3}/src/patch_cc/patches/output.py +0 -0
  42. {patch_cc-0.2.2 → patch_cc-0.2.3}/src/patch_cc/patches/thinking.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patch-cc
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Interactive patcher for the Claude Code native binary
5
5
  Project-URL: Homepage, https://github.com/anfreire/patch-cc
6
6
  Project-URL: Repository, https://github.com/anfreire/patch-cc
@@ -46,6 +46,27 @@ constantly. These rules are what keep matchers alive:
46
46
  every backup before removing any part of it: under the older, tighter callee
47
47
  that same probe read 1 on all 8 builds, so a measurement taken before the
48
48
  callee was dissolved does not survive it.
49
+ - **Rewrite at the dispatch point; never model the arm.** When an update is
50
+ valid at arm entry, splice it in where the code dispatches on the semantic
51
+ string — after a `case` label chain, or by wrapping the dispatch test
52
+ itself, `(test&&((update),!0))`, which preserves the test's value exactly
53
+ and so survives any surrounding composition (wrapping the *enclosing*
54
+ condition instead would run the update for the other side of a bare `||`) —
55
+ and say nothing about what the arm already does. Arm bodies are upstream's
56
+ busiest surface: 2.1.226 threaded one flag through five arms of the stream
57
+ reducer (`stmt1,stmt2;` became `if(stmt1,stmt2,d)call;`) and appended a
58
+ declarator to the head's `let`, and every literal that had modelled those
59
+ bodies — a cross-product of `mode(` vs `mode?.(`, braced vs unbraced arms,
60
+ four `displayTransform` spellings — died or was already dead weight. The
61
+ dispatch strings themselves (`"thinking_delta"`, `"message_stop"`) are the
62
+ API's vocabulary and do not churn. Same doctrine as the callee rule above:
63
+ the arm is *reached through*, the dispatch point is what the update is
64
+ *applied to*. Entry insertion does reorder against mid-arm work (the
65
+ message-stop reset now runs before upstream's `finalize()`), so it asks
66
+ that the update be independent of the arm's own channels — decide that,
67
+ don't assume it. Bound heads at grammar edges for the same reason — in a
68
+ minified bundle only `;` or `,` can follow `let{…}=t`, and which one is
69
+ noise (`(?=[;,])`, not `;`).
49
70
  - Match an optional brace **pair** conditionally (`(\{)?…(?(1)\})`), never as
50
71
  two independent `\{?` / `\}?`. A lone optional closing brace eats the
51
72
  *enclosing* block's `}` on the unbraced shape, and a rewrite that emits its
@@ -174,7 +195,7 @@ first is how a manifest starts lying. The manifest is held to the same rule:
174
195
 
175
196
  ## Sub-steps, and why `live-thinking` has them
176
197
 
177
- Most patches are one rewrite. `live-thinking` is ~11 named sub-steps, because
198
+ Most patches are one rewrite. `live-thinking` is ~20 named sub-steps, because
178
199
  upstream has reshaped the stream reducer at least three times and a single hit
179
200
  count cannot tell "all landed" from "half silently drifted".
180
201
 
@@ -183,9 +204,16 @@ kinds:
183
204
 
184
205
  - **Independent fixes** (`memo-cache`, `linger`, `display-mode`, `bottom-row`,
185
206
  …) — each may or may not be present on a given build.
186
- - **Mutually-exclusive reducer variants** — `reducer-destructured` (2.1.138+),
187
- `reducer-inner` (2.1.183+), `reducer-legacy` (pre-2.1.138). On any one build
188
- exactly one should land. On 2.1.216 it is `reducer-inner`.
207
+ - **Mutually-exclusive reducer variants** — `reducer-options` (2.1.138+, the
208
+ options-bag head; whether the bag still destructures `onStreamingThinking`
209
+ is the same shape with the prop present or absent, not a second variant) and
210
+ `reducer-legacy` (pre-2.1.138, positional parameters). On any one build
211
+ exactly one should land; its count is *recognition*, one per reducer. The
212
+ state updates themselves are per-dispatch-point steps (`request-start`,
213
+ `message-stop`, `text-clear`, `message-delta-clear`, `thinking-start`,
214
+ `thinking-append`), so a build that folds one arm away reads as that point's
215
+ absence by name, never as a bare count drop inside an aggregate. On
216
+ 2.1.216–2.1.226 the variant is `reducer-options` with all six points.
189
217
 
190
218
  An *optional* sub-step that finds nothing is reported as absent, not broken —
191
219
  it is just a shape this build doesn't have. A sub-step that finds a shape but
@@ -201,11 +229,11 @@ The rest carry expectations, so their absence is checked rather than noted:
201
229
  but *proof*: they are credited only when the rewritten reducer body actually
202
230
  contains the state updates, which is what recognising a reducer does not by
203
231
  itself establish.
204
- - **The `reducer` group** — `reducer-destructured`, `reducer-inner`,
205
- `reducer-legacy`. At least one must land; none landing is the signal that
206
- upstream shipped a fourth reducer shape that needs a new variant. Two landing
207
- is allowed on purpose — a transitional build carrying two reducers would have
208
- both correctly patched, and that is no reason to cry wolf.
232
+ - **The `reducer` group** — `reducer-options`, `reducer-legacy`. At least one
233
+ must land; none landing is the signal that upstream shipped a new reducer
234
+ head that needs a new variant. Two landing is allowed on purpose — a
235
+ transitional build carrying two reducers would have both correctly patched,
236
+ and that is no reason to cry wolf.
209
237
  - **Optional** — `memo-cache`, `memo-removal`, `linger`, `bottom-row`,
210
238
  `final-summary`. The first four match nothing on 2.1.216+ and are kept for
211
239
  older builds; `discover` is a notes-only channel that records how far back the
@@ -354,11 +382,13 @@ for you. Each entry: what it changes, the stable anchor, and where it lives.
354
382
 
355
383
  ### Live thinking — `streaming.py`
356
384
 
357
- - **`live-thinking`** — the ~11-step patch above. Discovery anchor is
385
+ - **`live-thinking`** — the ~20-step patch above. Discovery anchor is
358
386
  `onStreamingThinking:` → `useState(null)` (the older `hidePastThinking`
359
387
  anchor is gone as of 2.1.216 — the fallback back-scan is load-bearing).
360
388
  Reducer anchors: `type==="stream_request_start"`, `case"thinking_delta"`,
361
- `content_block_start`.
389
+ `content_block_start` — the reducer is the function carrying all three, its
390
+ head bounded at the declarator edge, and every state update is an insertion
391
+ at a dispatch point (the rule above); the arm bodies are never matched.
362
392
  The `display-mode` sub-step defaults the request's thinking display to
363
393
  `"summarized"`; without it the API only streams summary text when the
364
394
  `showThinkingSummaries` setting is on. Two shapes: the legacy inline env
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "patch-cc"
7
- version = "0.2.2"
7
+ version = "0.2.3"
8
8
  description = "Interactive patcher for the Claude Code native binary"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,3 +1,3 @@
1
1
  """patch-cc: an interactive patcher for the Claude Code native binary."""
2
2
 
3
- __version__ = "0.2.2"
3
+ __version__ = "0.2.3"
@@ -3,4 +3,4 @@
3
3
  from .container import Bundle, ContainerError, read, write
4
4
  from .errors import BunError
5
5
 
6
- __all__ = ["Bundle", "BunError", "ContainerError", "read", "write"]
6
+ __all__ = ["BunError", "Bundle", "ContainerError", "read", "write"]
@@ -85,7 +85,7 @@ def write(
85
85
  compared against ``source`` -- *before* it is moved into place. A rebuild
86
86
  bug therefore fails without ever touching the live binary.
87
87
  """
88
- import shutil # noqa: PLC0415
88
+ import shutil
89
89
 
90
90
  new_blob = blobmod.rebuild(
91
91
  bundle.blob, source.encode("utf8"), drop_bytecode=drop_bytecode
@@ -120,7 +120,7 @@ def verify(path: str, expected: str) -> None:
120
120
  """Re-extract from a written binary and assert it round-trips exactly."""
121
121
  try:
122
122
  written = read(path)
123
- except Exception as exc: # noqa: BLE001
123
+ except Exception as exc:
124
124
  raise ContainerError(f"patched binary could not be re-read: {exc}") from exc
125
125
  if written.source != expected:
126
126
  raise ContainerError(
@@ -224,11 +224,11 @@ def write_section(buf: bytes, payload: bytes, name: str = ".bun") -> bytes:
224
224
  and seg.filesz
225
225
  and seg.offset >= end
226
226
  and seg.align
227
+ and (seg.offset + delta) % seg.align != seg.vaddr % seg.align
227
228
  ):
228
- if (seg.offset + delta) % seg.align != seg.vaddr % seg.align:
229
- raise ElfError(
230
- f"shifting LOAD segment {seg.index} would break its alignment"
231
- )
229
+ raise ElfError(
230
+ f"shifting LOAD segment {seg.index} would break its alignment"
231
+ )
232
232
 
233
233
  new_size = bun.size + delta
234
234
  body = bytearray(payload)
@@ -27,7 +27,7 @@ class MachOError(BunError):
27
27
 
28
28
  def _lief():
29
29
  try:
30
- import lief # noqa: PLC0415
30
+ import lief
31
31
  except ImportError as exc: # pragma: no cover - platform dependent
32
32
  raise MachOError(
33
33
  "LIEF is required to patch macOS binaries. Install it with "
@@ -107,6 +107,7 @@ def codesign(path: str) -> None:
107
107
  ["codesign", "--sign", "-", "--force", path],
108
108
  capture_output=True,
109
109
  text=True,
110
+ check=False,
110
111
  )
111
112
  if result.returncode != 0: # pragma: no cover - macOS only
112
113
  raise MachOError(f"codesign failed: {result.stderr.strip()}")
@@ -36,7 +36,7 @@ from pathlib import Path
36
36
  from rich.markup import escape
37
37
 
38
38
  from . import cache, locate, patcher
39
- from .bun import BunError, Bundle
39
+ from .bun import Bundle, BunError
40
40
  from .codex import DEFAULT_PORT, is_valid_port
41
41
  from .codex.models import CodexModel, catalogue, is_reserved_id, is_valid_id, reconcile
42
42
  from .patches import (
@@ -549,7 +549,7 @@ def _doctor_target(path: str | None) -> tuple[Bundle, str] | None:
549
549
  given -- that is how any kept backup becomes a regression corpus -- while the
550
550
  installed binary falls back to its pristine copy when it is already patched.
551
551
  """
552
- from .bun import container # noqa: PLC0415
552
+ from .bun import container
553
553
 
554
554
  if path is not None:
555
555
  # A name the user typed is data, not markup: an unescaped `claude[old]`
@@ -695,7 +695,7 @@ def _baked_port() -> int | None:
695
695
  why ``serve`` asks once at startup rather than per request -- and why a re-bake
696
696
  onto a different port needs a ``serve`` restart, as the apply report says.
697
697
  """
698
- from .bun import container # noqa: PLC0415
698
+ from .bun import container
699
699
 
700
700
  install = locate.find()
701
701
  if install is None:
@@ -155,7 +155,7 @@ class _Handler(BaseHTTPRequestHandler):
155
155
  def log_message(self, *_args) -> None: # keep the terminal clean
156
156
  pass
157
157
 
158
- def do_POST(self) -> None: # noqa: N802 (BaseHTTPRequestHandler's name)
158
+ def do_POST(self) -> None:
159
159
  # One request per connection: an SSE reply is open-ended (no
160
160
  # Content-Length), so the stream ends by connection close.
161
161
  self.close_connection = True
@@ -177,7 +177,9 @@ class _Handler(BaseHTTPRequestHandler):
177
177
  # this API just as unparseable bytes are, and is answered the same
178
178
  # way. Reaching `_messages` with one used to raise straight out of
179
179
  # the handler thread: a traceback, a reset connection, no reply.
180
- raise ValueError("not a JSON object")
180
+ raise ValueError( # noqa: TRY004 - lands in json's own handler below
181
+ "not a JSON object"
182
+ )
181
183
  except ValueError:
182
184
  return self._error(
183
185
  400, "invalid_request_error", "request body must be a JSON object"
@@ -74,7 +74,7 @@ class Credentials:
74
74
  }
75
75
 
76
76
  @classmethod
77
- def from_json(cls, data: object) -> "Credentials | None":
77
+ def from_json(cls, data: object) -> Credentials | None:
78
78
  if not isinstance(data, dict):
79
79
  return None
80
80
  access, refresh = data.get("access"), data.get("refresh")
@@ -78,7 +78,7 @@ def _resolve(path: Path) -> Installation | None:
78
78
 
79
79
  def _is_native(binary: Path) -> bool:
80
80
  """Is this file one of the containers we can actually open?"""
81
- from .bun import BunError, container # noqa: PLC0415 - keeps import order flat
81
+ from .bun import BunError, container
82
82
 
83
83
  try:
84
84
  container.detect(str(binary))
@@ -27,8 +27,9 @@ from __future__ import annotations
27
27
  import sys
28
28
  import textwrap
29
29
  import threading
30
+ from collections.abc import Callable
30
31
  from dataclasses import dataclass, field
31
- from typing import TYPE_CHECKING, Any, Callable
32
+ from typing import TYPE_CHECKING, Any
32
33
 
33
34
  from blessed import Terminal
34
35
  from rich import box
@@ -140,7 +141,7 @@ class MenuModel:
140
141
  """Everything the menu operates on, independent of the rendering engine."""
141
142
 
142
143
  install: locate.Installation
143
- status: "Status"
144
+ status: Status
144
145
  pristine: Bundle
145
146
  agents: list[BuiltinAgent]
146
147
  models: list[str]
@@ -154,8 +155,8 @@ class MenuModel:
154
155
 
155
156
  @classmethod
156
157
  def build(
157
- cls, install: locate.Installation, status: "Status", pristine: Bundle
158
- ) -> "MenuModel":
158
+ cls, install: locate.Installation, status: Status, pristine: Bundle
159
+ ) -> MenuModel:
159
160
  agents = discover_agents(pristine.source)
160
161
  models = [INHERIT, *discover_models(pristine.source)]
161
162
  model = cls(
@@ -577,7 +578,7 @@ class MenuApp:
577
578
  self.view = "select" # select | busy | report | doctor
578
579
  self.stack: list[tuple[Modal, Callable[[object], None] | None]] = []
579
580
  self.report: patcher.PatchReport | None = None
580
- self.doctor_result: "DryRun | None" = None
581
+ self.doctor_result: DryRun | None = None
581
582
  self.busy_message = ""
582
583
  self.flash = ""
583
584
  self.exit_code = 0
@@ -642,7 +643,7 @@ class MenuApp:
642
643
  attrs = termios.tcgetattr(fd)
643
644
  attrs[0] &= ~(termios.IXON | termios.IXOFF)
644
645
  termios.tcsetattr(fd, termios.TCSANOW, attrs)
645
- except Exception:
646
+ except Exception: # noqa: BLE001, S110 - best effort; no termios, no freeze risk
646
647
  pass
647
648
 
648
649
  def _key_name(self, keystroke) -> str:
@@ -1080,7 +1081,7 @@ class MenuApp:
1080
1081
 
1081
1082
  def _apply(
1082
1083
  self, selection: cache.Selection
1083
- ) -> tuple[patcher.PatchReport, "Status | None"]:
1084
+ ) -> tuple[patcher.PatchReport, Status | None]:
1084
1085
  options = selection.options
1085
1086
  if options.codex_models:
1086
1087
  # Fill in each model's name and window right before baking. A pick
@@ -1154,7 +1155,7 @@ class MenuApp:
1154
1155
  def run() -> None:
1155
1156
  try:
1156
1157
  self._worker_result = (kind, work(), None)
1157
- except Exception as exc:
1158
+ except Exception as exc: # noqa: BLE001 - the worker's failure IS the result
1158
1159
  self._worker_result = (kind, None, str(exc) or exc.__class__.__name__)
1159
1160
 
1160
1161
  threading.Thread(target=run, daemon=True).start()
@@ -326,7 +326,7 @@ def restore(install: locate.Installation) -> Path:
326
326
  "for this version was never saved -- reinstall to get a clean binary."
327
327
  )
328
328
  # A full-file copy-back, so it works for both ELF and Mach-O.
329
- from .bun.elf import atomic_write # noqa: PLC0415
329
+ from .bun.elf import atomic_write
330
330
 
331
331
  atomic_write(str(install.binary), backup.read_bytes(), mode_from=str(backup))
332
332
  return install.binary
@@ -79,7 +79,7 @@ def _subagent_prompt(content: str, _options: Options, outcome: Outcome) -> str:
79
79
  transcript_var = transcript.group(1)
80
80
  gate_pattern = compile_js(rf"{re.escape(transcript_var)}&&({IDENT})&&")
81
81
 
82
- def drop_gate(match: re.Match[str]) -> str:
82
+ def drop_gate(match: re.Match[str], segment: str = segment) -> str:
83
83
  prompt_var = match.group(1)
84
84
  nearby = segment[match.end() : match.end() + 260]
85
85
  if f"{{prompt:{prompt_var},theme:" not in nearby:
@@ -69,7 +69,7 @@ def derived_brand() -> str:
69
69
 
70
70
  try:
71
71
  user = getpass.getuser().strip()
72
- except Exception:
72
+ except Exception: # noqa: BLE001 - however it fails, the answer is the same
73
73
  user = ""
74
74
  return f"{user}'s Code" if user else DEFAULT_BRAND
75
75
 
@@ -84,7 +84,7 @@ class Options:
84
84
  #: Codex models to register and route -- ordinary patch configuration, like
85
85
  #: every field here: chosen in the menu or with ``--codex``, and recorded in
86
86
  #: the manifest by the patch that bakes it.
87
- codex_models: list["CodexModel"] = field(default_factory=list)
87
+ codex_models: list[CodexModel] = field(default_factory=list)
88
88
  #: Localhost port shared by the redirect patch and the gateway. Defaulted
89
89
  #: from the one home (:data:`patch_cc.codex.DEFAULT_PORT`) so the number
90
90
  #: never lives in two places.
@@ -116,7 +116,7 @@ class Outcome:
116
116
  applied: int = 0
117
117
  notes: list[str] = field(default_factory=list)
118
118
  #: Named sub-steps, for patches built from several independent rewrites.
119
- steps: dict[str, "Outcome"] = field(default_factory=dict)
119
+ steps: dict[str, Outcome] = field(default_factory=dict)
120
120
  #: What this sub-step's absence means (set via :meth:`step`). ``False`` --
121
121
  #: a shape some builds simply lack; ``True`` -- the patch is broken without
122
122
  #: it; a string -- name of a group of which at least one member must land.
@@ -153,7 +153,7 @@ class Outcome:
153
153
  def note(self, message: str) -> None:
154
154
  self.notes.append(message)
155
155
 
156
- def step(self, name: str, expect: bool | str = False) -> "Outcome":
156
+ def step(self, name: str, expect: bool | str = False) -> Outcome:
157
157
  """Get (or create) a named sub-outcome.
158
158
 
159
159
  A single scalar count cannot distinguish "all twelve rewrites landed"
@@ -167,7 +167,7 @@ class Outcome:
167
167
  sub.expect = expect
168
168
  return sub
169
169
 
170
- def finalize(self) -> "Outcome":
170
+ def finalize(self) -> Outcome:
171
171
  """Roll sub-step totals up into this outcome."""
172
172
  if self.steps:
173
173
  self.candidates += sum(s.candidates for s in self.steps.values())
@@ -304,12 +304,8 @@ def _register_picker(
304
304
  # because this is the only place in the picker where a model leaves Anthropic,
305
305
  # and nothing else in the list would say so.
306
306
  entries = ",".join(
307
- "{value:%s,label:%s,description:%s}"
308
- % (
309
- js_string(value),
310
- js_string(_display_name(value)),
311
- js_string(_describe(model)),
312
- )
307
+ f"{{value:{js_string(value)},label:{js_string(_display_name(value))},"
308
+ f"description:{js_string(_describe(model))}}}"
313
309
  for value, model in picks
314
310
  )
315
311
  # Every row is emitted with no build-time "already present?" filter, and dedup