Skip to content

Commit 2c750c3

Browse files
authored
Merge pull request #123 from johnslavik/pablo-pyrepl-docstrings
PyREPL refactor: add more docstrings/comments
2 parents b0c8ac3 + e9637f4 commit 2c750c3

File tree

6 files changed

+185
-3
lines changed

6 files changed

+185
-3
lines changed

Lib/_pyrepl/content.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,63 @@
77

88
@dataclass(frozen=True, slots=True)
99
class ContentFragment:
10+
"""A single display character with its visual width and style.
11+
12+
The body of ``>>> def greet`` becomes one fragment per character::
13+
14+
d e f g r e e t
15+
╰──┴──╯ ╰──┴──┴──┴──╯
16+
keyword (unstyled)
17+
18+
e.g. ``ContentFragment("d", 1, StyleRef(tag="keyword"))``.
19+
"""
20+
1021
text: str
1122
width: int
1223
style: StyleRef = StyleRef()
1324

1425

1526
@dataclass(frozen=True, slots=True)
1627
class PromptContent:
28+
"""The prompt split into leading full-width lines and an inline portion.
29+
30+
For the common ``">>> "`` prompt (no newlines)::
31+
32+
>>> def greet(name):
33+
╰─╯
34+
text=">>> ", width=4, leading_lines=()
35+
36+
If ``sys.ps1`` contains newlines, e.g. ``"Python 3.13\\n>>> "``::
37+
38+
Python 3.13 ← leading_lines[0]
39+
>>> def greet(name):
40+
╰─╯
41+
text=">>> ", width=4
42+
"""
43+
1744
leading_lines: tuple[ContentFragment, ...]
1845
text: str
1946
width: int
2047

2148

2249
@dataclass(frozen=True, slots=True)
2350
class SourceLine:
51+
"""One logical line from the editor buffer, before styling.
52+
53+
Given this two-line input in the REPL::
54+
55+
>>> def greet(name):
56+
... return name
57+
▲ cursor
58+
59+
The buffer ``"def greet(name):\\n return name"`` yields::
60+
61+
SourceLine(lineno=0, text="def greet(name):",
62+
start_offset=0, has_newline=True)
63+
SourceLine(lineno=1, text=" return name",
64+
start_offset=17, cursor_index=14)
65+
"""
66+
2467
lineno: int
2568
text: str
2669
start_offset: int
@@ -34,6 +77,15 @@ def cursor_on_line(self) -> bool:
3477

