8000 PyTerminal Plugin by WebReflection · Pull Request #1801 · pyscript/pyscript · GitHub
[go: up one dir, main page]

Skip to content

PyTerminal Plugin #1801

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 2 commits into from
Closed

PyTerminal Plugin #1801

wants to merge 2 commits into from

Conversation

WebReflection
Copy link
Contributor
@WebReflection WebReflection commented Oct 12, 2023

Description

This MR takes over previous attempt #1771 simplifying a couple of things, improving foreign dependencies import performance and using a standard custom-element.

This MR makes the terminal interactive only if the <py-terminal> tag is present on the page and only if the running script uses a worker attribute.

Please Note the terminal currently doesn't work in MicroPython worker (interactive mode) as the code module is missing and I have errors in redefining sys.stdout and friends.

Changes

  • add a py-terminal.js plugin that bootstraps correctly py-terminal from either main or worker, actually interactive only within the latter case
  • improved the pyterminal.py file to be sure it does what it does only once and only if in worker
  • implemented logic to have only one py-terminal on the page so that even if multiple terminals are there, only one (the first one) would actually work as py-terminal
  • the logic is lazy thanks to custom elements so that in theory we could have a smarter bootstrap when a user just import pyterminal from a worker, appending the element if not present on the page, and let the rest of the plugin work as expected

Checklist

  • All tests pass locally
  • I have updated docs/changelog.md
  • I have created documentation for this(if applicable)

@WebReflection
Copy link
Contributor Author

@antocuni how is CI pushing commits when my pre-commits file passed? 🤔

@JeffersGlass
Copy link
Contributor
JeffersGlass commented Oct 12, 2023

Such an awesome demo of this today Andrea! Really stoked to have this moving forward.

Just to put in writing the suggestion I made on the call - the way I most often use a REPL is with python -i, which runs a script and then drops the user into an interactive terminal to poke around with what they're code did. To that end, I was suggesting something like:

<script type="py" terminal></script>

This would execute the code in a worker and then launch a terminal/REPL that's hooked to that same worker.

