diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0337e84..65631c4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -53,3 +53,6 @@ jobs: - name: Run jpterm run: uv run jpterm --help + + - name: Run tests + run: uv run --group test pytest tests -v --color=yes diff --git a/docs/usage/CLI.md b/docs/usage/CLI.md index 0b0eab2..ec436ba 100644 --- a/docs/usage/CLI.md +++ b/docs/usage/CLI.md @@ -1,40 +1,19 @@ -Once jpterm has been installed, it can be launched from the command line. You can show the help message like so: +When jpterm is invoked with a command, it runs as a CLI in your terminal without taking control of it, inlining potential outputs below the command. -```bash -$ jpterm --help - - Usage: jpterm [OPTIONS] - -╭─ Options ─────────────────────────────────────────────────────────────────────╮ -│ --logo Show the jpterm logo. │ -│ --server TEXT The URL to the Jupyter server. │ -│ --collaborative/--no-collaborative Collaborative mode (with a server). │ -│ --experimental/--no-experimental Experimental mode (with Jupyverse). │ -│ --configfile TEXT Read YAML configuration file. │ -│ --set TEXT Set configuration. │ -│ --help Show this message and exit. │ -╰───────────────────────────────────────────────────────────────────────────────╯ -``` - -The easiest way to launch jpterm is simply by entering `jpterm` and hitting *Enter*. Jpterm will run locally on your machine, without needing a Jupyter server. - -Jpterm can also be a client to a Jupyter server, just like when you use JupyterLab. While JupyterLab is a Web client that runs in the browser, jpterm runs in the terminal. The two main Jupyter servers are [jupyter-server](https://github.com/jupyter-server/jupyter_server) and [jupyverse](https://github.com/jupyter-server/jupyverse). You can install and launch them like so: +Use the `show` command to display the (rich) content of a file: ```bash -# Install jupyter-server: -pip install "jupyterlab>=4" -pip install jupyter-collaboration -# Launch it: -jupyter lab --ServerApp.token='' --ServerApp.password='' --ServerApp.disable_check_xsrf=True --no-browser --port=8000 +# Without a server: +jpterm show path/to/file -# Or install jupyverse: -pip install "jupyverse[jupyterlab, noauth]" -# Launch it: -jupyverse +# With a server: +jpterm --server http://127.0.0.1:8000 show path/to/file ``` -Then pass the URL of the Jupyter server to jpterm through the `--server` option: +By "rich content" we mean that for instance, a Python file will be rendered with Python syntax highlighting, a Jupyter notebook will be rendered with a nice view of cells, etc. + +If you pass `--json` then the content will not be represented as a rich output but as JSON. This can be useful for passing the output to another tool: ```bash -jpterm --server http://127.0.0.1:8000 +jpterm show path/to/notebook --json | jq -r '.cells[0].source' ``` diff --git a/docs/usage/TUI.md b/docs/usage/TUI.md new file mode 100644 index 0000000..d0054b6 --- /dev/null +++ b/docs/usage/TUI.md @@ -0,0 +1,43 @@ +Once jpterm has been installed, it can be launched from the command line. You can show the help message like so: + +``` +$ jpterm --help + + Usage: jpterm [OPTIONS] [COMMAND] [ARGS]... + +╭─ Options ──────────────────────────────────────────────────────────────────────────────────────╮ +│ --logo Show the jpterm logo. │ +│ --server TEXT The URL to the Jupyter server. │ +│ --backend TEXT The name of the event loop to use (asyncio or trio). │ +│ --collaborative/--no-collaborative Collaborative mode (with a server). │ +│ --experimental/--no-experimental Experimental mode (with Jupyverse). │ +│ --set TEXT Set configuration. │ +│ --help Show this message and exit. │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Commands ─────────────────────────────────────────────────────────────────────────────────────╮ +│ show Show document PATH inline using its registered editor. │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯ +``` + +The easiest way to launch jpterm is simply by entering `jpterm` and hitting *Enter*. Jpterm will run locally on your machine, without needing a Jupyter server. This is the TUI mode (Text User Interface), where jpterm takes control of your terminal and runs as a full-screen application. There is also a [CLI](CLI.md) mode (Command Line Interface). + +Jpterm can also run as a client to a Jupyter server, just like when you use JupyterLab. While JupyterLab is a Web client that runs in the browser, jpterm runs in the terminal. The two main Jupyter servers are [jupyter-server](https://github.com/jupyter-server/jupyter_server) and [jupyverse](https://github.com/jupyter-server/jupyverse). You can install and launch them like so: + +```bash +# Install jupyter-server (through JupyterLab): +pip install jupyterlab +pip install jupyter-collaboration +# Launch it: +jupyter lab --ServerApp.token='' --ServerApp.password='' --ServerApp.disable_check_xsrf=True --no-browser --port=8000 + +# Or install jupyverse: +pip install "jupyverse[jupyterlab,noauth,collaboration]" +# Launch it: +jupyverse +``` + +Then pass the URL of the Jupyter server to jpterm through the `--server` option: + +```bash +jpterm --server http://127.0.0.1:8000 +``` diff --git a/mkdocs.yml b/mkdocs.yml index 91cc471..0681135 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -31,6 +31,7 @@ nav: - Overview: index.md - install.md - Usage: + - usage/TUI.md - usage/CLI.md - usage/key_bindings.md - Plugins: diff --git a/plugins/editors/txl_editors/main.py b/plugins/editors/txl_editors/main.py index e296aee..1f0ec55 100644 --- a/plugins/editors/txl_editors/main.py +++ b/plugins/editors/txl_editors/main.py @@ -36,7 +36,7 @@ def register_editor_factory( self.ext_editor_factories[ext] = [] self.ext_editor_factories[ext].append(editor_factory) - async def on_open(self, path: str) -> None: + def create_editor(self, path: str) -> Editor: path = Path(path) extension = path.suffix for ext, editor_factories in self.ext_editor_factories.items(): @@ -47,8 +47,11 @@ async def on_open(self, path: str) -> None: if None not in self.ext_editor_factories: raise RuntimeError(f"Could not find an editor for file extension {extension}") preferred_editor_factory = self.ext_editor_factories[None][0] - preferred_editor = preferred_editor_factory() - self.main_area.show(preferred_editor, path.name) + return preferred_editor_factory() + + async def on_open(self, path: str) -> None: + preferred_editor = self.create_editor(path) + self.main_area.show(preferred_editor, Path(path).name) bindings = preferred_editor.get_bindings() if bindings: self.footer.update_bindings(bindings) diff --git a/plugins/jpterm/txl_jpterm/main.py b/plugins/jpterm/txl_jpterm/main.py index 12612af..ce22d1d 100644 --- a/plugins/jpterm/txl_jpterm/main.py +++ b/plugins/jpterm/txl_jpterm/main.py @@ -1,3 +1,4 @@ +from anyio import Event from fps import Module from textual._context import active_app from textual.app import App @@ -19,20 +20,30 @@ class Jpterm(App): ("q", "quit", "Quit"), ] show_browser = var(True) + cli: bool = False def __init__(self, header, footer, main_area, *args, **kwargs): self.header = header self.footer = footer self.main_area = main_area + self.shell_ready = Event() super().__init__(*args, **kwargs) def start(self, launcher, file_browser) -> None: + self.launcher = launcher + self.file_browser = file_browser + self.shell_ready.set() + + async def on_mount(self) -> None: + if self.cli: + return + await self.shell_ready.wait() active_app.set(self) - self.mount(self.header) - container = Container(file_browser, self.main_area) - self.mount(container) - self.mount(self.footer) - self.main_area.show(launcher, "Launcher", mount=False) + await self.mount(self.header) + container = Container(self.file_browser, self.main_area) + await self.mount(container) + await self.mount(self.footer) + self.main_area.show(self.launcher, "Launcher", mount=False) def watch_show_browser(self, show_browser: bool) -> None: self.set_class(show_browser, "-show-browser") diff --git a/plugins/markdown_viewer/txl_markdown_viewer/main.py b/plugins/markdown_viewer/txl_markdown_viewer/main.py index aa452d8..db76d52 100644 --- a/plugins/markdown_viewer/txl_markdown_viewer/main.py +++ b/plugins/markdown_viewer/txl_markdown_viewer/main.py @@ -23,17 +23,24 @@ async def open(self, path: str) -> None: self.ytext = await self.contents.get(path, type="file") self.focus() await self._update_viewer() - self.ytext.observe(self.on_change) + if not self.app.cli: + self.ytext.observe(self.on_change) async def _update_viewer(self): md = self.ytext.source if self.viewer is not None: - self.viewer.remove() - self.viewer = TextualMarkdownViewer(md, show_table_of_contents=True) - self.mount(self.viewer) + await self.viewer.remove() + self.viewer = TextualMarkdownViewer(show_table_of_contents=not self.app.cli) + if self.app.cli: + self.viewer.styles.height = "auto" + self.viewer.styles.overflow_y = "hidden" + self.viewer.styles.scrollbar_size_vertical = 0 + self.viewer.styles.scrollbar_gutter = "auto" + await self.mount(self.viewer) + await self.viewer.document.update(md) def on_change(self, target, event): - self.task_group.create_task(self.update_viewer()) + self.task_group.create_task(self._update_viewer()) class MarkdownViewerModule(Module): diff --git a/plugins/notebook_editor/pyproject.toml b/plugins/notebook_editor/pyproject.toml index b6bce93..c05838a 100644 --- a/plugins/notebook_editor/pyproject.toml +++ b/plugins/notebook_editor/pyproject.toml @@ -11,7 +11,7 @@ requires-python = ">=3.10" license = "MIT" keywords = [] classifiers = [ "Development Status :: 4 - Beta", "Programming Language :: Python", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy",] -dependencies = [ "txl ==0.3.3", "pycrdt >=0.14.1,<0.15.0", "jupyter-ydoc >=4.1.1,<5.0.0",] +dependencies = [ "httpx2>=2.6.0", "txl ==0.3.3", "pycrdt >=0.14.1,<0.15.0", "jupyter-ydoc >=4.1.1,<5.0.0",] [[project.authors]] name = "David Brochart" email = "david.brochart@gmail.com" diff --git a/plugins/notebook_editor/txl_notebook_editor/main.py b/plugins/notebook_editor/txl_notebook_editor/main.py index dc23689..41552bb 100644 --- a/plugins/notebook_editor/txl_notebook_editor/main.py +++ b/plugins/notebook_editor/txl_notebook_editor/main.py @@ -5,7 +5,7 @@ import anyio from fps import Module -from httpx import AsyncClient +from httpx2 import AsyncClient from textual._context import active_app from textual.app import App, ComposeResult, RenderResult from textual.containers import VerticalScroll @@ -85,16 +85,11 @@ def __init__( self.cell_i = 0 self.cell_copy = None self.edit_mode = False - self.nb_change_target = StapledObjectStream( - *anyio.create_memory_object_stream[Any](max_buffer_size=math.inf) - ) - self.nb_change_events = StapledObjectStream( - *anyio.create_memory_object_stream[Any](max_buffer_size=math.inf) - ) self.top_bar = TopBar() def compose(self) -> ComposeResult: - yield self.top_bar + if not self.app.cli: + yield self.top_bar def watch_busy(self, busy): self.top_bar.busy = busy @@ -153,13 +148,20 @@ async def open(self, path: str | None = None) -> None: self.path = path self.ynb = await self.contents.get(self.path, type="notebook", format="json") self.update() - self.ynb.observe(self.on_change) - self.task_group.create_task(self.observe_nb_changes()) + if not self.app.cli: + self.nb_change_target = StapledObjectStream( + *anyio.create_memory_object_stream[Any](max_buffer_size=math.inf) + ) + self.nb_change_events = StapledObjectStream( + *anyio.create_memory_object_stream[Any](max_buffer_size=math.inf) + ) + self.ynb.observe(self.on_change) + self.task_group.create_task(self.observe_nb_changes()) def update(self): ipynb = self.ynb.source self.language = ipynb.get("metadata", {}).get("kernelspec", {}).get("language", None) - if self.kernel is None: + if self.kernel is None and not self.app.cli: kernel_name = ipynb.get("metadata", {}).get("kernelspec", {}).get("name") if kernel_name: self.kernel = self.kernels(kernel_name) @@ -168,9 +170,15 @@ def update(self): cell = self.cell_factory( self.ynb.ycells[i_cell], self.language, self.kernel ) + if self.app.cli: + cell.source.read_only = True + cell.source.highlight_cursor_line = False + cell.source.show_cursor = False + cell.source.styles.max_height = None + cell.source.styles.scrollbar_size_vertical = 0 self.mount(cell) self.cells.append(cell) - if self.cells: + if self.cells and not self.app.cli: self.cells[self.cell_i].select() def on_change(self, target, events): @@ -240,6 +248,8 @@ async def observe_nb_changes(self): idx += 1 async def on_key(self, event: Event) -> None: + if self.app.cli: + return if event.key == Keys.Escape: self.edit_mode = False self.current_cell.focus() @@ -416,7 +426,7 @@ async def start(self) -> None: main_area = await self.get(MainArea) app = await self.get(App) - _kernelspecs = await kernelspecs.get() + _kernelspecs = {} if app.cli else await kernelspecs.get() async with anyio.create_task_group() as self.tg: def notebook_editor_factory(): diff --git a/plugins/remote_contents/pyproject.toml b/plugins/remote_contents/pyproject.toml index baaaf3e..e7600a5 100644 --- a/plugins/remote_contents/pyproject.toml +++ b/plugins/remote_contents/pyproject.toml @@ -11,7 +11,7 @@ requires-python = ">=3.10" license = "MIT" keywords = [] classifiers = [ "Development Status :: 4 - Beta", "Programming Language :: Python", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy",] -dependencies = [ "txl ==0.3.3", "httpx>=0.23.1", "httpx-ws>=0.4.2", "pycrdt >=0.14.1,<0.15.0", "pycrdt-websocket >=0.16.4,<0.17.0", "jupyter-ydoc >=4.1.1,<5.0.0",] +dependencies = [ "txl ==0.3.3", "httpx2[ws]>=2.6.0", "pycrdt >=0.14.1,<0.15.0", "pycrdt-websocket >=0.16.4,<0.17.0", "jupyter-ydoc >=4.1.1,<5.0.0",] [[project.authors]] name = "David Brochart" email = "david.brochart@gmail.com" diff --git a/plugins/remote_contents/txl_remote_contents/main.py b/plugins/remote_contents/txl_remote_contents/main.py index 805ff10..93065fa 100644 --- a/plugins/remote_contents/txl_remote_contents/main.py +++ b/plugins/remote_contents/txl_remote_contents/main.py @@ -4,11 +4,10 @@ from typing import Any, Dict, List, Optional, Union from urllib import parse -import httpx -from anyio import create_task_group, sleep_forever +import httpx2 +from anyio import Event, create_task_group, sleep_forever from fps import Module -from httpx_ws import aconnect_ws -from pycrdt import Doc, Provider +from pycrdt import Doc, Provider, YMessageType, YSyncMessageType from pycrdt.websocket.websocket import HttpxWebsocket from txl.base import Contents @@ -16,6 +15,23 @@ ydocs = {ep.name: ep.load() for ep in entry_points(group="jupyter_ydoc")} +class SyncedWebsocket(HttpxWebsocket): + """Signal after the provider has applied the initial sync response.""" + + def __init__(self, websocket, path, synced=None): + super().__init__(websocket, path) + self.synced = Event() if synced is None else synced + self.received_sync = False + + async def __anext__(self): + if self.received_sync: + self.synced.set() + message = await super().__anext__() + if message[:2] == bytes([YMessageType.SYNC, YSyncMessageType.SYNC_STEP2]): + self.received_sync = True + return message + + class Entry: """Provide a scandir-like API""" @@ -37,13 +53,13 @@ def is_dir(self) -> bool: class RemoteContents(Contents): base_url: str query_params: Dict[str, List[str]] - cookies: httpx.Cookies + cookies: httpx2.Cookies def __init__( self, base_url: str, query_params: Dict[str, List[str]], - cookies: httpx.Cookies, + cookies: httpx2.Cookies, collaborative: bool, task_group, ) -> None: @@ -65,13 +81,14 @@ async def get( ) -> Union[List, Doc]: path = "" if path == "." else path if is_dir or not self.collaborative: - async with httpx.AsyncClient() as client: + async with httpx2.AsyncClient() as client: r = await client.get( f"{self.base_url}/api/contents/{path}", params={**{"content": 1}, **self.query_params}, cookies=self.cookies, ) self.cookies.update(r.cookies) + r.raise_for_status() model = r.json() if model["type"] == "directory": dir_list = [Entry(entry) for entry in model["content"]] @@ -93,7 +110,7 @@ async def get( else: format = "text" doc_type = type # if type == "notebook" else "file" - async with httpx.AsyncClient() as client: + async with httpx2.AsyncClient() as client: response = await client.put( f"{self.base_url}/api/collaboration/session/{path}", json={"format": format, "type": doc_type}, @@ -101,12 +118,15 @@ async def get( cookies=self.cookies, ) self.cookies.update(response.cookies) + response.raise_for_status() r = response.json() room_id = f"{r['format']}:{r['type']}:{r['fileId']}" session_id = r["sessionId"] ydoc = Doc() jupyter_ydoc = ydocs[type](ydoc) - self.task_group.start_soon(self.websocket_provider, room_id, ydoc, session_id) + synced = Event() + self.task_group.start_soon(self.websocket_provider, room_id, ydoc, session_id, synced) + await synced.wait() self.document_id[jupyter_ydoc] = room_id return jupyter_ydoc @@ -133,7 +153,7 @@ async def save( "path": path, "type": typ, } - async with httpx.AsyncClient() as client: + async with httpx2.AsyncClient() as client: r = await client.put( f"{self.base_url}/api/contents/{path}", json=content, @@ -142,13 +162,14 @@ async def save( ) self.cookies.update(r.cookies) - async def websocket_provider(self, room_id, ydoc, session_id=None): + async def websocket_provider(self, room_id, ydoc, session_id=None, synced=None): ws_url = f"{self.ws_url}/api/collaboration/room/{room_id}" params = None if session_id is None else {"sessionId": session_id} - async with aconnect_ws( - ws_url, cookies=self.cookies, params=params + async with httpx2.AsyncClient(cookies=self.cookies) as client, client.websocket( + ws_url, params=params ) as websocket: - async with Provider(ydoc, HttpxWebsocket(websocket, room_id)): + channel = SyncedWebsocket(websocket, room_id, synced) + async with Provider(ydoc, channel): await sleep_forever() @@ -162,7 +183,7 @@ async def start(self) -> None: parsed_url = parse.urlparse(self.url) base_url = parse.urljoin(self.url, parsed_url.path).rstrip("/") query_params = parse.parse_qs(parsed_url.query) - cookies = httpx.Cookies() + cookies = httpx2.Cookies() async with create_task_group() as tg: contents = RemoteContents(base_url, query_params, cookies, self.collaborative, tg) self.put(contents, Contents) diff --git a/plugins/remote_kernels/pyproject.toml b/plugins/remote_kernels/pyproject.toml index 99a331d..49ee016 100644 --- a/plugins/remote_kernels/pyproject.toml +++ b/plugins/remote_kernels/pyproject.toml @@ -11,7 +11,7 @@ requires-python = ">=3.10" license = "MIT" keywords = [] classifiers = [ "Development Status :: 4 - Beta", "Programming Language :: Python", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy",] -dependencies = [ "txl ==0.3.3", "txl_kernel", "httpx >=0.23.1", "httpx-ws >=0.4.2", "pycrdt >=0.14.1,<0.15.0",] +dependencies = [ "txl ==0.3.3", "txl_kernel", "httpx2[ws]>=2.6.0", "pycrdt >=0.14.1,<0.15.0",] [[project.authors]] name = "David Brochart" email = "david.brochart@gmail.com" diff --git a/plugins/remote_kernels/txl_remote_kernels/driver.py b/plugins/remote_kernels/txl_remote_kernels/driver.py index 18be423..ee35e93 100644 --- a/plugins/remote_kernels/txl_remote_kernels/driver.py +++ b/plugins/remote_kernels/txl_remote_kernels/driver.py @@ -4,9 +4,8 @@ from typing import Any, Dict from urllib import parse -import httpx +import httpx2 from anyio import Lock, sleep -from httpx_ws import aconnect_ws from txl_kernel.driver import KernelMixin from txl_kernel.message import date_to_str @@ -36,7 +35,7 @@ def __init__( parsed_url = parse.urlparse(url) self.base_url = parse.urljoin(url, parsed_url.path).rstrip("/") self.query_params = parse.parse_qs(parsed_url.query) - self.cookies = httpx.Cookies() + self.cookies = httpx2.Cookies() i = self.base_url.find(":") self.ws_url = ("wss" if self.base_url[i - 1] == "s" else "ws") + self.base_url[i:] self.start_task = task_group.create_task(self.start()) @@ -49,7 +48,7 @@ def __init__( async def start(self): i = str(uuid.uuid4()) - async with httpx.AsyncClient() as client: + async with httpx2.AsyncClient() as client: body = { "kernel": {"name": self.kernel_name}, "name": i, @@ -72,10 +71,9 @@ async def start(self): ) if r.status_code != 200 or self.kernel_id != r.json()["id"]: return - async with aconnect_ws( + async with httpx2.AsyncClient(cookies=self.cookies) as client, client.websocket( f"{self.ws_url}/api/kernels/{self.kernel_id}/channels", params={"session_id": self.session_id}, - cookies=self.cookies, subprotocols=["v1.kernel.websocket.jupyter.org"], ) as self.websocket: recv_task = self.task_group.create_task(self._recv()) diff --git a/plugins/remote_kernels/txl_remote_kernels/main.py b/plugins/remote_kernels/txl_remote_kernels/main.py index 64bceb4..7db79be 100644 --- a/plugins/remote_kernels/txl_remote_kernels/main.py +++ b/plugins/remote_kernels/txl_remote_kernels/main.py @@ -3,7 +3,7 @@ from typing import Any from urllib import parse -import httpx +import httpx2 from anyio import create_task_group, sleep from fps import Module from pycrdt import Map @@ -37,12 +37,12 @@ def __init__( parsed_url = parse.urlparse(url) self.base_url = parse.urljoin(url, parsed_url.path).rstrip("/") self.query_params = parse.parse_qs(parsed_url.query) - self.cookies = httpx.Cookies() + self.cookies = httpx2.Cookies() async def get(self) -> dict[str, Any]: url = f"{self.base_url}/api/kernelspecs" try: - async with httpx.AsyncClient() as client: + async with httpx2.AsyncClient() as client: r = await client.get( url, params={**self.query_params}, @@ -51,7 +51,7 @@ async def get(self) -> dict[str, Any]: d = r.json() self.cookies.update(r.cookies) return d - except httpx.ConnectError: + except httpx2.ConnectError: raise RuntimeError(f"Could not connect to a Jupyter server at {url}") diff --git a/plugins/remote_terminals/pyproject.toml b/plugins/remote_terminals/pyproject.toml index b1eaced..9b68f08 100644 --- a/plugins/remote_terminals/pyproject.toml +++ b/plugins/remote_terminals/pyproject.toml @@ -11,7 +11,7 @@ requires-python = ">=3.10" license = "MIT" keywords = [] classifiers = [ "Development Status :: 4 - Beta", "Programming Language :: Python", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy",] -dependencies = [ "txl ==0.3.3", "httpx>=0.23.1", "httpx-ws>=0.4.2",] +dependencies = [ "txl ==0.3.3", "httpx2[ws]>=2.6.0",] [[project.authors]] name = "David Brochart" email = "david.brochart@gmail.com" diff --git a/plugins/remote_terminals/txl_remote_terminals/main.py b/plugins/remote_terminals/txl_remote_terminals/main.py index c8edc8f..eff423c 100644 --- a/plugins/remote_terminals/txl_remote_terminals/main.py +++ b/plugins/remote_terminals/txl_remote_terminals/main.py @@ -2,10 +2,9 @@ from typing import Any, Dict, List from urllib import parse -import httpx +import httpx2 from anyio import Event, create_memory_object_stream, create_task_group, sleep from fps import Module -from httpx_ws import aconnect_ws from textual.widget import Widget from textual.widgets._header import HeaderTitle @@ -22,7 +21,7 @@ def __init__( self, base_url: str, query_params: Dict[str, List[str]], - cookies: httpx.Cookies, + cookies: httpx2.Cookies, header: Header, terminal: TerminalFactory, task_group, @@ -45,7 +44,7 @@ def __init__( super().__init__() async def open(self): - async with httpx.AsyncClient() as client: + async with httpx2.AsyncClient() as client: response = await client.post( f"{self.base_url}/api/terminals", json={"cwd": ""}, @@ -66,8 +65,8 @@ async def open(self): terminal.focus() self.mount(terminal) await terminal.size_set.wait() - async with aconnect_ws( - f"{self.ws_url}/terminals/websocket/{name}", cookies=self.cookies + async with httpx2.AsyncClient(cookies=self.cookies) as client, client.websocket( + f"{self.ws_url}/terminals/websocket/{name}" ) as self.websocket: self.task_group.start_soon(self._recv) self.send_task = self.task_group.create_task(self._send()) @@ -104,7 +103,7 @@ async def start(self) -> None: parsed_url = parse.urlparse(self.url) base_url = parse.urljoin(self.url, parsed_url.path).rstrip("/") query_params = parse.parse_qs(parsed_url.query) - cookies = httpx.Cookies() + cookies = httpx2.Cookies() async with create_task_group() as self.tg: def terminals_factory(): diff --git a/plugins/text_editor/txl_text_editor/main.py b/plugins/text_editor/txl_text_editor/main.py index e32f046..4594370 100644 --- a/plugins/text_editor/txl_text_editor/main.py +++ b/plugins/text_editor/txl_text_editor/main.py @@ -27,21 +27,36 @@ def __init__(self, contents: Contents, main_area: MainArea, task_group) -> None: self.contents = contents self.main_area = main_area self.task_group = task_group - self.change_target = StapledObjectStream( - *create_memory_object_stream[Any](max_buffer_size=math.inf) - ) - self.change_events = StapledObjectStream( - *create_memory_object_stream[Any](max_buffer_size=math.inf) - ) async def open(self, path: str) -> None: self.path = path self.ytext = await self.contents.get(path, type="file") self.editor = TextInput(ytext=self.ytext._ysource, path=path) + if self.app.cli: + self.editor.read_only = True + self.editor.highlight_cursor_line = False + self.editor.show_cursor = False + self.editor.styles.height = "auto" + self.editor.styles.max_height = None + self.editor.styles.scrollbar_size_vertical = 0 + self.editor.styles.border = ("none", "transparent") + self.editor.styles.padding = 0 + # TextArea treats a terminating newline as an extra editable row. + # A read-only printout already terminates its final rendered line. + text = self.editor.text + if text.endswith("\n"): + self.editor.load_text(text[:-2] if text.endswith("\r\n") else text[:-1]) self.task_group.start_soon(self.editor.start) self.mount(self.editor) - self.ytext.observe(self.on_change) - self.task_group.start_soon(self.observe_changes) + if not self.app.cli: + self.change_target = StapledObjectStream( + *create_memory_object_stream[Any](max_buffer_size=math.inf) + ) + self.change_events = StapledObjectStream( + *create_memory_object_stream[Any](max_buffer_size=math.inf) + ) + self.ytext.observe(self.on_change) + self.task_group.start_soon(self.observe_changes) async def close(self) -> None: await self.editor.stop() @@ -65,6 +80,8 @@ async def observe_changes(self): self.main_area.set_dirty(self) async def on_key(self, event: Event) -> None: + if self.app.cli: + return if event.key == Keys.ControlS: await self.contents.save(self.path, self.ytext) self.main_area.clear_dirty(self) diff --git a/pyproject.toml b/pyproject.toml index a7c5706..13f0cd2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,10 @@ dependencies = [ ] [dependency-groups] +test = [ + "pytest", + "jupyverse[jupyterlab,noauth]", +] docs = [ "mkdocs", "mkdocs-material", @@ -106,3 +110,6 @@ select = [ [tool.ruff] line-length = 100 + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/src/jpterm/cli.py b/src/jpterm/cli.py index 654358a..9e10cee 100644 --- a/src/jpterm/cli.py +++ b/src/jpterm/cli.py @@ -5,9 +5,11 @@ import rich_click as click from rich import print as rich_print -from txl.app import disabled +from txl.app import disabled, modules from txl.cli import set_main, txl_main +from .show import show + def jpterm_main(kwargs): logo = kwargs.pop("logo") @@ -20,6 +22,13 @@ def jpterm_main(kwargs): collaborative = kwargs.pop("collaborative") experimental = kwargs.pop("experimental") set_ = list(kwargs["set_"]) + backend_modules = { + name for name in modules if name.startswith(("local_", "remote_")) + } + disabled[:] = [name for name in disabled if name not in backend_modules] + disabled.extend( + name for name in backend_modules if name.startswith("local_" if server else "remote_") + ) if server: set_.append(f"remote_contents.url={server}") set_.append(f"remote_terminals.url={server}") @@ -27,23 +36,6 @@ def jpterm_main(kwargs): set_.append(f"remote_kernelspecs.url={server}") set_.append(f"remote_contents.collaborative={collaborative}") set_.append(f"notebook_editor.experimental={experimental}") - disabled.extend( - [ - "local_contents", - "local_terminals", - "local_kernels", - "local_kernelspecs", - ] - ) - else: - disabled.extend( - [ - "remote_contents", - "remote_terminals", - "remote_kernels", - "remote_kernelspecs", - ] - ) kwargs["set_"] = set_ return kwargs @@ -70,10 +62,26 @@ def main(): help="Experimental mode (with Jupyverse).", ), ] - _main = txl_main + @functools.wraps(txl_main) + def _main(**kwargs): + return txl_main(**kwargs) + + _main.__click_params__ = list(getattr(txl_main, "__click_params__", [])) for decorator in decorators[::-1]: _main = decorator(_main) - command = click.command()(_main) + @click.group(invoke_without_command=True) + @click.pass_context + def command(ctx, **kwargs): + if ctx.invoked_subcommand is None: + _main(**kwargs) + else: + if kwargs["collaborative"] and not kwargs["server"]: + raise click.UsageError("--collaborative requires --server for CLI commands.") + ctx.obj = jpterm_main(kwargs) + + # Keep the existing TUI options on the root command. + command.params.extend(click.command()(_main).params) + command.add_command(show) command() diff --git a/src/jpterm/show.py b/src/jpterm/show.py new file mode 100644 index 0000000..f3aaf6c --- /dev/null +++ b/src/jpterm/show.py @@ -0,0 +1,93 @@ +"""Render documents off-screen and print their full-height layout once.""" + +import json +from pathlib import PurePosixPath + +import anyio +import rich_click as click +from fps import get_root_module +from textual import events +from textual.geometry import Size + +from txl.app import AppModule +from txl.base import Contents, Editors + + +async def load_document(path, settings, timeout, render=False): + config = {"type": AppModule, "config": {"cli": True, "inline": render}} + for setting in settings: + if "=" not in setting: + raise ValueError(f"No '=' while setting a module parameter: {setting}") + key, value = setting.split("=", 1) + node = config + parts = key.split(".") + for name in parts[:-1]: + node = node.setdefault("modules", {}).setdefault(name, {}) + node.setdefault("config", {})[parts[-1]] = value + module = get_root_module({"app": config}) + module._global_start_timeout = timeout + with anyio.fail_after(timeout): + async with module: + if render: + await module.app_ready.wait() + app = module.app + editors = await module.get(Editors) + editor = editors.create_editor(path) + editor.styles.height = "auto" + editor.styles.overflow_y = "hidden" + editor.styles.scrollbar_size_vertical = 0 + app.screen.styles.height = "auto" + app.screen.styles.border = ("none", "transparent") + app.screen.styles.padding = 0 + app.screen.styles.scrollbar_size_vertical = 0 + await app.screen.mount(editor) + await editor.open(path) + + def show_full_height(): + height = max(1, editor.virtual_size.height + editor.styles.gutter.height) + size = Size(app.size.width, height) + if app.size == size: + # Freeze the finished layout before the app shuts down. + app.exit(message=app.screen._compositor.__rich__()) + return + app.post_message(events.Resize(size, size)) + app.screen.post_message(events.Resize(size, size)) + app.call_after_refresh(lambda: app.call_after_refresh(show_full_height)) + + app.call_after_refresh(show_full_height) + await module.app_exited.wait() + return None + else: + contents = await module.get(Contents) + kind = "notebook" if PurePosixPath(path).suffix == ".ipynb" else "file" + document = await contents.get(path, type=kind) + source = document.source + return source + + +@click.command() +@click.argument("path") +@click.option("--json", "as_json", is_flag=True, help="Print the document source as JSON.") +@click.option("--timeout", type=click.FloatRange(min=0, min_open=True), default=30.0, + show_default=True, help="Maximum time to load and synchronize, in seconds.") +@click.pass_context +def show(ctx, path, as_json, timeout): + """Show document PATH inline using its registered editor. + + Display the full document and return immediately. This command does not execute cells. + Use --json for structured output. + """ + options = ctx.obj + try: + source = anyio.run( + load_document, path, options["set_"], timeout, not as_json, + backend=options["backend"], + ) + if as_json: + click.echo(json.dumps(source, indent=2, ensure_ascii=False)) + except Exception as exc: + # AnyIO task groups may wrap the original loading error. + while getattr(exc, "exceptions", None): + exc = exc.exceptions[0] + message = "Timed out loading document" if isinstance(exc, TimeoutError) else str(exc) + raise click.ClickException(message) from exc diff --git a/tests/test_server_cli.py b/tests/test_server_cli.py new file mode 100644 index 0000000..9fe45b8 --- /dev/null +++ b/tests/test_server_cli.py @@ -0,0 +1,177 @@ +"""CLI integration checks against a real, non-collaborative Jupyverse server.""" + +import json +import os +import shutil +import socket +import subprocess +import sys +import time +from types import SimpleNamespace + +import httpx2 +import pytest + + +@pytest.fixture(scope="module") +def jupyverse_server(tmp_path_factory): + server = SimpleNamespace() + root = tmp_path_factory.mktemp("server-cli") + executable = os.environ.get("JUPYVERSE_EXECUTABLE") or shutil.which("jupyverse") + if executable is None: + pytest.skip("Install jupyverse[jupyterlab,noauth] to run server tests") + server_root = root / "server" + server.client_root = root / "client" + server_root.mkdir() + server.client_root.mkdir() + server.source_lines = [f"# server_cell_line_{index:02}" for index in range(40)] + server.source_lines.append("raise RuntimeError('show must not execute this cell')") + server.notebook = { + "nbformat": 4, + "nbformat_minor": 5, + "metadata": { + "kernelspec": { + "name": "must-not-start", + "display_name": "Must not start", + "language": "python", + } + }, + "cells": [ + { + "id": "server-cell", + "cell_type": "code", + "metadata": {}, + "source": "\n".join(server.source_lines), + "execution_count": 7, + "outputs": [ + {"output_type": "stream", "name": "stdout", "text": "saved_server_output\n"} + ], + } + ], + } + (server_root / "remote.ipynb").write_text(json.dumps(server.notebook), encoding="utf-8") + server.text = "SERVER LICENSE\n\nOnly available from the server.\n" + # Write exact bytes so Windows does not translate LF into CRLF. + (server_root / "LICENSE").write_bytes(server.text.encode("utf-8")) + server.crlf_text = server.text.replace("\n", "\r\n") + (server_root / "LICENSE_CRLF").write_bytes(server.crlf_text.encode("utf-8")) + server.markdown = "# Server heading\n\n" + "\n\n".join( + (f"server_paragraph_{index:02}" for index in range(30)) + ) + (server_root / "remote.md").write_text(server.markdown, encoding="utf-8") + for name in ("remote.ipynb", "LICENSE", "LICENSE_CRLF", "remote.md"): + (server.client_root / name).write_text("WRONG_LOCAL_CONTENT", encoding="utf-8") + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + port = listener.getsockname()[1] + server.url = f"http://127.0.0.1:{port}" + server.log_path = root / "server.log" + with server.log_path.open("w", encoding="utf-8") as log: + server.process = subprocess.Popen( + [executable, "--host", "127.0.0.1", "--port", str(port), "--timeout", "10"], + cwd=server_root, + stdout=log, + stderr=subprocess.STDOUT, + ) + try: + deadline = time.monotonic() + 20 + ready = False + with httpx2.Client(trust_env=False, timeout=0.5) as client: + while time.monotonic() < deadline: + if server.process.poll() is not None: + break + try: + response = client.get(f"{server.url}/api/contents/remote.ipynb") + if response.status_code == 200: + ready = True + break + except httpx2.HTTPError: + pass + time.sleep(0.1) + if not ready: + pytest.fail(f"Jupyverse did not become ready:\n{server.log_path.read_text()}") + yield server + finally: + if server.process.poll() is None: + server.process.terminate() + try: + server.process.wait(timeout=10) + except subprocess.TimeoutExpired: + server.process.kill() + server.process.wait(timeout=5) + + +@pytest.fixture +def run_cli(jupyverse_server): + + def run(*arguments, expected_code=0): + environment = dict(os.environ, PYTHONIOENCODING="utf-8", NO_COLOR="1", COLUMNS="80") + result = subprocess.run( + [ + sys.executable, + "-c", + "from jpterm.cli import main; main()", + "--server", + jupyverse_server.url, + "show", + *arguments, + ], + cwd=jupyverse_server.client_root, + env=environment, + capture_output=True, + text=True, + encoding="utf-8", + timeout=20, + ) + assert result.returncode == expected_code, result.stdout + result.stderr + assert "WRONG_LOCAL_CONTENT" not in result.stdout + result.stderr + assert "Traceback" not in result.stderr + return result + + return run + + +def test_show_notebook(run_cli, jupyverse_server): + result = run_cli("remote.ipynb") + output = result.stdout + result.stderr + for line in jupyverse_server.source_lines: + assert output.count(line) == 1 + assert output.count("saved_server_output") == 1 + assert "In [7]" in output + with httpx2.Client(trust_env=False) as client: + response = client.get(f"{jupyverse_server.url}/api/kernels") + response.raise_for_status() + assert response.json() == [] + + +@pytest.mark.parametrize("path", ["remote.ipynb", "LICENSE", "LICENSE_CRLF"]) +def test_show_json(run_cli, jupyverse_server, path): + source = json.loads(run_cli(path, "--json").stdout) + if path == "LICENSE": + assert source == jupyverse_server.text + elif path == "LICENSE_CRLF": + assert source == jupyverse_server.crlf_text + else: + cell = source["cells"][0] + assert cell["id"] == "server-cell" + assert cell["source"] == jupyverse_server.notebook["cells"][0]["source"] + assert cell["execution_count"] == 7 + + +def test_show_plain_text(run_cli, jupyverse_server): + result = run_cli("LICENSE") + output = result.stdout + result.stderr + assert [line.rstrip() for line in output.splitlines()] == jupyverse_server.text.splitlines() + + +def test_show_markdown(run_cli, jupyverse_server): + result = run_cli("remote.md") + output = result.stdout + result.stderr + assert output.count("Server heading") == 1 + for index in range(30): + assert output.count(f"server_paragraph_{index:02}") == 1 + + +def test_missing_file(run_cli, jupyverse_server): + result = run_cli("missing.ipynb", expected_code=1) + assert "404" in result.stderr diff --git a/tests/test_show.py b/tests/test_show.py new file mode 100644 index 0000000..f561e2c --- /dev/null +++ b/tests/test_show.py @@ -0,0 +1,363 @@ +import json +from io import StringIO +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock, patch + +import anyio +import httpx2 +from click.testing import CliRunner +from fps import Module, get_root_module +from jpterm.cli import jpterm_main, main +from jpterm.show import load_document +from pycrdt import YMessageType, YSyncMessageType +from rich.console import Console +from textual.app import App +from textual.widgets import Static +from txl_editors.main import _Editors +from txl_jpterm.main import Jpterm +from txl_jpterm.main_area import MainArea +from txl_markdown_viewer.main import MarkdownViewer, MarkdownViewerModule +from txl_notebook_editor.main import NotebookEditor, NotebookEditorModule +from txl_remote_contents.main import RemoteContents, SyncedWebsocket, ydocs +from txl_text_editor.main import TextEditor, TextEditorModule + +from txl.app import AppModule +from txl.base import CellFactory, Contents, Editors, Kernels, Kernelspecs, Launcher +from txl.base import MainArea as MainAreaService + + +def test_backend_selection_uses_registered_modules(): + registered = dict.fromkeys([ + "local_contents", "remote_contents", "local_extra", "remote_extra", "custom_disabled", + ]) + disabled = ["custom_disabled"] + with patch("jpterm.cli.modules", registered), patch("jpterm.cli.disabled", disabled): + for server in ("", "http://server", ""): + jpterm_main({ + "logo": False, "server": server, "collaborative": False, + "experimental": False, "set_": [], + }) + prefix = "local_" if server else "remote_" + expected = {name for name in registered if name.startswith(prefix)} + assert set(disabled) == expected | {"custom_disabled"} + + +def test_ctrl_q_exits_application_and_stops_plugins(): + + class TestApp(App): + async def run_async(self, **kwargs): + ready = kwargs["auto_pilot"] + + async def quit(pilot): + await ready(pilot) + await pilot.press("ctrl+q") + + kwargs.update(headless=True, auto_pilot=quit) + return await super().run_async(**kwargs) + + class TestModule(Module): + async def start(self): + self.put(TestApp(), App) + self.done() + await anyio.sleep_forever() + + async def stop(self): + stopped.append(True) + + stopped = [] + + async def check(): + root = get_root_module({"app": {"type": AppModule}}) + with anyio.fail_after(3): + await root._main() + assert root.app_exited.is_set() + assert stopped + + with patch("txl.app.modules", {"test": TestModule}): + anyio.run(check) + + +def test_inline_uses_text_editor_for_text_file(): + lines = [f"text_line_{index:02}" for index in range(50)] + app, contents = render_file("\n".join(lines), "example.txt", TextEditorModule, TextEditor) + assert app.cli + contents.get.assert_awaited_once_with("example.txt", type="file") + for line in lines: + assert app.output.count(line) == 1 + + +def test_extensionless_file_falls_back_to_plain_text(): + app, contents = render_file( + "MIT License\n\nCopyright example\n", "LICENSE", TextEditorModule, TextEditor + ) + assert app.output.count("MIT License") == 1 + assert "Copyright example" in app.output + assert [line.rstrip() for line in app.output.splitlines()] == [ + "MIT License", + "", + "Copyright example", + ] + contents.get.assert_awaited_once_with("LICENSE", type="file") + assert contents.get.return_value.source == "MIT License\n\nCopyright example\n" + + +def test_inline_renders_markdown_to_end(): + source = ( + "# First heading\n\n" + + "\n\n".join((f"Paragraph {index:02}" for index in range(30))) + + "\n\n## Last heading\n\n| Name | Value |\n| --- | --- |\n| final_row | 42 |\n" + ) + app, _ = render_file(source, "example.md", MarkdownViewerModule, MarkdownViewer) + assert app.cli + for text in ("First heading", "Paragraph 29", "Last heading", "final_row", "42"): + assert app.output.count(text) == 1 + + +def test_markdown_preserves_block_spacing(): + app, _ = render_file( + "# Heading\n\nParagraph\n", "example.md", MarkdownViewerModule, MarkdownViewer + ) + lines = [line.strip() for line in app.output.splitlines()] + assert lines == ["", "", "Heading", "", "Paragraph", ""] + + +def render_file(source, path, module_type, editor_type): + document = ydocs["file"]() + document.source = source + contents = SimpleNamespace(get=AsyncMock(return_value=document)) + + class TestApp(App): + async def run_async(self, **kwargs): + self.requested_headless = kwargs["headless"] + kwargs.update(headless=True, inline=False, size=(80, 10)) + return await super().run_async(**kwargs) + + def exit(self, *args, **kwargs): + self.query_one(editor_type) + output = StringIO() + Console(file=output, width=80).print(kwargs.pop("message")) + self.output = output.getvalue() + return super().exit(*args, **kwargs) + + app = TestApp() + + class TestModule(Module): + async def start(self): + main_area = MainArea() + self.put(contents, Contents) + self.put(app, App) + self.put(main_area, MainAreaService) + self.put(_Editors(Static(), Static(), main_area), Editors) + + with patch("txl.app.modules", {"test": TestModule, "viewer": module_type}): + anyio.run(load_document, path, [], 5, True) + assert app.requested_headless + return (app, contents) + + +def test_tui_mounts_application_shell(): + + async def check(): + header, footer, browser, launcher = [Static() for _ in range(4)] + main_area = MainArea() + app = Jpterm(header, footer, main_area) + app.start(launcher, browser) + async with app.run_test() as pilot: + await pilot.pause() + assert header.is_mounted + assert footer.is_mounted + assert browser.is_mounted + assert launcher.is_mounted + + anyio.run(check) + + +def test_cli_does_not_start_application_shell(): + + async def check(): + app = Jpterm(Static(), Static(), Static()) + app.cli = True + async with app.run_test(): + assert len(app.screen.children) == 0 + + anyio.run(check) + + +def test_inline_uses_notebook_editor_without_starting_kernel(): + sources = [f"source_{index:02}" for index in range(30)] + sources[0] = "\n".join((f"long_line_{index:02}" for index in range(40))) + notebook = {"cells": [], "metadata": {"kernelspec": {"name": "python3"}}} + document = SimpleNamespace( + source=notebook, ycells=sources, cell_number=len(sources), observe=lambda callback: None + ) + contents = SimpleNamespace(get=AsyncMock(return_value=document)) + used_cells = [] + kernels = Mock() + kernelspecs = SimpleNamespace(get=AsyncMock()) + + class TestApp(App): + async def run_async(self, **kwargs): + self.inline_requested = kwargs["inline"] + kwargs.update(headless=True, inline=False, size=(80, 10)) + return await super().run_async(**kwargs) + + def exit(self, *args, **kwargs): + self.rendered = self.query_one(NotebookEditor).size.height > 0 + output = StringIO() + Console(file=output, width=80).print(kwargs.pop("message")) + self.output = output.getvalue() + self.final_height = self.screen.size.height + return super().exit(*args, **kwargs) + + app = TestApp() + + def factory(cell, language, kernel): + assert kernel is None + used_cells.append(cell) + widget = Static(cell) + widget.source = widget + widget.select = lambda: None + return widget + + class TestModule(Module): + async def start(self): + self.put(contents, Contents) + self.put(app, App) + self.put(factory, CellFactory) + self.put(kernels, Kernels) + self.put(kernelspecs, Kernelspecs) + self.put(SimpleNamespace(register=Mock()), Launcher) + main_area = MainArea() + self.put(main_area, MainAreaService) + self.put(_Editors(Static(), Static(), main_area), Editors) + + with patch("txl.app.modules", {"test": TestModule, "notebook_editor": NotebookEditorModule}): + anyio.run(load_document, "example.ipynb", [], 1, True) + assert app.inline_requested + assert used_cells == sources + assert app.rendered + assert app.final_height > 40 + for source in sources: + for line in source.splitlines(): + assert app.output.count(line) == 1 + kernels.assert_not_called() + kernelspecs.get.assert_not_awaited() + contents.get.assert_awaited_once_with("example.ipynb", type="notebook", format="json") + + +def test_collaborative_get_waits_for_sync(): + + async def check(): + provider_started = anyio.Event() + allow_sync = anyio.Event() + returned = anyio.Event() + response = httpx2.Response( + 200, + request=httpx2.Request("PUT", "http://server/api/collaboration/session/test"), + json={"format": "json", "type": "notebook", "fileId": "test", "sessionId": "session"}, + ) + client = AsyncMock() + client.put.return_value = response + client.__aenter__.return_value = client + async with anyio.create_task_group() as tasks: + contents = RemoteContents("http://server", {}, httpx2.Cookies(), True, tasks) + + async def provider(room, document, session, synced): + provider_started.set() + await allow_sync.wait() + synced.set() + + contents.websocket_provider = provider + + async def load(): + await contents.get("test", type="notebook") + returned.set() + + with patch("txl_remote_contents.main.httpx2.AsyncClient", return_value=client): + tasks.start_soon(load) + await provider_started.wait() + assert not returned.is_set() + allow_sync.set() + await returned.wait() + + anyio.run(check) + + +def test_load_uses_injected_contents(): + notebook = {"cells": []} + contents = SimpleNamespace(get=AsyncMock(return_value=SimpleNamespace(source=notebook))) + + class TestContentsModule(Module): + async def start(self): + self.put(contents, Contents) + self.put(App(), App) + + with patch("txl.app.modules", {"test_contents": TestContentsModule}): + result = anyio.run(load_document, "example.ipynb", [], 5) + assert result == notebook + contents.get.assert_awaited_once_with("example.ipynb", type="notebook") + + +def invoke(args): + with patch("sys.argv", ["jpterm", *args]): + with CliRunner().isolation() as output: + try: + main() + except SystemExit as exc: + code = exc.code + else: + code = 0 + return (code, output[0].getvalue().decode() + output[1].getvalue().decode()) + + +def test_json_and_inline(): + notebook = { + "cells": [ + { + "id": "abc", + "cell_type": "code", + "source": ["print(1)"], + "execution_count": 1, + "outputs": [{"output_type": "stream", "text": ["1\n"]}], + } + ] + } + with patch("jpterm.show.load_document", AsyncMock(return_value=notebook)) as load: + code, output = invoke(["show", "example.ipynb", "--json"]) + assert code == 0 + assert json.loads(output) == notebook + assert not load.call_args.args[-1] + code, output = invoke(["show", "example.ipynb"]) + assert code == 0 + assert load.call_args.args[-1] + + +def test_tui_default(): + with patch("txl.cli.run") as run: + code, _ = invoke([]) + assert code == 0 + run.assert_called_once() + + +def test_error(): + with patch("jpterm.show.load_document", AsyncMock(side_effect=ValueError("bad notebook"))): + code, _ = invoke(["show", "bad.ipynb"]) + assert code == 1 + + +def test_sync_signal_follows_application(): + + async def check(): + synced = anyio.Event() + websocket = AsyncMock() + websocket.receive_bytes.side_effect = [ + bytes([YMessageType.SYNC, YSyncMessageType.SYNC_STEP2]), + b"next", + ] + channel = SyncedWebsocket(websocket, "room", synced) + await channel.__anext__() + assert not synced.is_set() + await channel.__anext__() + assert synced.is_set() + + anyio.run(check) diff --git a/tests/test_websockets.py b/tests/test_websockets.py new file mode 100644 index 0000000..0774c1e --- /dev/null +++ b/tests/test_websockets.py @@ -0,0 +1,144 @@ +"""Exercise the remote transports with HTTPX2's native WebSocket client.""" + +from copy import deepcopy +from types import SimpleNamespace + +import anyio +import httpx2 +import pytest +from httpx2.websockets import ASGIWebSocketTransport +from pycrdt import Doc, Text, handle_sync_message +from txl_remote_contents.main import RemoteContents +from txl_remote_kernels.driver import KernelDriver +from txl_remote_terminals.main import RemoteTerminals + + +def client_factory(app): + client_class = httpx2.AsyncClient + + def create(**kwargs): + return client_class(transport=ASGIWebSocketTransport(app), **kwargs) + + return create + + +def test_contents_websocket_sync(monkeypatch): + server_doc = Doc() + server_doc["source"] = Text("Synchronized through HTTPX2") + + async def app(scope, receive, send): + assert scope["path"] == "/api/collaboration/room/test-room" + assert scope["query_string"] == b"sessionId=test-session" + assert b"session-cookie=yes" in dict(scope["headers"])[b"cookie"] + assert (await receive())["type"] == "websocket.connect" + await send({"type": "websocket.accept"}) + while True: + event = await receive() + if event["type"] == "websocket.disconnect": + return + reply = handle_sync_message(event["bytes"][1:], server_doc) + if reply is not None: + await send({"type": "websocket.send", "bytes": reply}) + + monkeypatch.setattr(httpx2, "AsyncClient", client_factory(app)) + + async def check(): + client_doc = Doc() + client_doc["source"] = Text() + synced = anyio.Event() + finished = anyio.Event() + monkeypatch.setattr("txl_remote_contents.main.sleep_forever", finished.wait) + with anyio.fail_after(3): + async with anyio.create_task_group() as tasks: + contents = RemoteContents( + "http://server", {}, httpx2.Cookies({"session-cookie": "yes"}), True, tasks, + ) + tasks.start_soon( + contents.websocket_provider, "test-room", client_doc, "test-session", synced, + ) + await synced.wait() + assert str(client_doc["source"]) == "Synchronized through HTTPX2" + finished.set() + + anyio.run(check) + + +@pytest.mark.parametrize("protocol", [None, "v1.kernel.websocket.jupyter.org"]) +@pytest.mark.parametrize("buffers", [[], [b"binary kernel buffer"]]) +def test_kernel_websocket_roundtrip(monkeypatch, protocol, buffers): + async def app(scope, receive, send): + assert scope["path"] == "/api/kernels/test-kernel/channels" + assert scope["query_string"] == b"session_id=test-session" + assert scope["subprotocols"] == ["v1.kernel.websocket.jupyter.org"] + assert b"session-cookie=yes" in dict(scope["headers"])[b"cookie"] + assert (await receive())["type"] == "websocket.connect" + await send({"type": "websocket.accept", "subprotocol": protocol}) + while True: + event = await receive() + if event["type"] == "websocket.disconnect": + return + payload = {key: event[key] for key in ("text", "bytes") if event.get(key) is not None} + await send({"type": "websocket.send", **payload}) + + monkeypatch.setattr(httpx2, "AsyncClient", client_factory(app)) + + async def check(): + driver = KernelDriver.__new__(KernelDriver) + driver.send_lock = anyio.Lock() + sender, receiver = anyio.create_memory_object_stream(1) + driver.recv_queue = SimpleNamespace(send_nowait=sender.send_nowait) + with anyio.fail_after(3): + async with httpx2.AsyncClient(cookies={"session-cookie": "yes"}) as client: + async with client.websocket( + "ws://server/api/kernels/test-kernel/channels", + params={"session_id": "test-session"}, + subprotocols=["v1.kernel.websocket.jupyter.org"], + ) as driver.websocket: + async with sender, receiver, anyio.create_task_group() as tasks: + tasks.start_soon(driver._recv) + message = { + "header": {"msg_type": "execute_request", "msg_id": "request"}, + "parent_header": {}, "metadata": {}, "content": {"code": "1 + 1"}, + "buffers": buffers, "channel": "shell", + } + await driver.send_message(deepcopy(message), "shell") + assert await receiver.receive() == message + tasks.cancel_scope.cancel() + + anyio.run(check) + + +def test_terminal_websocket_roundtrip(monkeypatch): + async def app(scope, receive, send): + assert scope["path"] == "/terminals/websocket/test-terminal" + assert b"session-cookie=yes" in dict(scope["headers"])[b"cookie"] + assert (await receive())["type"] == "websocket.connect" + await send({"type": "websocket.accept"}) + while True: + event = await receive() + if event["type"] == "websocket.disconnect": + return + await send({"type": "websocket.send", "text": event["text"]}) + + monkeypatch.setattr(httpx2, "AsyncClient", client_factory(app)) + + async def check(): + outgoing, send_queue = anyio.create_memory_object_stream(1) + recv_queue, incoming = anyio.create_memory_object_stream(1) + with anyio.fail_after(3): + async with httpx2.AsyncClient(cookies={"session-cookie": "yes"}) as client: + async with client.websocket("ws://server/terminals/websocket/test-terminal") as ws: + terminal = SimpleNamespace( + websocket=ws, _send_queue=send_queue, _recv_queue=recv_queue, + _done=anyio.Event(), + ) + async with outgoing, send_queue, recv_queue, incoming: + async with anyio.create_task_group() as tasks: + terminal.send_task = tasks.create_task(RemoteTerminals._send(terminal)) + tasks.start_soon(RemoteTerminals._recv, terminal) + message = ["stdin", "terminal input\n"] + await outgoing.send(message) + assert await incoming.receive() == message + tasks.cancel_scope.cancel() + + anyio.run(check) diff --git a/txl/txl/app.py b/txl/txl/app.py index 08eeba3..b8895b1 100644 --- a/txl/txl/app.py +++ b/txl/txl/app.py @@ -1,5 +1,6 @@ import logging from importlib.metadata import entry_points +from shutil import get_terminal_size import anyio from fps import Module @@ -7,8 +8,8 @@ from textual._context import active_app from textual.app import App -logging.getLogger("httpx").setLevel(logging.CRITICAL) -logging.getLogger("httpcore").setLevel(logging.CRITICAL) +logging.getLogger("httpx2").setLevel(logging.CRITICAL) +logging.getLogger("httpcore2").setLevel(logging.CRITICAL) modules = { ep.name: ep.load() for ep in entry_points(group="txl.modules") @@ -18,8 +19,11 @@ class AppModule(Module): - def __init__(self, name: str): + def __init__(self, name: str, cli: bool = False, inline: bool = False): super().__init__(name) + self.cli = cli + self.inline = inline + self.app_ready = anyio.Event() self.app_exited = anyio.Event() for name, module_class in modules.items(): if name not in disabled: @@ -28,9 +32,22 @@ def __init__(self, name: str): async def start(self) -> None: self.done() app = await self.get(App) + self.app = app + app.cli = self.cli active_app.set(app) - await app.run_async() + async def ready(pilot): + self.app_ready.set() + + await app.run_async( + headless=self.cli, + inline=self.inline, + inline_no_clear=self.inline, + size=tuple(get_terminal_size()) if self.cli else None, + auto_pilot=ready, + ) self.app_exited.set() + if not self.cli: + self.exit_app() def run(kwargs) -> None: diff --git a/txl/txl/base.py b/txl/txl/base.py index 48708a3..191a5f4 100644 --- a/txl/txl/base.py +++ b/txl/txl/base.py @@ -24,6 +24,10 @@ def get_bindings(self) -> List[Binding] | None: class Editors(ABC): + @abstractmethod + def create_editor(self, path: str) -> Editor: + ... + @abstractmethod def register_editor_factory(self, editor_factory: Callable, extensions: List[str] = [None]): ... diff --git a/txl/txl/text_input.py b/txl/txl/text_input.py index 60a85ea..e0b7fac 100644 --- a/txl/txl/text_input.py +++ b/txl/txl/text_input.py @@ -17,7 +17,9 @@ def __init__(self, ytext: Text, path=None, language=None): language = None elif language == "ipython": language = "python" - super().__init__(text, language=language, theme="monokai") + super().__init__(text, theme="monokai") + if language in self.available_languages: + self.language = language async def start(self): self.send_change_events, self.receive_change_events = create_memory_object_stream()