3578
@dataclass(frozen=True, slots=True)
3679
class ContentLine:
80+
"""A logical line paired with its prompt and styled body.
81+
82+
For ``>>> def greet(name):``::
83+
84+
>>> def greet(name):
85+
╰─╯ ╰──────────────╯
86+
prompt body: one ContentFragment per character
87+
"""
88+
3789
source: SourceLine
3890
prompt: PromptContent
3991
body: tuple[ContentFragment, ...]
@@ -59,6 +111,7 @@ def build_body_fragments(
59111
colors: list[ColorSpan] | None,
60112
start_index: int,
61113
) -> tuple[ContentFragment, ...]:
114+
"""Convert a line's text into styled content fragments."""
62115
# Two separate loops to avoid the THEME() call in the common uncolored path.
63116
if colors is None:
64117
return tuple(

Lib/_pyrepl/layout.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
"""Wrap content lines to the terminal width before rendering."""
2+
13
from __future__ import annotations
24

35
from dataclasses import dataclass
@@ -9,6 +11,16 @@
911

1012
@dataclass(frozen=True, slots=True)
1113
class LayoutRow:
14+
"""Metadata for one physical screen row.
15+
16+
For the row ``>>> def greet(name):``::
17+
18+
>>> def greet(name):
19+
╰─╯ ╰──────────────╯
20+
4 char_widths=(1,1,1,…) ← 16 entries
21+
buffer_advance=17 ← includes the newline
22+
"""
23+
1224
prompt_width: int
1325
char_widths: tuple[int, ...]
1426
suffix_width: int = 0
@@ -28,6 +40,16 @@ def screeninfo(self) -> ScreenInfoRow:
2840

2941
@dataclass(frozen=True, slots=True)
3042
class LayoutMap:
43+
"""Mapping between buffer positions and screen coordinates.
44+
45+
Single source of truth for cursor placement. Given::
46+
47+
>>> def greet(name): ← row 0, buffer_advance=17
48+
... return name ← row 1, buffer_advance=15
49+
▲cursor
50+
51+
``pos_to_xy(31)`` → ``(18, 1)``: prompt width 4 + 14 body chars.
52+
"""
3153
rows: tuple[LayoutRow, ...]
3254

3355
@classmethod
@@ -95,6 +117,14 @@ def xy_to_pos(self, x: int, y: int) -> int:
95117

96118
@dataclass(frozen=True, slots=True)
97119
class WrappedRow:
120+
"""One physical screen row after wrapping, ready for rendering.
121+
122+
When a line overflows the terminal width, it splits into
123+
multiple rows with a ``\\`` continuation marker::
124+
125+
>>> x = "a very long li\\ ← suffix="\\", suffix_width=1
126+
ne that wraps" ← prompt_text="" (continuation)
127+
"""
98128
prompt_text: str = ""
99129
prompt_width: int = 0
100130
fragments: tuple[ContentFragment, ...] = ()
@@ -116,6 +146,15 @@ def layout_content_lines(
116146
width: int,
117147
start_offset: int,
118148
) -> LayoutResult:
149+
"""Wrap content lines to fit *width* columns.
150+
151+
A short line passes through as one ``WrappedRow``; a long line is
152+
split at the column boundary with ``\\`` markers::
153+
154+
>>> short = 1 ← one WrappedRow
155+
>>> x = "a long stri\\ ← two WrappedRows, first has suffix="\\"
156+
ng"
157+
"""
119158
if width <= 0:
120159
return LayoutResult((), LayoutMap(()), ())
121160

@@ -140,6 +179,7 @@ def layout_content_lines(
140179
body = tuple(line.body)
141180
body_widths = tuple(fragment.width for fragment in body)
142181

182+
# Fast path: line fits on one row.
143183
if not body_widths or (sum(body_widths) + prompt_width) < width:
144184
offset += len(body) + newline_advance
145185
line_end_offsets.append(offset)
@@ -161,11 +201,13 @@ def layout_content_lines(
161201
)
162202
continue
163203

204+
# Slow path: line needs wrapping.
164205
current_prompt = prompt_text
165206
current_prompt_width = prompt_width
166207
start = 0
167208
total = len(body)
168209
while True:
210+
# Find how many characters fit on this row.
169211
index_to_wrap_before = 0
170212
column = 0
171213
for char_width in body_widths[start:]:

Lib/_pyrepl/reader.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,8 @@ def make_default_commands() -> dict[CommandName, CommandClass]:
157157

158158
@dataclass(frozen=True, slots=True)
159159
class RefreshInvalidation:
160+
"""Which parts of the screen need to be recomputed on the next refresh."""
161+
160162
cursor_only: bool = False
161163
buffer_from_pos: int | None = None
162164
prompt: bool = False
@@ -308,6 +310,8 @@ class Reader:
308310
## cached metadata to speed up screen refreshes
309311
@dataclass
310312
class RefreshCache:
313+
"""Previously computed render/layout data for incremental refresh."""
314+
311315
render_lines: list[RenderLine] = field(default_factory=list)
312316
layout_rows: list[LayoutRow] = field(default_factory=list)
313317
line_end_offsets: list[int] = field(default_factory=list)
@@ -412,6 +416,7 @@ def calc_screen(self) -> RenderedScreen:
412416
)
413417
and (self.invalidation.message or self.invalidation.overlay)
414418
):
419+
# Fast path: only overlays or messages changed.
415420
offset, num_common_lines = self.last_refresh_cache.get_cached_location(
416421
self,
417422
reuse_full=True,

Lib/_pyrepl/render.py

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,20 @@
1818

1919

2020
class _ThemeSyntax(Protocol):
21+
"""Protocol for theme objects that map tag names to SGR escape strings."""
2122
def __getitem__(self, key: str, /) -> str: ...
2223

2324

2425
@dataclass(frozen=True, slots=True)
2526
class RenderCell:
27+
"""One terminal cell: a character, its column width, and SGR style.
28+
29+
A screen row like ``>>> def`` is a sequence of cells::
30+
31+
> > > d e f
32+
╰─╯╰─╯╰─╯╰─╯╰─╯╰─╯╰─╯
33+
"""
34+
2635
text: str
2736
width: int
2837
style: StyleRef = field(default_factory=StyleRef)
@@ -94,6 +103,12 @@ def append_plain_text(segment: str) -> None:
94103

95104
@dataclass(frozen=True, slots=True)
96105
class RenderLine:
106+
"""One physical screen row as a tuple of :class:`RenderCell` objects.
107+
108+
``text`` is the pre-rendered terminal string (characters + SGR escapes);
109+
``width`` is the total visible column count.
110+
"""
111+
97112
cells: tuple[RenderCell, ...]
98113
text: str
99114
width: int
@@ -139,10 +154,16 @@ def from_rendered_text(cls, text: str) -> Self:
139154
class ScreenOverlay:
140155
"""An overlay that replaces or inserts lines at a screen position.
141156
142-
If insert is True, lines are spliced in (shifting content down);
143-
if False (default), lines replace existing content at y.
157+
If *insert* is True, lines are spliced in (shifting content down);
158+
if False (default), lines replace existing content at *y*.
144159
145160
Overlays are used to display tab completion menus and status messages.
161+
For example, a tab-completion menu inserted below the input::
162+
163+
>>> os.path.j ← line 0 (base content)
164+
join ← ScreenOverlay(y=1, insert=True)
165+
junction ← (pushes remaining lines down)
166+
... ← line 1 (shifted down by 2)
146167
"""
147168
y: int
148169
lines: tuple[RenderLine, ...]
@@ -151,6 +172,19 @@ class ScreenOverlay:
151172

152173
@dataclass(frozen=True, slots=True)
153174
class RenderedScreen:
175+
"""The complete screen state: content lines, cursor, and overlays.
176+
177+
``lines`` holds the base content; ``composed_lines`` is the final
178+
result after overlays (completion menus, messages) are applied::
179+
180+
lines: composed_lines:
181+
┌──────────────────┐ ┌──────────────────┐
182+
│>>> os.path.j │ │>>> os.path.j │
183+
│... │ ──► │ join │ ← overlay
184+
└──────────────────┘ │... │
185+
└──────────────────┘
186+
"""
187+
154188
lines: tuple[RenderLine, ...]
155189
cursor: CursorXY
156190
overlays: tuple[ScreenOverlay, ...] = ()
@@ -173,9 +207,11 @@ def _compose(self) -> tuple[RenderLine, ...]:
173207
"overlays must be sorted by ascending y"
174208
)
175209
if overlay.insert:
210+
# Splice overlay lines in, pushing existing content down.
176211
lines[adjusted_y:adjusted_y] = overlay.lines
177212
y_offset += len(overlay.lines)
178213
else:
214+
# Replace existing lines at the overlay position.
179215
target_len = adjusted_y + len(overlay.lines)
180216
if len(lines) < target_len:
181217
lines.extend([EMPTY_RENDER_LINE] * (target_len - len(lines)))
@@ -217,6 +253,17 @@ def screen_lines(self) -> tuple[str, ...]:
217253

