Monkeypatching Rich for Beautiful Terminals in Pyscript
Published September 27, 2022
intro.py
|
|
TL;DR: How to Use Rich in PyScript
To use Rich for the output of all your PyScript tags, add the following to a new PyScript take at the top of the page's body
:
_richsetup.py
|
|
Live updates work a little differently in PyScript than they do in the terminal - see the Live Updates section for details.
This code was written (and is running on this page on) PyScript Version 2022.06.1. Since there's an overhaul of how PyScript renders coming very soon, check the documentation for updates.
Background
Though PyScript is still in its infancy, the possibilities unlocked by running Python in a browser are already blossoming. As such, I'm seeing more and more users on the official forums, the unofficial Discord, and the Github Issue Tracker interested in working with their favorite libraries to the web. Let's look at the process of taking a package that runs but doesn't run well, and see how we can use patch it after import to bring it to life using Pyscript.
Lots of packages work fine right out of the box - anything written in Pure Python stands a good chance of at least running. But just because it runs, doesn't mean it'll look good or behave the way we expect objects to on a webpage. Interactive packages, like matplotlib or terminal-based packages like tqdm or colorama, may not be immediately interactable in the browser, because they've implemented their own methods for interpretting input/output that the browser doesn't play nicely with. Just because the PyScript/Pyodide interpretter doesn't crash doesn't mean you can get useful info in and out of an existing module.
One such library is Rich: "a Python library for rich text and beautiful formatting in the terminal" by Will McGugan. It allows for tasteful pretty-printing of most Python objects, syntax highlighting, color and layout control and more, all written in Pure Python. See the sample image to the side or the linked homepage for bountiful exmaples.
Of course, Rich is intended to run in the terminal. Since the display functionality in a web browser differs significantly from a terminal environment, there's no reason to expect it will work out of the box in PyScript. But since it exists as a pure Python wheel and is importable by Pyodide, I wanted to see what it would take to get it working.
What follows is the result of a few hours of bashing things together. It's not meant to be production ready (thought it could turn into a module if there's interest). Rather, it's meant to demonstrate a patching strategy for modules that already integrate with web-Python environments like Jupyter and iPython.
If you want to skip the dev log, you can skip to the code that runs to patch Rich on this page or the gallery of Rich-in-PyScript samples below.
The demo image from the Rich GitHub page shows off its many features
The Groundwork
The strategy we'll employ to get Rich working is called "Monkeypatching." From the Zope Wiki:
A MonkeyPatch is a piece of Python code which extends or modifies other code at runtime (typically at startup)...The motivation for monkeypatching is typically that the developer needs to modify or extend behavior of a third-party product ... and does not wish to maintain a private copy of the source code.
So, we'll be loading/importing Rich as-is, modifying some of the attributes/methods/behaviors of its classes and functions and leaving others along. This will let us preserve the most of Rich's functionality untouched, while tweaking it just enough to work inside PyScript.
Almost all of the heavy lifting in terms of the formatting is handled by the fact Rich already supports Jupyter Notebooks, so there's already translation in place to translate Rich's internal formatting syntax to HTML. All we have to do is:
- Import rich (which means it'll need to be present in our
<py-env>
- Hook into or replace the code that detects that we're running in a Notebook to instead tell that we're running inside Pyodide.
- Take the output that would be fed to the notebook and feed it to
stdout
, where PyScript's context managers will get it to the right place - Overwrite the built-in
print()
function to point to rich's print function, to get nicely formatted printing - Point PyScript's
Element.write()
method at a new method that hooks into Rich's __rich_console__ and__rich__
formatting methods.
The Steps
Making Rich Think We're in a Jupyter Notebook
Since we're intending to run this in a browser anyway, we could just set console.is_jupyter = True
to force Rich to render HTML. But we'll be slightly nicer and redirect that property to a new function is_pyodide
. This just looks to see if 'pyodide' is in our available modules, as suggested by the pyodide FAQ. This means that whenever our code is running in Pyodide, the Rich library will render as if it's going to be output to a Jupyter notebook.
|
|
Replacing Rich's Display Function with our Own
Similarly, we'll point rich.jupyter.display
at a new function we'll write that gets the output that the Jupyter notebook would have received and send it to stdout. And, as noted above, we'll redirect the usual print
function to the rich print function, to get nicely formatted outputs whenever we use the standard print() syntax.
|
|
Fixing Element.write()
Finally, we need to match some adjustments to PyScript's Element.write()
function, which is a utility method that allows PyScript users to send output to a specific DOM element directly. Since this bypasses the usual writing to stdout (and directly modifies the innerHTML attribute of the DOM element), we need to do a little legwork to get the formatting to work.
In a nutshell, we'll solve this issue in 3 steps:
- When the user's code calls to
Element.write()
, if the object written is a plainstr
,Exception
, orJsException
, we'll pass it though toElement.write()
unchanged. This preserves some of the functionality around how PyScript currrently does error handling and presentation. - Otherwise, we'll use a context manager to temporarily redirect
stdout
to a buffer, feed the object torich.console.print()
, and capture that output in the buffer. - When the context manager closes, it writes its contents to the appropriate element using the original
Element.write()
functionality.
I've implemented a rudementary File-like object called output_buffer
that simply saves anything written to it as a concatenated string. If this isn't the first thing in the buffer, we insert a <br>
tag to make it start on a new line. This is admittedly a hack, but it largely gives the right appearance.
|
|
What About ...
Those who are familiar with the various ways Rich already provides to capture its own output, as well as exporting it as HTML, may have some reasonable questions here. It's surely possible I've missed something in Rich's expansive API, but I didn't find a solution that did everything I want without implementing my own context manager. That said, it does feel like there shuold be a simpler way...
- I wanted to make the default console returned by
get_console()
have the desired behavior, as well as any consoles the user created in the future. Hence the reason for overriding the _is_jupyter method instead of just making the default consoleforce_jupyter=True
- Using
Console.capture()
captures the entire contents of the console, from which it can be exported (or saved as a file) to HTML, but there isn't a direct way to save just the user-input-turned-into-HTML as far as I know. - Because Rich's jupyter.display() method tries specifically to write to an iPython display, I needed to override this method to render the objects to HTML and just write those to std.
With all these pieces put together, now most writes to stdout should be formatted using Rich's format rules.
Live Updates
While there are lots of things that make running Python inside a browser window different from running in a terminal/desktop environment, one of the most striking is that we only have one event loop and we can't block it. Ever. Even a simple time.sleep(1)
irrevocably blocks the JavaScript event loop.
This is where asyncio comes to the rescue. The Pyodide runtime has a custom event loop ("Webloop") that hooks to the asyncio webloop, allowing nonblock asynchronous operations. For example, we can use asyncio.sleep()
instead of time.sleep()
, asynccontextmanager
s instead of context managers
, and so on.
Hooking this deep into Rich's functionality requires some significant rewriting of the Live class, as well as an additional helper class that constantly refreshes the live display by adding new callouts to the event loop every quarter second. The full results are below.
If you want to use the Live update element in your PyScript page, you'll want to:
- Add the following code to a PyScript tag near the top of your page.
- Use the included
Live
class instead of importing fromRich.live
. It has the same interface as Rich.live, though not all features are implemented yet. - Avoid using any blocking io calls, instead substituting with their async versions. For an example of how to use the new Live class in the same way Rich does (i.e. as a context manager), see the live examples on the Rich Demo page.
_livepatch.py
|
|
Live Table Demo
livetable.py
|
|
What Works and What Doesn't
See the demo page for working examples.
Out of the box, this allows for formatting of most static Rich objects: Text, Lists and Dicts, JSON objets, etc. The various formatting objects that rely on them - Panels, Columns, Layouts etc - also work.
Some specific formatting tags are broken - though personally, I"m not too sad that <blink>
doesn't work.
Emoji are also (somewhat) broken, though that's mostly through me running out of time to look at their implementation in depth. A brief glance at the Emoji.py source makes it look like perhaps what I'm doing for output is clobbering the unicode characters that should be output as Emoji? Or perhaps how they're being rendered - the TL;DR example at the top of the page shows (for me) a successful "hand-pointing-down" but a non-colored "play button".
Things that Don't Work
Some Text Formatting Options
richnonformatted.py
|
|
Emoji's (Ish)
richemoji.py
|
|