(The name I don't have strong opinions on. Could be terminal or interactive or py-terminal or data-py-terminal or... 🤷)

the custom element story works relatively well but I think a pyterminal.init(target) would be better...

I wonder if having an attribute above might solve the need for a custom element - if you want a 'naked' REPL (i.e. a REPL with no other user code run before it), you just throw <script type="py" terminal> on the page somewhere? Or... it could be <script type="py-terminal">... though I like that less...

@JeffersGlass
Copy link
Contributor

if on main the story is super awkward it's not clear to me why we shouldn't just throw instead

Agreed, if we have a declarative way to start a terminal from Python, it should throw if that's called in the main thread.

@WebReflection
Copy link
Contributor Author
WebReflection commented Oct 13, 2023

<script type="py" terminal> ... I love it, and it makes it easy to provide mpy too whenever it'll be possible to do so ... and indeed now I am thinking about <script type="py-terminal"> that maybe is better ... but actually I prefer the terminal more ... any other opinion around this? that explicit custom type though helps confining interpreter and features per custom type so maybe it is a solution, it can bootstrap as worker out of the box so it's quite possibly the best solution to this problem, and it doesn't need to be a plugin neither. @antocuni @ntoll @fpliger thoughts?

@fpliger
Copy link
Contributor
fpliger commented Oct 13, 2023

<script type="py" terminal> ... I love it, and it makes it easy to provide mpy too whenever it'll be possible to do so ... and indeed now I am thinking about <script type="py-terminal"> that maybe is better ... but actually I prefer the terminal more ... any other opinion around this? that explicit custom type though helps confining interpreter and features per custom type so maybe it is a solution, it can bootstrap as worker out of the box so it's quite possibly the best solution to this problem, and it doesn't need to be a plugin neither. @antocuni @ntoll @fpliger thoughts?

+2 . Loove it! Let's go!

@ntoll
Copy link
Member
ntoll commented Oct 13, 2023

I prefer <script type="py" terminal src="foo.py"> that defaults to a web worker.

Over on discord there was mention of allowing something "terminal like" for the main thread, but this may only be something as simple as a <pre id="stdout"></pre> tag styled to look vaguely terminal-ish.

@WebReflection
Copy link
Contributor Author

I prefer <script type="py" terminal src="foo.py"> that defaults to a web worker.

just to clarify, is the src mandatory or it was just an example?

@WebReflection WebReflection marked this pull request as ready for review October 16, 2023 09:18
@WebReflection
Copy link
Contributor Author

Folks, I've just found a reltively elegant way to avoid making the terminal interactive on main but fully working as expected from the worker. I did some other cleanup and I think as first iteration this plugin might be good enough.

Please feel free to review current state and if you grab this branch feel free to test the test/py-terminal.html smoke page with or without the worker attribute.

If this is OK, I might as well write an integration test before merging the PR, thank you.

@fpliger
Copy link
Contributor
fpliger commented Oct 16, 2023

Really like it... super clean and a great starting point

@JoshuaLowe1002
Copy link
Member

Just tried this out on workers and main, it's great!
Excellent work @WebReflection!

# avoid bootstrapping interact() if no terminal exists
if _pyterminal.PY_TERMINAL:
import code as _code
_code.interact()
Copy link
Contributor

Choose a reason for hiding this comment

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

It's a shame not to support top level await...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@hoodmane not intentional ... and I am not sure what you are referring to. Mind to expand a bit? We do support top level await if the tag has an async attribute though, but I am trying to figure out what would be the benefit here ... or maybe you are referring to some missing field when interact(...) is called that would allow top level await within the repl?

Thanks in advance for any sort of clarification, please remember I know nothing about Python 😅

Copy link
Contributor Author

Choose a reason for hiding this comment

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

OK, this has been discussed during yesterday community call, I am waiting for @hoodmane link/hint on how to workaround and enable the top level await if possible, thanks!

@antocuni
Copy link
Contributor

Finally managed to have a look at this. The discussion is big and it spanned several media (e.g., a discord thread and the PyScript FUN call, for which I saw the recording) so let me try to summarize some of my thoughts.

Note that not all of these ideas necessarily apply to the upcoming release: we might decide to implement them later, but it's still good to have an idea of what is the final goal.

  • main vs worker: I think we should have a write-only py-terminal also in the main thread. There are tons of use cases for this, from a simple print("hello world") to e.g. running a full pytest session.

  • "dumb terminal" vs xterm.js: there are cases in which a simple <div> works fine, without having to bring in a full xterm.js: this maps very well to the concept of dumb terminal which already exists in computing. In that case, advanced things like ANSI escapes and colors won't work. I think that is is orthogonal to main/worker though. I also think it should be opt-in, i.e. by default you get an "fully featured" terminal, but if you want to optimize you can ask for a dumb one. E.g. by using <py-terminal dumb>, or <py-config>TERM=dumb</py-config> etc. (the details should be discussed elsewhere, IMHO).

  • terminal vs REPL: I seem to understand that in this PR, the terminal always automatically starts code.interact(). I'm not sure it's a good idea: from my POV, the terminal and the REPL are two separate things (although the REPL lives inside the terminal), and it should be possible to have a terminal without the REPL.

  • how to get a REPL then? That's a good question. I can imagine things like <py-terminal repl> or <py-repl>, but probably this discussion should live in a subsequent PR IMHO.

  • overriding sys.stdout: this is the biggest pain point, I left it for the last because it's the most technical and requires more writing :).

In my original PR (#1771) I played with two ways of intercepting stdout/stderr:

  1. using pyodide.setStdout at the JS level

    const pyodide = pyScript.interpreter;
    pyodide.setStdout({ raw: myStdout });
    pyodide.setStderr({ raw: myStdout });

  2. overriding sys.stdout at python level:

    class PyTerminal:
    def write(self, line):
    sync.pyterminal_write(line)
    def input(self, prompt):
    return sync.pyterminal_readline(prompt)
    PY_TERMINAL = None
    def init():
    global PY_TERMINAL
    PY_TERMINAL = PyTerminal()
    sys.stdout = sys.stderr = PY_TERMINAL

They two two very different things: approach (2) only intercept python things, and it has problems because the object that we are using is not a "full file" and it misses methods which other programs could expect (e.g., sys.stdout.flush() and sys.stdout.isatty()). We could try to implement them, but it's a never-ending race, I fear.
Moreover, I doesn't intercept C-level printf(), or code which explicitly manipulate the fd 1.
This is not super-common, but there are definitely libraries around which do things like this (e.g., pytest fdcap fixture).

AFAIK, pyodide.setStdout intercepts all writes to unix file descriptor 1, so all cases above would work out of the box. My gut feeling is that it would prevent lots of headaches in the long run.
@hoodmane, do you confirm?

The reason why I couldn't use it in the worker is that plugins cannot inject JS code in the worker, and that's where I got blocked in my PR.
I don't have any opinion on how to solve the problem, but it might require to make py-terminal not-a-plugin, if needed.

@WebReflection
Copy link
Contributor Author

quick answers:

  • main is already write only in latest PR
  • worker does start code interact out of the box indeed ... I like the repl attribute for the custom element, I will update that
  • dumb VS xterm feels unnecessary to me but we could try to use dumb attribute ... that's the beauty of custom elements, we have no limitations in the attributes we want to expose, as long as these make sense ... however, I feel like that's branching logic for something harder to both explain and maintain ... the XTerm is a transparent dependency no user really needs to know or care about, all they want is a terminal so I don't think the dumb brings much to the plate
  • during yesterday call @hoodmane suggested that setStdout is the best way to go ... so I will try to do that instead
  • the discussion about hooks and JS code in workers is here: To evaluate or not to evaluate ... that's the question! polyscript#61

@antocuni
Copy link
Contributor

the discussion about hooks and JS code in workers is here: pyscript/polyscript#61

it seems a very dense one and I think I'll need some time to fully understand :).
But the point which I wanted to make is that if using generic hooks causes too many problems, we always have the option of not using hooks and embed the pyterminal-related JS code directly into xworker.js, haven't we?
(I might be saying nonsense, in that case sorry for that)

@WebReflection
Copy link
Contributor Author
WebReflection commented Oct 18, 2023

we always have the option of not using hooks and embed the pyterminal-related JS code directly into xworker.js, haven't we?

it's some sort of slippery slope + the evident demonstration the architecture behind is good for some, but not for everyone ... defeating a bit the conversation around plugins and limiting the community support and creativity.

To this I rather say no ... but if having a repl is really a fundamental thing to have for the project, of course we can hack internally as much as we like, but that still doesn't cover the fact "hooks are good, but not always" which I'd rather, personally, solve, specially in the RC phase of the project.

edit I am not sure I've read your comment right neither ... are you suggesting we should fork XTerm? do we have even capacity to do that?

@antocuni
Copy link
Contributor

edit I am not sure I've read your comment right neither ... are you suggesting we should fork XTerm? do we have even capacity to do that?

no, I'm not saying that.

I'm saying that if we can have a "good" py-terminal solution with the current hooks, that's awesome.
If the current hooks are not enough but we can tweak them the achieve the "good" py-terminal, that's also good.

But in case it is not possible to tweak the hooks for technical reasons, I'd rather have a "good" py-terminal-which-is-not-a-plugin than a "less good" py-terminal-which-is-a-plugin.
That's just my opinion, though.

@WebReflection
Copy link
Contributor Author

I'd rather have a "good" py-terminal-which-is-not-a-plugin than a "less good" py-terminal-which-is-a-plugin.

we kinda agree there, my different PoV is that I want plugins to be as good as they need to be ... if we could move this forward maybe we'll have the cake and we can eat it all too pyscript/polyscript#61

@ntoll
Copy link
Member
ntoll commented Oct 24, 2023

I prefer <script type="py" terminal src="foo.py"> that defaults to a web worker.

just to clarify, is the src mandatory or it was just an example?

Just an example. Without a src I imagine you just get a "vanilla" Python prompt.

@ntoll
Copy link
Member
ntoll commented Oct 24, 2023

OK... once we have this merged, even as a deliberate "first draft", I think we're good to go for two things to happen:

  • Release RC3.
  • Assuming folks using RC3 in the wild for a few days don't highlight something terrible, we promote RC3 to final and call it our first official release of PyScript "next".

@JoshuaLowe1002
Copy link
Member

Just spotted another bug, unless we're expecting this not to work. Adding async to the script tag stops the terminal from working:

https://joshualowe1002.pyscriptapps.com/aged-pond/v1/

@ntoll
Copy link
Member
ntoll commented Oct 24, 2023

@JoshuaLowe1002 I believe we're waiting on @hoodmane to advise @WebReflection on top level await / async flag. See: #1801 (review)

But this could be a post-release refinement if we don't allow or ignore async and terminal attributes.

@JoshuaLowe1002
Copy link
Member

Aha! It would help if i read the review... thanks :)

@mchilvers
Copy link
Contributor

To help make it easy for the app writer to control the location and style of the terminals (maybe I want 5 terminals in a row each with a different background color etc), could the terminal attribute be either:-

<script type="py" terminal> which creates an element for me... or... <script type="py" terminal="the-id-of-the-element-i-want-the-terminal-in"> Also, maybe a "half-way house" towards styling, another attribute terminal-class="cool-terminal"?

@WebReflection
Copy link
Contributor Author

@mchilvers currently the terminal takes over the whole interpreter input/output, so multiple terminals are not supported, like it was before ... multiple terminals will be a follow up, here it's more about feature parity (or something close to it) with PyScript classic. As nobody wants 5 different pyodide bootstraps neither, I agree with you it's something to provide in the near future, just "not today" imho, as that's not super easy, if even possible, right now without having 5 pyodides so 10 to 20 seconds before you can work on that page.

@WebReflection
Copy link
Contributor Author

@mchilvers btw, this is also why there is the repl name/proposal, as single repl are easier to reason about.

@WebReflection
Copy link
Contributor Author
WebReflection commented Oct 24, 2023
    <script type="py" worker terminal="output">
        # code
        # questions:
        #   * print bootstraps terminal? NOTE: default on PSDC to have terminal attribute
        #   * which backend for the terminal? NOT A BLOCKER + FOLLOW UP + XTerm.js so far so good
        #
        #   * slightly unrelated (affects also error plugin):
        #        * take over the console.error? (maybe nope?)

        # runtime error: here to explain current issue
        def shenanigans():
            print("fails", whatever())

        js.setTimeout(shenanigans)
    </script>
    <div id="output"></div>

@WebReflection
Copy link
Contributor Author
WebReflection commented Oct 24, 2023

PyTerminal Specs

  • an attribute defines when the code should end up in a terminal or not
  • if the terminal is just present as attribute, a target node is used instead (either the script.target or the py-script element itself)
  • if the terminal is present, and it points to an ID (generic CSS selector as fallback, maybe?) that is the target node
  • if the worker attribute is not present, the terminal runs on main as read-only for users, and write-only for python (no input)
  • if the worker attribute is present, the terminal runs fully
  • if the async attribute is present, assuming the user wants to enable top-level await in the terminal:

edit now that I think about it, the async story is very different because it works on the code in the script, but it can't work within the terminal if it's in worker mode, as top level await there would fail. I hence believe a console.warn is the best way to go because the intent is not fully broken, only interactive intent would be broken.

@JeffersGlass
Copy link
Contributor

Sorry to have missed the community call - I think these specs are really quite good! I would maybe suggest some changes to the way the async attribute functions... but since we don't support that yet, I'd rather defer that when it comes to to implement that attribute? Otherwise +1 to all of the above spec.

@WebReflection
Copy link
Contributor Author

thanks @JeffersGlass, you've been missed! Anyway, I have adjusted specs around async because it's a double-edge sword ... the code actually will run async without any issue, it's just the interaction later in the repl that wouldn't ... I hope my edit / amend makes sense, happy to improve even more once we can support async interactive shell too.

@WebReflection WebReflection marked this pull request as draft October 24, 2023 17:07
@WebReflection
Copy link
Contributor Author

FYI I've converted this into draft as almost nothing in this MR fulfills the specs we agreed on for the next PyScript Terminal

@JeffersGlass
Copy link
Contributor

I think in general I understand your edit/amend, and agree! async should work fine on mine. The only thing I don't understand is:

it can't work within the terminal if it's in worker mode, as top level await there would fail.

This is waiting on Hood/implementation details there, yeah? Or is there something else that would block top level await working in a worker?

@hoodmane
Copy link
Contributor

This is waiting on Hood/implementation details there, yeah?

Yes that's right. Sorry for the delay, I'm going to write something for this right now.

@JoshuaLowe1002
Copy link
Member
JoshuaLowe1002 commented Oct 24, 2023

I don't want to sidetrack the conversation and prevent a release, and i may have misunderstood what's happening, however:

After some experimenting, I think the error that I was unable to display in the terminal is a bug with the terminal implementation, rather than us not being able to catch it via Pyodide...

  1. Add the following code to main.py and add to a <script type="py" worker>
print("hello)
  1. We get an error in the console, SyntaxError: unterminated string literal which is expected, but not in the terminal.

Nothing shows in the terminal, because it seems that the code in pyterminal.py is not being run to initiate the terminal.

However this error does show in the main thread terminal, because from what I can see, the terminal is initiated in a different way.

This obviously doesn't solve the runtime error issue, however, I think it covers showing errors that beginners will cause.

@hoodmane
Copy link
Contributor

My suggestion is to use pyodide.console.Console or pyodide.console.PyodideConsole. The pyodide.console.PyodideConsole will automatically load packages from the Pyodide lockfile that the user tries to import, whereas Console won't, but that is the only difference between them. They don't have an interact method. For now I encourage you to subclass Console / PyodideConsole and add the missing methods, but we should upstream these changes soon.

My guess is the following code will work, but I haven't tested it yet. I'll try to test it soon, but let me know if you try it and run into trouble.

Details
import sys
from pyodide import console


class Console(console.PyodideConsole):
    def write(self, txt):
        if self.stdout_callback:
            self.stdout_callback(txt)
        else:
            print(txt)

    def raw_input(self, prompt=""):
        """Write a prompt and read a line.

        The returned line does not include the trailing newline.
        When the user enters the EOF key sequence, EOFError is raised.

        The base implementation uses the built-in function
        input(); a subclass may replace this with a different
        implementation.

        """
        return input(prompt)

    async def interact(self, banner=None, exitmsg=None):
        """Closely emulate the interactive Python console.

        The optional banner argument specifies the banner to print
        before the first interaction; by default it prints a banner
        similar to the one printed by the real Python interpreter,
        followed by the current class name in parentheses (so as not
        to confuse this with the real interpreter -- since it's so
        close!).

        The optional exitmsg argument specifies the exit message
        printed when exiting. Pass the empty string to suppress
        printing an exit message. If exitmsg is not given or None,
        a default message is printed.

        """
        try:
            sys.ps1
        except AttributeError:
            sys.ps1 = ">>> "
        try:
            sys.ps2
        except AttributeError:
            sys.ps2 = "... "
        cprt = 'Type "help", "copyright", "credits" or "license" for more information.'
        if banner is None:
            banner = "Python %s on %s\n%s\n(%s)\n" % (
                sys.version,
                sys.platform,
                cprt,
                self.__class__.__name__,
            )
        self.write("%s\n" % str(banner))
        more = 0
        while 1:
            try:
                if more:
                    prompt = sys.ps2
                else:
                    prompt = sys.ps1
                try:
                    line = self.raw_input(prompt)
                except EOFError:
                    self.write("\n")
                    break
                else:
                    fut = self.push(line)
                    more = fut.syntax_check == "incomplete"
                    # raises an error if there was a syntax error or error
                    res = await fut
                    if res:
                        self.write(
                            console.repr_shorten(
                                res, separator="\n<long output truncated>\n"
                            )
                        )
            except KeyboardInterrupt:
                self.write("\nKeyboardInterrupt\n")
                self.buffer = []
                more = 0
        if exitmsg is None:
            self.write("now exiting %s...\n" % self.__class__.__name__)
        elif exitmsg != "":
            self.write("%s\n" % exitmsg)

@hoodmane
Copy link
Contributor

I'm sorry I missed the meeting this morning, I had to go to the DMV...

@antocuni
Copy link
Contributor
antocuni commented Oct 25, 2023

I agree with all the specs, with two minor points:

an attribute defines when the code should end up in a terminal or not

what happens if we have two tags running in the main thread:

<script type="py">print("hello")</script>
<script type="py" terminal>print("world")</script>

The stdout redirection is per-interpreter and these two tags share the same interpreter. I expect that they would print hello world to a terminal attached to the 2nd tag.
Maybe we want to specify it in the docs.

I hence believe a console.warn is the best way to go because the intent is not fully broken,

I firmly believe that we should never user console for warnings and errors.
We should always assume that our users don't have access and/or don't know what are the devtools, so warnings should always go to the DOM.

@ntoll
Copy link
Member
ntoll commented Oct 25, 2023

I firmly believe that we should never user console for warnings and errors.
We should always assume that our users don't have access and/or don't know what are the devtools, so warnings should always go to the DOM.

Hmmm... I'd put it more like, "*by default warnings and errors should go to the DOM (but this can be turned off)". I can think of some cases where app authors don't want this sort of behaviour. E.g. a non-beta "final" version of their code may not display such things, but log them silently in a way that allows the app author to see what their non-technical user is doing.

E8FF

@WebReflection
Copy link
Contributor Author

@JoshuaLowe1002 I will check that but the error plugin doesn't work for workers ... this MR should stop having anyone trying anything as it is not what we'll ship. I am saying this so that nobody would waste time on this MR, thanks for your understanding.

@WebReflection
Copy link
Contributor Author

This can be closed after this MR that just landed: #1816

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

8 participants
0