GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — gh-131507: Refactor screen and cursor position calculations by ambv · Pull Request #131547 · python/cpython

Conversation

@ambv

@ambv ambv commented Mar 21, 2025 •
edited by bedevere-app Bot
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown
Contributor

This is based off #131509.

I changed a bunch of variable names to make things more obvious in calc_screen() and pos2xy(). Plus very mild refactoring once the better names make it obvious which things are the same. It is helpful to me at least.

The one actual change here is moving disp_str() to _pyrepl.utils because this is where syntax highlighting will live as well. With that move comes a slight performance optimization that will become functionally important later: disp_str() no longer repacks the list of characters into a string (that is later only iterated on anyway in calc_screen()). Additionally, our version of disp_str() never had the behavior presented in the docstring, so I replaced it with something more sensible.

That's about it. Tests prove no functional change.

  • Issue: Syntax highlighting in PyREPL #131507

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
@ambv
pythongh-131507: Refactor screen and cursor position calculations
f9f4722
This is based off python#131509.
@ambv
ambv requested review from lysnikolaou and pablogsal as code owners March 21, 2025 15:07
@bedevere-app bedevere-app Bot added the awaiting core review label Mar 21, 2025
@bedevere-app bedevere-app Bot mentioned this pull request Mar 21, 2025
Syntax highlighting in PyREPL #131507
Closed
@ambv ambv added skip news needs backport to 3.13 only security fixes labels Mar 21, 2025
pablogsal
pablogsal reviewed Mar 21, 2025
View reviewed changes
Comment thread Lib/_pyrepl/reader.py
offset = len(character_widths) - character_widths.count(0)
in_wrapped_line = prompt_len + sum(character_widths) >= self.console.width
prompt_len, char_widths = self.screeninfo[i]
offset = len(char_widths)

@pablogsal pablogsal Mar 21, 2025

Copy link
Copy Markdown
Member

There was a problem hiding this comment.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this breaking 0-width characters (like combining marks)?

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions

@ambv ambv Mar 21, 2025 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown
Contributor Author

There was a problem hiding this comment.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, this is fixing 0-width characters.

Edit: to explain more clearly. The code I removed looks like it was meant to help with 0-width characters, but the code was actually the wrong way round. The reason why was that originally the "0-width" was historically used in pyrepl to show characters like ^C as two-wide by returning [1, 0] from disp_str(). You can still see that in the docstring I edited. But either we made a change to undo that when originally merging pyrepl, or they changed it over the course of history so it didn't work anyway. So those 0-counts weren't doing anything productive anyway, but were only adding to our confusion. As proof, all zero-width characters are currently printed out as part of the Other unicodedata group:

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions

@ambv ambv Mar 21, 2025

Copy link
Copy Markdown
Contributor Author

There was a problem hiding this comment.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Screenshot 2025-03-21 at 17 47 16

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions

@ambv ambv Mar 21, 2025

Copy link
Copy Markdown
Contributor Author

There was a problem hiding this comment.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be clear, the screenshot represents the state before this PR, and this PR is not affecting it in any way.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions

@ambv ambv Mar 21, 2025

Copy link
Copy Markdown
Contributor Author

There was a problem hiding this comment.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The way I discovered all this was that I originally wanted disp_str() to emit syntax highlighting as separate zero-width characters and then it turned out that the removal of the .count(0) was necessary for the cursor positioning to work correctly. And even that was insufficient, since having zero-width characters in the stream was causing wrapping on narrow terminals to fall in infinite loops, there's an open issue about this (#126685).

This PR fixes #126685.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
pablogsal
pablogsal reviewed Mar 21, 2025
View reviewed changes
Comment thread Lib/_pyrepl/reader.py

def pos2xy(self) -> tuple[int, int]:
"""Return the x, y coordinates of position 'pos'."""
# this *is* incomprehensible, yes.

@pablogsal pablogsal Mar 21, 2025

Copy link
Copy Markdown
Member