218254
@dataclass(frozen=True, slots=True)
219255
class LineDiff:
256+
"""The changed region between an old and new version of one screen row.
257+
258+
When the user types ``e`` so the row changes from
259+
``>>> nam`` to ``>>> name``::
260+
261+
>>> n a m old
262+
>>> n a m e new
263+
╰─╯
264+
start_cell=7, new_cells=("m","e"), old_cells=("m",)
265+
"""
266+
220267
start_cell: int
221268
start_x: int
222269
old_cells: tuple[RenderCell, ...]
@@ -250,10 +297,13 @@ class LineUpdate:
250297
y: int
251298
start_cell: int
252299
start_x: int
300+
"""Screen x-coordinate where the update begins. Used for cursor positioning."""
253301
cells: tuple[RenderCell, ...]
254302
char_width: int = 0
255303
clear_eol: bool = False
256304
reset_to_margin: bool = False
305+
"""If True, the console must resync the cursor position after writing
306+
(needed when cells contain non-SGR escape sequences that may move the cursor)."""
257307
text: str = field(init=False, default="")
258308

259309
def __post_init__(self) -> None:
@@ -273,6 +323,14 @@ def render_cells(
273323
cells: Sequence[RenderCell],
274324
visual_style: str | None = None,
275325
) -> str:
326+
"""Render a sequence of cells into a terminal string with SGR escapes.
327+
328+
Tracks the active SGR state to emit resets only when the style
329+
actually changes, minimizing output bytes.
330+
331+
If *visual_style* is given (used by redraw visualization), it is appended
332+
to every cell's style.
333+
"""
276334
rendered: list[str] = []
277335
active_escape = ""
278336
for cell in cells:
@@ -305,6 +363,8 @@ def diff_render_lines(old: RenderLine, new: RenderLine) -> LineDiff | None:
305363
start_x = 0
306364
max_prefix = min(len(old.cells), len(new.cells))
307365
while prefix < max_prefix and old.cells[prefix] == new.cells[prefix]:
366+
# Stop at any cell with non-SGR controls, since those might affect
367+
# cursor position and must be re-emitted.
308368
if old.cells[prefix].controls:
309369
break
310370
start_x += old.cells[prefix].width

Lib/_pyrepl/unix_console.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,13 +151,35 @@ def poll(self, timeout: float | None = None) -> list[int]:
151151

152152
@dataclass(frozen=True, slots=True)
153153
class UnixRefreshPlan:
154+
"""Instructions for updating the terminal after a screen change.
155+
156+
After the user types ``e`` to complete ``name``::
157+
158+
Before: >>> def greet(nam|):
159+
160+
LineUpdate here: insert_char "e"
161+
162+
After: >>> def greet(name|):
163+
164+
165+
Only the changed cells are sent to the terminal; unchanged rows
166+
are skipped entirely.
167+
"""
168+
154169
grow_lines: int
170+
"""Number of blank lines to append at the bottom to accommodate new content."""
155171
use_tall_mode: bool
172+
"""Use absolute cursor addressing via ``cup`` instead of relative moves.
173+
Activated when content exceeds one screen height."""
156174
offset: int
175+
"""Vertical scroll offset: the buffer row displayed at the top of the terminal window."""
157176
reverse_scroll: int
177+
"""Number of lines to scroll backwards (content moves down)."""
158178
forward_scroll: int
179+
"""Number of lines to scroll forwards (content moves up)."""
159180
line_updates: tuple[LineUpdate, ...]
160181
cleared_lines: tuple[int, ...]
182+
"""Row indices to erase (old content with no replacement)."""
161183
rendered_screen: RenderedScreen
162184
cursor: tuple[int, int]
163185

Lib/_pyrepl/utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -437,7 +437,7 @@ def prev_next_window[T](
437437

438438
@dataclass(frozen=True, slots=True)
439439
class StyleRef:
440-
tag: str | None = None
440+
tag: str | None = None # From THEME().syntax, e.g. "keyword", "builtin"
441441
sgr: str = ""
442442

443443
@classmethod

0 commit comments

Comments
 (0)