There was a problem hiding this comment.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😆

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
pablogsal
pablogsal reviewed Mar 21, 2025
View reviewed changes
Comment thread Lib/_pyrepl/utils.py
Comment on lines +44 to +77
def disp_str(buffer: str) -> tuple[CharBuffer, CharWidths]:
r"""Decompose the input buffer into a printable variant.

Returns a tuple of two lists:
- the first list is the input buffer, character by character;
- the second list is the visible width of each character in the input
buffer.

Examples:
>>> utils.disp_str("a = 9")
(['a', ' ', '=', ' ', '9'], [1, 1, 1, 1, 1])
"""
chars: CharBuffer = []
char_widths: CharWidths = []

if not buffer:
return chars, char_widths

for c in buffer:
if c == "\x1a": # CTRL-Z on Windows
chars.append(c)
char_widths.append(2)
elif ord(c) < 128:
chars.append(c)
char_widths.append(1)
elif unicodedata.category(c).startswith("C"):
c = r"\u%04x" % ord(c)
chars.append(c)
char_widths.append(len(c))
else:
chars.append(c)
char_widths.append(str_width(c))
trace("disp_str({buffer}) = {s}, {b}", buffer=repr(buffer), s=chars, b=char_widths)
return chars, char_widths

@pablogsal pablogsal Mar 21, 2025

Copy link
Copy Markdown
Member

There was a problem hiding this comment.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check if you like something of this version with some improvements:

Suggested change
def disp_str(buffer: str) -> tuple[CharBuffer, CharWidths]:
r"""Decompose the input buffer into a printable variant.
Returns a tuple of two lists:
- the first list is the input buffer, character by character;
- the second list is the visible width of each character in the input
buffer.
Examples:
>>> utils.disp_str("a = 9")
(['a', ' ', '=', ' ', '9'], [1, 1, 1, 1, 1])
"""
chars: CharBuffer = []
char_widths: CharWidths = []
if not buffer:
return chars, char_widths
for c in buffer:
if c == "\x1a": # CTRL-Z on Windows
chars.append(c)
char_widths.append(2)
elif ord(c) < 128:
chars.append(c)
char_widths.append(1)
elif unicodedata.category(c).startswith("C"):
c = r"\u%04x" % ord(c)
chars.append(c)
char_widths.append(len(c))
else:
chars.append(c)
char_widths.append(str_width(c))
trace("disp_str({buffer}) = {s}, {b}", buffer=repr(buffer), s=chars, b=char_widths)
return chars, char_widths
CharBuffer = List[str]
CharWidths = List[int]
CTRL_Z = "\x1a"
ASCII_MAX = 127
CTRL_Z_WIDTH = 2
ASCII_WIDTH = 1
@lru_cache(maxsize=1024)
def cached_category(c: str) -> str:
"""Cache unicodedata.category calls for better performance."""
return unicodedata.category(c)
def disp_str(buffer: str) -> Tuple[CharBuffer, CharWidths]:
r"""Decompose the input buffer into a printable variant.
Returns a tuple of two lists:
- the first list is the input buffer, character by character;
- the second list is the visible width of each character in the input buffer.
Examples:
>>> utils.disp_str("a = 9")
(['a', ' ', '=', ' ', '9'], [1, 1, 1, 1, 1])
"""
# Fast path for empty buffer
if not buffer:
return [], []
# Optimization for common case: pure ASCII text
if all(ord(c) < ASCII_MAX for c in buffer):
chars = list(buffer)
char_widths = [ASCII_WIDTH] * len(buffer)
trace("disp_str({buffer}) = {s}, {b}", buffer=repr(buffer), s=chars, b=char_widths)
return chars, char_widths
chars: CharBuffer = []
char_widths: CharWidths = []
# Pre-allocate lists for better performance
chars = [None] * len(buffer)
char_widths = [0] * len(buffer)
for i, c in enumerate(buffer):
if c == CTRL_Z: # CTRL-Z on Windows
chars[i] = c
char_widths[i] = CTRL_Z_WIDTH
elif ord(c) < ASCII_MAX:
chars[i] = c
char_widths[i] = ASCII_WIDTH
elif cached_category(c).startswith("C"):
unicode_repr = f"\\u{ord(c):04x}"
chars[i] = unicode_repr
char_widths[i] = len(unicode_repr)
else:
chars[i] = c
try:
char_widths[i] = str_width(c)
except Exception:
# Fallback if str_width fails
char_widths[i] = 1
trace("disp_str({buffer}) = {s}, {b}", buffer=repr(buffer), s=chars, b=char_widths)
return chars, char_widths

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions

@ambv ambv Mar 21, 2025

Copy link
Copy Markdown
Contributor Author

There was a problem hiding this comment.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like caching character categories, will add this to the third PR in the sequence. The other optimization you added would have to be removed anyway when adding color codes.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
pablogsal
pablogsal reviewed Mar 21, 2025
View reviewed changes
Comment thread Lib/_pyrepl/reader.py
Comment on lines +554 to +557
if not in_wrapped_line:
offset += 1 # there's a newline in buffer

pos -= offset

@pablogsal pablogsal Mar 21, 2025

Copy link
Copy Markdown
Member

There was a problem hiding this comment.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see why these two are equivalent :(

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions

@ambv ambv Mar 21, 2025

Copy link
Copy Markdown
Contributor Author

There was a problem hiding this comment.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is equivalent, but it's subtle. Note that even in the old version, the if test in line 580 is the same as what's under the variable in_wrapped_line.

In the new version, we're reusing the offset variable that already had -1 applied if in_wrapped_line. So we only need to handle the newline case, which we do.

There's less computation in the new version, so it reads better to me. It almost could be shortened further, but the break check is awkwardly in the middle there, so I kept it for equivalence.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
pablogsal
pablogsal approved these changes Mar 21, 2025
View reviewed changes

@pablogsal pablogsal left a comment

Copy link
Copy Markdown
Member

There was a problem hiding this comment.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM with some comments

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
@bedevere-app bedevere-app Bot added awaiting merge and removed awaiting core review labels Mar 21, 2025
@ambv
ambv merged commit 4cc82ff into python:main Mar 21, 2025
@bedevere-app bedevere-app Bot removed the awaiting merge label Mar 21, 2025
@miss-islington-app

miss-islington-app Bot commented Mar 21, 2025

Copy link
Copy Markdown

Thanks @ambv for the PR 🌮🎉.. I'm working now to backport this PR to: 3.13.
🐍🍒⛏🤖

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

miss-islington pushed a commit to miss-islington/cpython that referenced this pull request Mar 21, 2025
@ambv @miss-islington
pythongh-131507: Refactor screen and cursor position calculations (py…
e2e514a
…thonGH-131547)

This is based off pythonGH-131509.
(cherry picked from commit 4cc82ff)

Co-authored-by: Łukasz Langa <[email protected]>
@bedevere-app

bedevere-app Bot commented Mar 21, 2025

Copy link
Copy Markdown

GH-131557 is a backport of this pull request to the 3.13 branch.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@bedevere-app bedevere-app Bot removed the needs backport to 3.13 only security fixes label Mar 21, 2025
ambv added a commit that referenced this pull request Mar 21, 2025
@miss-islington @ambv
[3.13] gh-131507: Refactor screen and cursor position calculations (G…
55fb3a7
…H-131547) (GH-131557)

gh-131507: Refactor screen and cursor position calculations (GH-131547)

This is based off GH-131509.
(cherry picked from commit 4cc82ff)

Co-authored-by: Łukasz Langa <[email protected]>
@ambv ambv mentioned this pull request Mar 21, 2025
Zero-width characters cause unbounded memory use in PyREPL #126685
Closed
@ambv
ambv deleted the pyrepl-sh-refactor-calc-screen branch March 21, 2025 18:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Reviewers

@pablogsal pablogsal pablogsal approved these changes
@lysnikolaou lysnikolaou Awaiting requested review from lysnikolaou lysnikolaou is a code owner

Uh oh!

There was an error while loading. Please reload this page.

Assignees

No one assigned

Labels

skip news

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

Uh oh!

There was an error while loading. Please reload this page.

2 participants

@ambv @pablogsal
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.
You are visitor 01402503 — GeezerVison does not store the pages you look up.
Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.