├── .gitignore
├── .python-version
├── LICENSE
├── README.md
├── examples
├── dictionary.py
├── dictionary.tcss
├── download_screenshot.py
├── open_link.py
├── serve.py
├── serve_any.py
├── serve_dictionary.py
└── serve_open_link.py
├── pyproject.toml
├── requirements-dev.lock
├── requirements.lock
└── src
└── textual_serve
├── __init__.py
├── _binary_encode.py
├── app_service.py
├── download_manager.py
├── py.typed
├── server.py
├── static
├── css
│ └── xterm.css
├── fonts
│ ├── RobotoMono-Italic-VariableFont_wght.ttf
│ └── RobotoMono-VariableFont_wght.ttf
├── images
│ └── background.png
└── js
│ └── textual.js
└── templates
└── app_index.html
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | .venv/
3 | __pycache__/
4 | *.py[cod]
5 | *$py.class
6 | .DS_Store
7 | # C extensions
8 | *.so
9 |
10 | # Distribution / packaging
11 | .Python
12 | build/
13 | develop-eggs/
14 | dist/
15 | downloads/
16 | eggs/
17 | .eggs/
18 | lib/
19 | lib64/
20 | parts/
21 | sdist/
22 | var/
23 | wheels/
24 | share/python-wheels/
25 | *.egg-info/
26 | .installed.cfg
27 | *.egg
28 | MANIFEST
29 |
30 | # PyInstaller
31 | # Usually these files are written by a python script from a template
32 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
33 | *.manifest
34 | *.spec
35 |
36 | # Installer logs
37 | pip-log.txt
38 | pip-delete-this-directory.txt
39 |
40 | # Unit test / coverage reports
41 | htmlcov/
42 | .tox/
43 | .nox/
44 | .coverage
45 | .coverage.*
46 | .cache
47 | nosetests.xml
48 | coverage.xml
49 | *.cover
50 | *.py,cover
51 | .hypothesis/
52 | .pytest_cache/
53 | cover/
54 |
55 | # Translations
56 | *.mo
57 | *.pot
58 |
59 | # Django stuff:
60 | *.log
61 | local_settings.py
62 | db.sqlite3
63 | db.sqlite3-journal
64 |
65 | # Flask stuff:
66 | instance/
67 | .webassets-cache
68 |
69 | # Scrapy stuff:
70 | .scrapy
71 |
72 | # Sphinx documentation
73 | docs/_build/
74 |
75 | # PyBuilder
76 | .pybuilder/
77 | target/
78 |
79 | # Jupyter Notebook
80 | .ipynb_checkpoints
81 |
82 | # IPython
83 | profile_default/
84 | ipython_config.py
85 |
86 | # pyenv
87 | # For a library or package, you might want to ignore these files since the code is
88 | # intended to run in multiple environments; otherwise, check them in:
89 | # .python-version
90 |
91 | # pipenv
92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
95 | # install all needed dependencies.
96 | #Pipfile.lock
97 |
98 | # poetry
99 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
100 | # This is especially recommended for binary packages to ensure reproducibility, and is more
101 | # commonly ignored for libraries.
102 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
103 | #poetry.lock
104 |
105 | # pdm
106 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
107 | #pdm.lock
108 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
109 | # in version control.
110 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
111 | .pdm.toml
112 | .pdm-python
113 | .pdm-build/
114 |
115 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
116 | __pypackages__/
117 |
118 | # Celery stuff
119 | celerybeat-schedule
120 | celerybeat.pid
121 |
122 | # SageMath parsed files
123 | *.sage.py
124 |
125 | # Environments
126 | .env
127 | .venv
128 | env/
129 | venv/
130 | ENV/
131 | env.bak/
132 | venv.bak/
133 |
134 | # Spyder project settings
135 | .spyderproject
136 | .spyproject
137 |
138 | # Rope project settings
139 | .ropeproject
140 |
141 | # mkdocs documentation
142 | /site
143 |
144 | # mypy
145 | .mypy_cache/
146 | .dmypy.json
147 | dmypy.json
148 |
149 | # Pyre type checker
150 | .pyre/
151 |
152 | # pytype static type analyzer
153 | .pytype/
154 |
155 | # Cython debug symbols
156 | cython_debug/
157 |
158 | # PyCharm
159 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
160 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
161 | # and can be added to the global gitignore or merged into this file. For a more nuclear
162 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
163 | #.idea/
164 |
--------------------------------------------------------------------------------
/.python-version:
--------------------------------------------------------------------------------
1 | 3.11.1
2 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Textualize
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # textual-serve
2 |
3 | Every [Textual](https://github.com/textualize/textual) application is now a web application.
4 |
5 | With 3 lines of code, any Textual app can run in the browser.
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | This is Posting running in the terminal.
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | This is Posting running in the browser .
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | ---
30 |
31 | ## Getting Started
32 |
33 | First, [install (or upgrade) Textual](https://textual.textualize.io/getting_started/#installation).
34 |
35 | Then install `textual-serve` from PyPI:
36 |
37 |
38 | ```
39 | pip install textual-serve
40 | ```
41 |
42 | ## Creating a server
43 |
44 | First import the Server class:
45 |
46 | ```python
47 | from textual_serve.server import Server
48 | ```
49 |
50 | Then create a `Server` instance and pass the command that launches your Textual app:
51 |
52 | ```python
53 | server = Server("python -m textual")
54 | ```
55 |
56 | The command can be anything you would enter in the shell, as long as it results in a Textual app running.
57 |
58 | Finally, call the `serve` method:
59 |
60 | ```python
61 | server.serve()
62 | ```
63 |
64 | You will now be able to click on the link in the terminal to run your app in a browser.
65 |
66 | ### Summary
67 |
68 | Run this code, visit http://localhost:8000
69 |
70 | ```python
71 | from textual_serve.server import Server
72 |
73 | server = Server("python -m textual")
74 | server.serve()
75 | ```
76 |
77 | ## Configuration
78 |
79 | The `Server` class has the following parameters:
80 |
81 | | parameter | description |
82 | | -------------- | ---------------------------------------------------------------------------------- |
83 | | command | A shell command to launch a Textual app. |
84 | | host | The host of the web application (defaults to "localhost"). |
85 | | port | The port for the web application (defaults to 8000). |
86 | | title | The title show in the web app on load, leave as `None` to use the command. |
87 | | public_url | The public URL, if the server is behind a proxy. `None` for the local URL. |
88 | | statics_path | Path to statics folder, relative to server.py. Default uses directory in module. |
89 | | templates_path | Path to templates folder, relative to server.py. Default uses directory in module. |
90 |
91 | The `Server.serve` method accepts a `debug` parameter.
92 | When set to `True`, this will enable [textual devtools](https://textual.textualize.io/guide/devtools/).
93 |
94 | ## How does it work?
95 |
96 | When you visit the app URL, the server launches an instance of your app in a subprocess, and communicates with it via a websocket.
97 |
98 | This means that you can serve multiple Textual apps across all the CPUs on your system.
99 |
100 |
101 | Note that Textual-serve uses a custom protocol to communicate with Textual apps.
102 | It *does not* simply expose a shell in your browser.
103 | There is no way for a malicious user to do anything the app-author didn't intend.
104 |
105 | ## See also
106 |
107 | See also [textual-web](https://github.com/Textualize/textual-web) which serves Textual apps on a public URL.
108 |
109 | You can consider this project to essentially be a self-hosted equivalent of Textual-web.
110 |
--------------------------------------------------------------------------------
/examples/dictionary.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | try:
4 | import httpx
5 | except ImportError:
6 | raise ImportError("Please install httpx with 'pip install httpx' ")
7 |
8 |
9 | from textual import work
10 | from textual.app import App, ComposeResult
11 | from textual.containers import VerticalScroll
12 | from textual.widgets import Input, Markdown
13 |
14 |
15 | class DictionaryApp(App[None]):
16 | """Searches a dictionary API as-you-type."""
17 |
18 | CSS_PATH = "dictionary.tcss"
19 |
20 | def compose(self) -> ComposeResult:
21 | yield Input(placeholder="Search for a word")
22 | with VerticalScroll(id="results-container"):
23 | yield Markdown(id="results")
24 |
25 | # def on_mount(self) -> None:
26 | # """Called when app starts."""
27 | # # Give the input focus, so we can start typing straight away
28 | # self.query_one(Input).focus()
29 |
30 | async def on_input_changed(self, message: Input.Changed) -> None:
31 | """A coroutine to handle a text changed message."""
32 | if message.value:
33 | self.lookup_word(message.value)
34 | else:
35 | # Clear the results
36 | await self.query_one("#results", Markdown).update("")
37 |
38 | @work(exclusive=True)
39 | async def lookup_word(self, word: str) -> None:
40 | """Looks up a word."""
41 | url = f"https://api.dictionaryapi.dev/api/v2/entries/en/{word}"
42 |
43 | async with httpx.AsyncClient() as client:
44 | response = await client.get(url)
45 | try:
46 | results = response.json()
47 | except Exception:
48 | self.query_one("#results", Markdown).update(response.text)
49 | return
50 |
51 | if word == self.query_one(Input).value:
52 | markdown = self.make_word_markdown(results)
53 | self.query_one("#results", Markdown).update(markdown)
54 |
55 | def make_word_markdown(self, results: object) -> str:
56 | """Convert the results in to markdown."""
57 | lines = []
58 | if isinstance(results, dict):
59 | lines.append(f"# {results['title']}")
60 | lines.append(results["message"])
61 | elif isinstance(results, list):
62 | for result in results:
63 | lines.append(f"# {result['word']}")
64 | lines.append("")
65 | for meaning in result.get("meanings", []):
66 | lines.append(f"_{meaning['partOfSpeech']}_")
67 | lines.append("")
68 | for definition in meaning.get("definitions", []):
69 | lines.append(f" - {definition['definition']}")
70 | lines.append("---")
71 |
72 | return "\n".join(lines)
73 |
74 |
75 | if __name__ == "__main__":
76 | app = DictionaryApp()
77 | app.run()
78 |
--------------------------------------------------------------------------------
/examples/dictionary.tcss:
--------------------------------------------------------------------------------
1 | Screen {
2 | background: $panel;
3 | }
4 |
5 | Input {
6 | dock: top;
7 | margin: 1 0;
8 | }
9 |
10 | #results {
11 | width: 100%;
12 | height: auto;
13 | }
14 |
15 | #results-container {
16 | background: $background 50%;
17 | margin: 0 0 1 0;
18 | height: 100%;
19 | overflow: hidden auto;
20 | border: tall $background;
21 | }
22 |
23 | #results-container:focus {
24 | border: tall $accent;
25 | }
26 |
--------------------------------------------------------------------------------
/examples/download_screenshot.py:
--------------------------------------------------------------------------------
1 | import io
2 | from pathlib import Path
3 | from textual import on
4 | from textual.app import App, ComposeResult
5 | from textual.events import DeliveryComplete
6 | from textual.widgets import Button, Input, Label
7 |
8 |
9 | class ScreenshotApp(App[None]):
10 | def compose(self) -> ComposeResult:
11 | yield Button("screenshot: no filename or mime", id="button-1")
12 | yield Button("screenshot: screenshot.svg / open in browser", id="button-2")
13 | yield Button("screenshot: screenshot.svg / download", id="button-3")
14 | yield Button(
15 | "screenshot: screenshot.svg / open in browser / plaintext mime",
16 | id="button-4",
17 | )
18 | yield Label("Deliver custom file:")
19 | yield Input(id="custom-path-input", placeholder="Path to file...")
20 |
21 | @on(Button.Pressed, selector="#button-1")
22 | def on_button_pressed(self) -> None:
23 | screenshot_string = self.export_screenshot()
24 | string_io = io.StringIO(screenshot_string)
25 | self.deliver_text(string_io)
26 |
27 | @on(Button.Pressed, selector="#button-2")
28 | def on_button_pressed_2(self) -> None:
29 | screenshot_string = self.export_screenshot()
30 | string_io = io.StringIO(screenshot_string)
31 | self.deliver_text(
32 | string_io, save_filename="screenshot.svg", open_method="browser"
33 | )
34 |
35 | @on(Button.Pressed, selector="#button-3")
36 | def on_button_pressed_3(self) -> None:
37 | screenshot_string = self.export_screenshot()
38 | string_io = io.StringIO(screenshot_string)
39 | self.deliver_text(
40 | string_io, save_filename="screenshot.svg", open_method="download"
41 | )
42 |
43 | @on(Button.Pressed, selector="#button-4")
44 | def on_button_pressed_4(self) -> None:
45 | screenshot_string = self.export_screenshot()
46 | string_io = io.StringIO(screenshot_string)
47 | self.deliver_text(
48 | string_io,
49 | save_filename="screenshot.svg",
50 | open_method="browser",
51 | mime_type="text/plain",
52 | )
53 |
54 | @on(DeliveryComplete)
55 | def on_delivery_complete(self, event: DeliveryComplete) -> None:
56 | self.notify(title="Download complete", message=event.key)
57 |
58 | @on(Input.Submitted)
59 | def on_input_submitted(self, event: Input.Submitted) -> None:
60 | path = Path(event.value)
61 | if path.exists():
62 | self.deliver_binary(path)
63 | else:
64 | self.notify(
65 | title="Invalid path",
66 | message="The path does not exist.",
67 | severity="error",
68 | )
69 |
70 |
71 | app = ScreenshotApp()
72 | if __name__ == "__main__":
73 | app.run()
74 |
--------------------------------------------------------------------------------
/examples/open_link.py:
--------------------------------------------------------------------------------
1 | from textual import on
2 | from textual.app import App, ComposeResult
3 | from textual.widgets import Button
4 |
5 |
6 | class OpenLink(App[None]):
7 | """Demonstrates opening a URL in the same tab or a new tab."""
8 |
9 | def compose(self) -> ComposeResult:
10 | yield Button("Visit the Textual docs", id="open-link-same-tab")
11 | yield Button("Visit the Textual docs in a new tab", id="open-link-new-tab")
12 |
13 | @on(Button.Pressed)
14 | def open_link(self, event: Button.Pressed) -> None:
15 | """Open the URL in the same tab or a new tab depending on which button was pressed."""
16 | self.open_url(
17 | "https://textual.textualize.io",
18 | new_tab=event.button.id == "open-link-new-tab",
19 | )
20 |
21 |
22 | app = OpenLink()
23 | if __name__ == "__main__":
24 | app.run()
25 |
--------------------------------------------------------------------------------
/examples/serve.py:
--------------------------------------------------------------------------------
1 | from textual_serve.server import Server
2 |
3 | server = Server("python -m textual")
4 | server.serve()
5 |
--------------------------------------------------------------------------------
/examples/serve_any.py:
--------------------------------------------------------------------------------
1 | import sys
2 | from textual_serve.server import Server
3 |
4 | if __name__ == "__main__":
5 | server = Server(sys.argv[1])
6 | server.serve(debug=True)
7 |
--------------------------------------------------------------------------------
/examples/serve_dictionary.py:
--------------------------------------------------------------------------------
1 | from textual_serve.server import Server
2 |
3 | server = Server("python dictionary.py")
4 | server.serve(debug=False)
5 |
--------------------------------------------------------------------------------
/examples/serve_open_link.py:
--------------------------------------------------------------------------------
1 | from textual_serve.server import Server
2 |
3 | server = Server("python open_link.py")
4 | server.serve(debug=False)
5 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "textual-serve"
3 | version = "1.1.2"
4 | description = "Turn your Textual TUIs in to web applications"
5 | authors = [
6 | { name = "Will McGugan", email = "will@textualize.io" }
7 | ]
8 | dependencies = [
9 | "aiohttp>=3.9.5",
10 | "aiohttp-jinja2>=1.6",
11 | "jinja2>=3.1.4",
12 | "rich",
13 | "textual>=0.66.0",
14 | ]
15 | readme = "README.md"
16 | requires-python = ">= 3.9"
17 | license = "MIT"
18 |
19 | [project.urls]
20 | Homepage = "https://github.com/Textualize/textual-serve"
21 |
22 |
23 | [build-system]
24 | requires = ["hatchling>=1.26.1"]
25 | build-backend = "hatchling.build"
26 |
27 | [tool.hatch.metadata]
28 | allow-direct-references = true
29 |
30 | [tool.rye]
31 | managed = true
32 | dev-dependencies = [
33 | "httpx",
34 | # required to run the dictionary example
35 | "textual-dev>=1.5.1",
36 | ]
37 |
38 | [tool.hatch.build.targets.wheel]
39 | packages = ["src/textual_serve"]
40 |
--------------------------------------------------------------------------------
/requirements-dev.lock:
--------------------------------------------------------------------------------
1 | # generated by rye
2 | # use `rye lock` or `rye sync` to update this lockfile
3 | #
4 | # last locked with the following flags:
5 | # pre: false
6 | # features: []
7 | # all-features: false
8 | # with-sources: false
9 | # generate-hashes: false
10 |
11 | -e file:.
12 | aiohttp==3.9.5
13 | # via aiohttp-jinja2
14 | # via textual-dev
15 | # via textual-serve
16 | aiohttp-jinja2==1.6
17 | # via textual-serve
18 | aiosignal==1.3.1
19 | # via aiohttp
20 | anyio==4.4.0
21 | # via httpx
22 | attrs==23.2.0
23 | # via aiohttp
24 | certifi==2024.7.4
25 | # via httpcore
26 | # via httpx
27 | click==8.1.7
28 | # via textual-dev
29 | frozenlist==1.4.1
30 | # via aiohttp
31 | # via aiosignal
32 | h11==0.14.0
33 | # via httpcore
34 | httpcore==1.0.5
35 | # via httpx
36 | httpx==0.27.0
37 | idna==3.7
38 | # via anyio
39 | # via httpx
40 | # via yarl
41 | jinja2==3.1.4
42 | # via aiohttp-jinja2
43 | # via textual-serve
44 | linkify-it-py==2.0.3
45 | # via markdown-it-py
46 | markdown-it-py==3.0.0
47 | # via mdit-py-plugins
48 | # via rich
49 | # via textual
50 | markupsafe==2.1.5
51 | # via jinja2
52 | mdit-py-plugins==0.4.1
53 | # via markdown-it-py
54 | mdurl==0.1.2
55 | # via markdown-it-py
56 | msgpack==1.0.8
57 | # via textual-dev
58 | multidict==6.0.5
59 | # via aiohttp
60 | # via yarl
61 | pygments==2.18.0
62 | # via rich
63 | rich==13.7.1
64 | # via textual
65 | # via textual-serve
66 | sniffio==1.3.1
67 | # via anyio
68 | # via httpx
69 | textual==0.78.0
70 | # via textual-dev
71 | # via textual-serve
72 | textual-dev==1.5.1
73 | typing-extensions==4.12.2
74 | # via textual
75 | # via textual-dev
76 | uc-micro-py==1.0.3
77 | # via linkify-it-py
78 | yarl==1.9.4
79 | # via aiohttp
80 |
--------------------------------------------------------------------------------
/requirements.lock:
--------------------------------------------------------------------------------
1 | # generated by rye
2 | # use `rye lock` or `rye sync` to update this lockfile
3 | #
4 | # last locked with the following flags:
5 | # pre: false
6 | # features: []
7 | # all-features: false
8 | # with-sources: false
9 | # generate-hashes: false
10 |
11 | -e file:.
12 | aiohttp==3.9.5
13 | # via aiohttp-jinja2
14 | # via textual-serve
15 | aiohttp-jinja2==1.6
16 | # via textual-serve
17 | aiosignal==1.3.1
18 | # via aiohttp
19 | attrs==23.2.0
20 | # via aiohttp
21 | frozenlist==1.4.1
22 | # via aiohttp
23 | # via aiosignal
24 | idna==3.7
25 | # via yarl
26 | jinja2==3.1.4
27 | # via aiohttp-jinja2
28 | # via textual-serve
29 | linkify-it-py==2.0.3
30 | # via markdown-it-py
31 | markdown-it-py==3.0.0
32 | # via mdit-py-plugins
33 | # via rich
34 | # via textual
35 | markupsafe==2.1.5
36 | # via jinja2
37 | mdit-py-plugins==0.4.1
38 | # via markdown-it-py
39 | mdurl==0.1.2
40 | # via markdown-it-py
41 | multidict==6.0.5
42 | # via aiohttp
43 | # via yarl
44 | pygments==2.18.0
45 | # via rich
46 | rich==13.7.1
47 | # via textual
48 | # via textual-serve
49 | textual==0.78.0
50 | # via textual-serve
51 | typing-extensions==4.12.2
52 | # via textual
53 | uc-micro-py==1.0.3
54 | # via linkify-it-py
55 | yarl==1.9.4
56 | # via aiohttp
57 |
--------------------------------------------------------------------------------
/src/textual_serve/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Textualize/textual-serve/06a8bd45d6b00a6ff8dea66252bae7fe6e7e2a2b/src/textual_serve/__init__.py
--------------------------------------------------------------------------------
/src/textual_serve/_binary_encode.py:
--------------------------------------------------------------------------------
1 | """
2 | An encoding / decoding format suitable for serializing data structures to binary.
3 |
4 | This is based on https://en.wikipedia.org/wiki/Bencode with some extensions.
5 |
6 | The following data types may be encoded:
7 |
8 | - None
9 | - int
10 | - bool
11 | - bytes
12 | - str
13 | - list
14 | - tuple
15 | - dict
16 |
17 | """
18 |
19 | from __future__ import annotations
20 |
21 | from typing import Any, Callable
22 |
23 |
24 | class DecodeError(Exception):
25 | """A problem decoding data."""
26 |
27 |
28 | def dump(data: object) -> bytes:
29 | """Encodes a data structure in to bytes.
30 |
31 | Args:
32 | data: Data structure
33 |
34 | Returns:
35 | A byte string encoding the data.
36 | """
37 |
38 | def encode_none(_datum: None) -> bytes:
39 | """
40 | Encodes a None value.
41 |
42 | Args:
43 | datum: Always None.
44 |
45 | Returns:
46 | None encoded.
47 | """
48 | return b"N"
49 |
50 | def encode_bool(datum: bool) -> bytes:
51 | """
52 | Encode a boolean value.
53 |
54 | Args:
55 | datum: The boolean value to encode.
56 |
57 | Returns:
58 | The encoded bytes.
59 | """
60 | return b"T" if datum else b"F"
61 |
62 | def encode_int(datum: int) -> bytes:
63 | """
64 | Encode an integer value.
65 |
66 | Args:
67 | datum: The integer value to encode.
68 |
69 | Returns:
70 | The encoded bytes.
71 | """
72 | return b"i%ie" % datum
73 |
74 | def encode_bytes(datum: bytes) -> bytes:
75 | """
76 | Encode a bytes value.
77 |
78 | Args:
79 | datum: The bytes value to encode.
80 |
81 | Returns:
82 | The encoded bytes.
83 | """
84 | return b"%i:%s" % (len(datum), datum)
85 |
86 | def encode_string(datum: str) -> bytes:
87 | """
88 | Encode a string value.
89 |
90 | Args:
91 | datum: The string value to encode.
92 |
93 | Returns:
94 | The encoded bytes.
95 | """
96 | encoded_data = datum.encode("utf-8")
97 | return b"s%i:%s" % (len(encoded_data), encoded_data)
98 |
99 | def encode_list(datum: list) -> bytes:
100 | """
101 | Encode a list value.
102 |
103 | Args:
104 | datum: The list value to encode.
105 |
106 | Returns:
107 | The encoded bytes.
108 | """
109 | return b"l%se" % b"".join(encode(element) for element in datum)
110 |
111 | def encode_tuple(datum: tuple) -> bytes:
112 | """
113 | Encode a tuple value.
114 |
115 | Args:
116 | datum: The tuple value to encode.
117 |
118 | Returns:
119 | The encoded bytes.
120 | """
121 | return b"t%se" % b"".join(encode(element) for element in datum)
122 |
123 | def encode_dict(datum: dict) -> bytes:
124 | """
125 | Encode a dictionary value.
126 |
127 | Args:
128 | datum: The dictionary value to encode.
129 |
130 | Returns:
131 | The encoded bytes.
132 | """
133 | return b"d%se" % b"".join(
134 | b"%s%s" % (encode(key), encode(value)) for key, value in datum.items()
135 | )
136 |
137 | ENCODERS: dict[type, Callable[[Any], Any]] = {
138 | type(None): encode_none,
139 | bool: encode_bool,
140 | int: encode_int,
141 | bytes: encode_bytes,
142 | str: encode_string,
143 | list: encode_list,
144 | tuple: encode_tuple,
145 | dict: encode_dict,
146 | }
147 |
148 | def encode(datum: object) -> bytes:
149 | """Recursively encode data.
150 |
151 | Args:
152 | datum: Data suitable for encoding.
153 |
154 | Raises:
155 | TypeError: If `datum` is not one of the supported types.
156 |
157 | Returns:
158 | Encoded data bytes.
159 | """
160 | try:
161 | decoder = ENCODERS[type(datum)]
162 | except KeyError:
163 | raise TypeError("Can't encode {datum!r}") from None
164 | return decoder(datum)
165 |
166 | return encode(data)
167 |
168 |
169 | def load(encoded: bytes) -> object:
170 | """Load an encoded data structure from bytes.
171 |
172 | Args:
173 | encoded: Encoded data in bytes.
174 |
175 | Raises:
176 | DecodeError: If an error was encountered decoding the string.
177 |
178 | Returns:
179 | Decoded data.
180 | """
181 | if not isinstance(encoded, bytes):
182 | raise TypeError("must be bytes")
183 | max_position = len(encoded)
184 | position = 0
185 |
186 | def get_byte() -> bytes:
187 | """Get an encoded byte and advance position.
188 |
189 | Raises:
190 | DecodeError: If the end of the data was reached
191 |
192 | Returns:
193 | A bytes object with a single byte.
194 | """
195 | nonlocal position
196 | if position >= max_position:
197 | raise DecodeError("More data expected")
198 | character = encoded[position : position + 1]
199 | position += 1
200 | return character
201 |
202 | def peek_byte() -> bytes:
203 | """Get the byte at the current position, but don't advance position.
204 |
205 | Returns:
206 | A bytes object with a single byte.
207 | """
208 | return encoded[position : position + 1]
209 |
210 | def get_bytes(size: int) -> bytes:
211 | """Get a number of bytes of encode data.
212 |
213 | Args:
214 | size: Number of bytes to retrieve.
215 |
216 | Raises:
217 | DecodeError: If there aren't enough bytes.
218 |
219 | Returns:
220 | A bytes object.
221 | """
222 | nonlocal position
223 | bytes_data = encoded[position : position + size]
224 | if len(bytes_data) != size:
225 | raise DecodeError(b"Missing bytes in {bytes_data!r}")
226 | position += size
227 | return bytes_data
228 |
229 | def decode_int() -> int:
230 | """Decode an int from the encoded data.
231 |
232 | Returns:
233 | An integer.
234 | """
235 | int_bytes = b""
236 | while (byte := get_byte()) != b"e":
237 | int_bytes += byte
238 | return int(int_bytes)
239 |
240 | def decode_bytes(size_bytes: bytes) -> bytes:
241 | """Decode a bytes string from the encoded data.
242 |
243 | Returns:
244 | A bytes object.
245 | """
246 | while (byte := get_byte()) != b":":
247 | size_bytes += byte
248 | bytes_string = get_bytes(int(size_bytes))
249 | return bytes_string
250 |
251 | def decode_string() -> str:
252 | """Decode a (utf-8 encoded) string from the encoded data.
253 |
254 | Returns:
255 | A string.
256 | """
257 | size_bytes = b""
258 | while (byte := get_byte()) != b":":
259 | size_bytes += byte
260 | bytes_string = get_bytes(int(size_bytes))
261 | decoded_string = bytes_string.decode("utf-8", errors="replace")
262 | return decoded_string
263 |
264 | def decode_list() -> list[object]:
265 | """Decode a list.
266 |
267 | Returns:
268 | A list of data.
269 | """
270 | elements: list[object] = []
271 | add_element = elements.append
272 | while peek_byte() != b"e":
273 | add_element(decode())
274 | get_byte()
275 | return elements
276 |
277 | def decode_tuple() -> tuple[object, ...]:
278 | """Decode a tuple.
279 |
280 | Returns:
281 | A tuple of decoded data.
282 | """
283 | elements: list[object] = []
284 | add_element = elements.append
285 | while peek_byte() != b"e":
286 | add_element(decode())
287 | get_byte()
288 | return tuple(elements)
289 |
290 | def decode_dict() -> dict[object, object]:
291 | """Decode a dict.
292 |
293 | Returns:
294 | A dict of decoded data.
295 | """
296 | elements: dict[object, object] = {}
297 | add_element = elements.__setitem__
298 | while peek_byte() != b"e":
299 | add_element(decode(), decode())
300 | get_byte()
301 | return elements
302 |
303 | DECODERS = {
304 | b"i": decode_int,
305 | b"s": decode_string,
306 | b"l": decode_list,
307 | b"t": decode_tuple,
308 | b"d": decode_dict,
309 | b"T": lambda: True,
310 | b"F": lambda: False,
311 | b"N": lambda: None,
312 | }
313 |
314 | def decode() -> object:
315 | """Recursively decode data.
316 |
317 | Returns:
318 | Decoded data.
319 | """
320 | decoder = DECODERS.get(initial := get_byte(), None)
321 | if decoder is None:
322 | return decode_bytes(initial)
323 | return decoder()
324 |
325 | return decode()
326 |
--------------------------------------------------------------------------------
/src/textual_serve/app_service.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | from pathlib import Path
3 |
4 | import asyncio
5 | import io
6 | import json
7 | import os
8 | from typing import Awaitable, Callable, Literal
9 | from asyncio.subprocess import Process
10 | import logging
11 |
12 | from importlib.metadata import version
13 | import uuid
14 |
15 | from textual_serve.download_manager import DownloadManager
16 | from textual_serve._binary_encode import load as binary_load
17 |
18 | log = logging.getLogger("textual-serve")
19 |
20 |
21 | class AppService:
22 | """Creates and manages a single Textual app subprocess.
23 |
24 | When a user connects to the websocket in their browser, a new AppService
25 | instance is created to manage the corresponding Textual app process.
26 | """
27 |
28 | def __init__(
29 | self,
30 | command: str,
31 | *,
32 | write_bytes: Callable[[bytes], Awaitable[None]],
33 | write_str: Callable[[str], Awaitable[None]],
34 | close: Callable[[], Awaitable[None]],
35 | download_manager: DownloadManager,
36 | debug: bool = False,
37 | ) -> None:
38 | self.app_service_id: str = uuid.uuid4().hex
39 | """The unique ID of this running app service."""
40 | self.command = command
41 | """The command to launch the Textual app subprocess."""
42 | self.remote_write_bytes = write_bytes
43 | """Write bytes to the client browser websocket."""
44 | self.remote_write_str = write_str
45 | """Write string to the client browser websocket."""
46 | self.remote_close = close
47 | """Close the client browser websocket."""
48 | self.debug = debug
49 | """Enable/disable debug mode."""
50 |
51 | self._process: Process | None = None
52 | self._task: asyncio.Task[None] | None = None
53 | self._stdin: asyncio.StreamWriter | None = None
54 | self._exit_event = asyncio.Event()
55 | self._download_manager = download_manager
56 |
57 | @property
58 | def stdin(self) -> asyncio.StreamWriter:
59 | """The processes standard input stream."""
60 | assert self._stdin is not None
61 | return self._stdin
62 |
63 | def _build_environment(self, width: int = 80, height: int = 24) -> dict[str, str]:
64 | """Build an environment dict for the App subprocess.
65 |
66 | Args:
67 | width: Initial width.
68 | height: Initial height.
69 |
70 | Returns:
71 | A environment dict.
72 | """
73 | environment = dict(os.environ.copy())
74 | environment["TEXTUAL_DRIVER"] = "textual.drivers.web_driver:WebDriver"
75 | environment["TEXTUAL_FPS"] = "60"
76 | environment["TEXTUAL_COLOR_SYSTEM"] = "truecolor"
77 | environment["TERM_PROGRAM"] = "textual"
78 | environment["TERM_PROGRAM_VERSION"] = version("textual-serve")
79 | environment["COLUMNS"] = str(width)
80 | environment["ROWS"] = str(height)
81 | if self.debug:
82 | environment["TEXTUAL"] = "debug,devtools"
83 | environment["TEXTUAL_LOG"] = "textual.log"
84 | return environment
85 |
86 | async def _open_app_process(self, width: int = 80, height: int = 24) -> Process:
87 | """Open a process to run the app.
88 |
89 | Args:
90 | width: Width of the terminal.
91 | height: height of the terminal.
92 | """
93 | environment = self._build_environment(width=width, height=height)
94 | self._process = process = await asyncio.create_subprocess_shell(
95 | self.command,
96 | stdin=asyncio.subprocess.PIPE,
97 | stdout=asyncio.subprocess.PIPE,
98 | stderr=asyncio.subprocess.PIPE,
99 | env=environment,
100 | )
101 | assert process.stdin is not None
102 | self._stdin = process.stdin
103 |
104 | return process
105 |
106 | @classmethod
107 | def encode_packet(cls, packet_type: Literal[b"D", b"M"], payload: bytes) -> bytes:
108 | """Encode a packet.
109 |
110 | Args:
111 | packet_type: The packet type (b"D" for data or b"M" for meta)
112 | payload: The payload.
113 |
114 | Returns:
115 | Data as bytes.
116 | """
117 | return b"%s%s%s" % (packet_type, len(payload).to_bytes(4, "big"), payload)
118 |
119 | async def send_bytes(self, data: bytes) -> bool:
120 | """Send bytes to process.
121 |
122 | Args:
123 | data: Data to send.
124 |
125 | Returns:
126 | True if the data was sent, otherwise False.
127 | """
128 | stdin = self.stdin
129 | try:
130 | stdin.write(self.encode_packet(b"D", data))
131 | except RuntimeError:
132 | return False
133 | try:
134 | await stdin.drain()
135 | except Exception:
136 | return False
137 | return True
138 |
139 | async def send_meta(self, data: dict[str, str | None | int | bool]) -> bool:
140 | """Send meta information to process.
141 |
142 | Args:
143 | data: Meta dict to send.
144 |
145 | Returns:
146 | True if the data was sent, otherwise False.
147 | """
148 | stdin = self.stdin
149 | data_bytes = json.dumps(data).encode("utf-8")
150 | try:
151 | stdin.write(self.encode_packet(b"M", data_bytes))
152 | except RuntimeError:
153 | return False
154 | try:
155 | await stdin.drain()
156 | except Exception:
157 | return False
158 | return True
159 |
160 | async def set_terminal_size(self, width: int, height: int) -> None:
161 | """Tell the process about the new terminal size.
162 |
163 | Args:
164 | width: Width of terminal in cells.
165 | height: Height of terminal in cells.
166 | """
167 | await self.send_meta(
168 | {
169 | "type": "resize",
170 | "width": width,
171 | "height": height,
172 | }
173 | )
174 |
175 | async def blur(self) -> None:
176 | """Send an (app) blur to the process."""
177 | await self.send_meta({"type": "blur"})
178 |
179 | async def focus(self) -> None:
180 | """Send an (app) focus to the process."""
181 | await self.send_meta({"type": "focus"})
182 |
183 | async def start(self, width: int, height: int) -> None:
184 | await self._open_app_process(width, height)
185 | self._task = asyncio.create_task(self.run())
186 |
187 | async def stop(self) -> None:
188 | """Stop the process and wait for it to complete."""
189 | if self._task is not None:
190 | await self._download_manager.cancel_app_downloads(
191 | app_service_id=self.app_service_id
192 | )
193 |
194 | await self.send_meta({"type": "quit"})
195 | await self._task
196 | self._task = None
197 |
198 | async def run(self) -> None:
199 | """Run the Textual app process.
200 |
201 | !!! note
202 |
203 | Do not call this manually, use `start`.
204 |
205 | """
206 | META = b"M"
207 | DATA = b"D"
208 | PACKED = b"P"
209 |
210 | assert self._process is not None
211 | process = self._process
212 |
213 | stdout = process.stdout
214 | stderr = process.stderr
215 | assert stdout is not None
216 | assert stderr is not None
217 |
218 | stderr_data = io.BytesIO()
219 |
220 | async def read_stderr() -> None:
221 | """Task to read stderr."""
222 | try:
223 | while True:
224 | data = await stderr.read(1024 * 4)
225 | if not data:
226 | break
227 | stderr_data.write(data)
228 | except asyncio.CancelledError:
229 | pass
230 |
231 | stderr_task = asyncio.create_task(read_stderr())
232 |
233 | try:
234 | ready = False
235 | # Wait for prelude text, so we know it is a Textual app
236 | for _ in range(10):
237 | if not (line := await stdout.readline()):
238 | break
239 | if line == b"__GANGLION__\n":
240 | ready = True
241 | break
242 |
243 | if not ready:
244 | log.error("Application failed to start")
245 | if error_text := stderr_data.getvalue():
246 | import sys
247 |
248 | sys.stdout.write(error_text.decode("utf-8", "replace"))
249 |
250 | readexactly = stdout.readexactly
251 | int_from_bytes = int.from_bytes
252 | while True:
253 | type_bytes = await readexactly(1)
254 | size_bytes = await readexactly(4)
255 | size = int_from_bytes(size_bytes, "big")
256 | payload = await readexactly(size)
257 | if type_bytes == DATA:
258 | await self.on_data(payload)
259 | elif type_bytes == META:
260 | await self.on_meta(payload)
261 | elif type_bytes == PACKED:
262 | await self.on_packed(payload)
263 |
264 | except asyncio.IncompleteReadError:
265 | pass
266 | except ConnectionResetError:
267 | pass
268 | except asyncio.CancelledError:
269 | pass
270 |
271 | finally:
272 | stderr_task.cancel()
273 | await stderr_task
274 |
275 | if error_text := stderr_data.getvalue():
276 | import sys
277 |
278 | sys.stdout.write(error_text.decode("utf-8", "replace"))
279 |
280 | async def on_data(self, payload: bytes) -> None:
281 | """Called when there is data.
282 |
283 | Args:
284 | payload: Data received from process.
285 | """
286 | await self.remote_write_bytes(payload)
287 |
288 | async def on_meta(self, data: bytes) -> None:
289 | """Called when there is a meta packet sent from the running app process.
290 |
291 | Args:
292 | data: Encoded meta data.
293 | """
294 | meta_data: dict[str, object] = json.loads(data)
295 | meta_type = meta_data["type"]
296 |
297 | if meta_type == "exit":
298 | await self.remote_close()
299 | elif meta_type == "open_url":
300 | payload = json.dumps(
301 | [
302 | "open_url",
303 | {
304 | "url": meta_data["url"],
305 | "new_tab": meta_data["new_tab"],
306 | },
307 | ]
308 | )
309 | await self.remote_write_str(payload)
310 | elif meta_type == "deliver_file_start":
311 | log.debug("deliver_file_start, %s", meta_data)
312 | try:
313 | # Record this delivery key as available for download.
314 | delivery_key = str(meta_data["key"])
315 | await self._download_manager.create_download(
316 | app_service=self,
317 | delivery_key=delivery_key,
318 | file_name=Path(meta_data["path"]).name,
319 | open_method=meta_data["open_method"],
320 | mime_type=meta_data["mime_type"],
321 | encoding=meta_data["encoding"],
322 | name=meta_data.get("name", None),
323 | )
324 | except KeyError:
325 | log.error("Missing key in `deliver_file_start` meta packet")
326 | return
327 | else:
328 | # Tell the browser front-end about the new delivery key,
329 | # so that it may hit the "/download/{key}" endpoint
330 | # to start the download.
331 | json_string = json.dumps(["deliver_file_start", delivery_key])
332 | await self.remote_write_str(json_string)
333 | else:
334 | log.warning(
335 | f"Unknown meta type: {meta_type!r}. You may need to update `textual-serve`."
336 | )
337 |
338 | async def on_packed(self, payload: bytes) -> None:
339 | """Called when there is a packed packet sent from the running app process.
340 |
341 | Args:
342 | payload: Encoded packed data.
343 | """
344 | unpacked = binary_load(payload)
345 | if unpacked[0] == "deliver_chunk":
346 | # If we receive a chunk, hand it to the download manager to
347 | # handle distribution to the browser.
348 | _, delivery_key, chunk = unpacked
349 | await self._download_manager.chunk_received(delivery_key, chunk)
350 |
--------------------------------------------------------------------------------
/src/textual_serve/download_manager.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import asyncio
4 | from dataclasses import dataclass, field
5 | import logging
6 | from typing import AsyncGenerator, TYPE_CHECKING
7 |
8 | if TYPE_CHECKING:
9 | from textual_serve.app_service import AppService
10 |
11 | log = logging.getLogger("textual-serve")
12 |
13 | DOWNLOAD_TIMEOUT = 4
14 | DOWNLOAD_CHUNK_SIZE = 1024 * 64 # 64 KB
15 |
16 |
17 | @dataclass
18 | class Download:
19 | app_service: "AppService"
20 | """The app service that the download is associated with."""
21 |
22 | delivery_key: str
23 | """Key which identifies the download."""
24 |
25 | file_name: str
26 | """The name of the file to download. This will be used to set
27 | the Content-Disposition filename."""
28 |
29 | open_method: str
30 | """The method to open the file with. "browser" or "download"."""
31 |
32 | mime_type: str
33 | """The mime type of the content."""
34 |
35 | encoding: str | None = None
36 | """The encoding of the content.
37 | Will be None if the content is binary.
38 | """
39 |
40 | name: str | None = None
41 | """Optional name set bt the client."""
42 |
43 | incoming_chunks: asyncio.Queue[bytes | None] = field(default_factory=asyncio.Queue)
44 | """A queue of incoming chunks for the download.
45 | Chunks are sent from the app service to the download handler
46 | via this queue."""
47 |
48 |
49 | class DownloadManager:
50 | """Class which manages downloads for the server.
51 |
52 | Serves as the link between the web server and app processes during downloads.
53 |
54 | A single server has a single download manager, which manages all downloads for all
55 | running app processes.
56 | """
57 |
58 | def __init__(self) -> None:
59 | self._active_downloads: dict[str, Download] = {}
60 | """A dictionary of active downloads.
61 |
62 | When a delivery key is received in a meta packet, it is added to this set.
63 | When the user hits the "/download/{key}" endpoint, we ensure the key is in
64 | this set and start the download by requesting chunks from the app process.
65 |
66 | When the download is complete, the app process sends a "deliver_file_end"
67 | meta packet, and we remove the key from this set.
68 | """
69 |
70 | async def create_download(
71 | self,
72 | *,
73 | app_service: "AppService",
74 | delivery_key: str,
75 | file_name: str,
76 | open_method: str,
77 | mime_type: str,
78 | encoding: str | None = None,
79 | name: str | None = None,
80 | ) -> None:
81 | """Prepare for a new download.
82 |
83 | Args:
84 | app_service: The app service to start the download for.
85 | delivery_key: The delivery key to start the download for.
86 | file_name: The name of the file to download.
87 | open_method: The method to open the file with.
88 | mime_type: The mime type of the content.
89 | encoding: The encoding of the content or None if the content is binary.
90 | """
91 | self._active_downloads[delivery_key] = Download(
92 | app_service,
93 | delivery_key,
94 | file_name,
95 | open_method,
96 | mime_type,
97 | encoding,
98 | name=name,
99 | )
100 |
101 | async def download(self, delivery_key: str) -> AsyncGenerator[bytes, None]:
102 | """Download a file from the given app service.
103 |
104 | Args:
105 | delivery_key: The delivery key to download.
106 | """
107 |
108 | app_service = await self._get_app_service(delivery_key)
109 | download = self._active_downloads[delivery_key]
110 | incoming_chunks = download.incoming_chunks
111 |
112 | while True:
113 | # Request a chunk from the app service.
114 | send_result = await app_service.send_meta(
115 | {
116 | "type": "deliver_chunk_request",
117 | "key": delivery_key,
118 | "size": DOWNLOAD_CHUNK_SIZE,
119 | "name": download.name,
120 | }
121 | )
122 |
123 | if not send_result:
124 | log.warning(
125 | "Download {delivery_key!r} failed to request chunk from app service"
126 | )
127 | del self._active_downloads[delivery_key]
128 | break
129 |
130 | try:
131 | chunk = await asyncio.wait_for(incoming_chunks.get(), DOWNLOAD_TIMEOUT)
132 | except asyncio.TimeoutError:
133 | log.warning(
134 | "Download %r failed to receive chunk from app service within %r seconds",
135 | delivery_key,
136 | DOWNLOAD_TIMEOUT,
137 | )
138 | chunk = None
139 |
140 | if not chunk:
141 | # Empty chunk - the app process has finished sending the file
142 | # or the download has been cancelled.
143 | incoming_chunks.task_done()
144 | del self._active_downloads[delivery_key]
145 | break
146 | else:
147 | incoming_chunks.task_done()
148 | yield chunk
149 |
150 | async def chunk_received(self, delivery_key: str, chunk: bytes | str) -> None:
151 | """Handle a chunk received from the app service for a download.
152 |
153 | Args:
154 | delivery_key: The delivery key that the chunk was received for.
155 | chunk: The chunk that was received.
156 | """
157 |
158 | download = self._active_downloads.get(delivery_key)
159 | if not download:
160 | # The download may have been cancelled - e.g. the websocket
161 | # was closed before the download could complete.
162 | log.debug("Chunk received for cancelled download %r", delivery_key)
163 | return
164 |
165 | if isinstance(chunk, str):
166 | chunk = chunk.encode(download.encoding or "utf-8")
167 | await download.incoming_chunks.put(chunk)
168 |
169 | async def _get_app_service(self, delivery_key: str) -> "AppService":
170 | """Get the app service that the given delivery key is linked to.
171 |
172 | Args:
173 | delivery_key: The delivery key to get the app service for.
174 | """
175 | for key in self._active_downloads.keys():
176 | if key == delivery_key:
177 | return self._active_downloads[key].app_service
178 | else:
179 | raise ValueError(f"No active download for delivery key {delivery_key!r}")
180 |
181 | async def get_download_metadata(self, delivery_key: str) -> Download:
182 | """Get the metadata for a download.
183 |
184 | Args:
185 | delivery_key: The delivery key to get the metadata for.
186 | """
187 | return self._active_downloads[delivery_key]
188 |
189 | async def cancel_app_downloads(self, app_service_id: str) -> None:
190 | """Cancel all downloads for the given app service.
191 |
192 | Args:
193 | app_service_id: The app service ID to cancel downloads for.
194 | """
195 | for download in self._active_downloads.values():
196 | if download.app_service.app_service_id == app_service_id:
197 | await download.incoming_chunks.put(None)
198 |
--------------------------------------------------------------------------------
/src/textual_serve/py.typed:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Textualize/textual-serve/06a8bd45d6b00a6ff8dea66252bae7fe6e7e2a2b/src/textual_serve/py.typed
--------------------------------------------------------------------------------
/src/textual_serve/server.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import asyncio
4 |
5 | import logging
6 | import os
7 | from pathlib import Path
8 | import signal
9 | import sys
10 |
11 | from typing import Any
12 |
13 | import aiohttp_jinja2
14 | from aiohttp import web
15 | from aiohttp import WSMsgType
16 | from aiohttp.web_runner import GracefulExit
17 | import jinja2
18 |
19 | from importlib.metadata import version
20 |
21 | from rich.console import Console
22 | from rich.logging import RichHandler
23 | from rich.highlighter import RegexHighlighter
24 |
25 | from textual_serve.download_manager import DownloadManager
26 |
27 | from .app_service import AppService
28 |
29 | log = logging.getLogger("textual-serve")
30 |
31 | LOGO = r"""[bold magenta]___ ____ _ _ ___ _ _ ____ _ ____ ____ ____ _ _ ____
32 | | |___ \/ | | | |__| | __ [__ |___ |__/ | | |___
33 | | |___ _/\_ | |__| | | |___ ___] |___ | \ \/ |___ [not bold]VVVVV
34 | """.replace("VVVVV", f"v{version('textual-serve')}")
35 |
36 |
37 | WINDOWS = sys.platform == "WINDOWS"
38 |
39 |
40 | class LogHighlighter(RegexHighlighter):
41 | base_style = "repr."
42 | highlights = [
43 | r"(?P(?\[.*?\])",
45 | r"(?b?'''.*?(? int:
50 | """Convert to an integer, or return a default if that's not possible.
51 |
52 | Args:
53 | number: A string possibly containing a decimal.
54 | default: Default value if value can't be decoded.
55 |
56 | Returns:
57 | Integer.
58 | """
59 | try:
60 | return int(value)
61 | except ValueError:
62 | return default
63 |
64 |
65 | class Server:
66 | """Serve a Textual app."""
67 |
68 | def __init__(
69 | self,
70 | command: str,
71 | host: str = "localhost",
72 | port: int = 8000,
73 | title: str | None = None,
74 | public_url: str | None = None,
75 | statics_path: str | os.PathLike = "./static",
76 | templates_path: str | os.PathLike = "./templates",
77 | ):
78 | """
79 |
80 | Args:
81 | app_factory: A callable that returns a new App instance.
82 | host: Host of web application.
83 | port: Port for server.
84 | statics_path: Path to statics folder. May be absolute or relative to server.py.
85 | templates_path" Path to templates folder. May be absolute or relative to server.py.
86 | """
87 | self.command = command
88 | self.host = host
89 | self.port = port
90 | self.title = title or command
91 | self.debug = False
92 |
93 | if public_url is None:
94 | if self.port == 80:
95 | self.public_url = f"http://{self.host}"
96 | elif self.port == 443:
97 | self.public_url = f"https://{self.host}"
98 | else:
99 | self.public_url = f"http://{self.host}:{self.port}"
100 | else:
101 | self.public_url = public_url
102 |
103 | base_path = (Path(__file__) / "../").resolve().absolute()
104 | self.statics_path = base_path / statics_path
105 | self.templates_path = base_path / templates_path
106 | self.console = Console()
107 | self.download_manager = DownloadManager()
108 |
109 | def initialize_logging(self) -> None:
110 | """Initialize logging.
111 |
112 | May be overridden in a subclass.
113 | """
114 | FORMAT = "%(message)s"
115 | logging.basicConfig(
116 | level="DEBUG" if self.debug else "INFO",
117 | format=FORMAT,
118 | datefmt="[%X]",
119 | handlers=[
120 | RichHandler(
121 | show_path=False,
122 | show_time=False,
123 | rich_tracebacks=True,
124 | tracebacks_show_locals=True,
125 | highlighter=LogHighlighter(),
126 | console=self.console,
127 | )
128 | ],
129 | )
130 |
131 | def request_exit(self) -> None:
132 | """Gracefully exit the app."""
133 | raise GracefulExit()
134 |
135 | async def _make_app(self) -> web.Application:
136 | """Make the aiohttp web.Application.
137 |
138 | Returns:
139 | New aiohttp web application.
140 | """
141 | app = web.Application()
142 |
143 | aiohttp_jinja2.setup(app, loader=jinja2.FileSystemLoader(self.templates_path))
144 |
145 | ROUTES = [
146 | web.get("/", self.handle_index, name="index"),
147 | web.get("/ws", self.handle_websocket, name="websocket"),
148 | web.get("/download/{key}", self.handle_download, name="download"),
149 | web.static("/static", self.statics_path, show_index=True, name="static"),
150 | ]
151 | app.add_routes(ROUTES)
152 |
153 | app.on_startup.append(self.on_startup)
154 | app.on_shutdown.append(self.on_shutdown)
155 | return app
156 |
157 | async def handle_download(self, request: web.Request) -> web.StreamResponse:
158 | """Handle a download request."""
159 | key = request.match_info["key"]
160 |
161 | try:
162 | download_meta = await self.download_manager.get_download_metadata(key)
163 | except KeyError:
164 | raise web.HTTPNotFound(text=f"Download with key {key!r} not found")
165 |
166 | response = web.StreamResponse()
167 | mime_type = download_meta.mime_type
168 |
169 | content_type = mime_type
170 | if download_meta.encoding:
171 | content_type += f"; charset={download_meta.encoding}"
172 |
173 | response.headers["Content-Type"] = content_type
174 | disposition = (
175 | "inline" if download_meta.open_method == "browser" else "attachment"
176 | )
177 | response.headers["Content-Disposition"] = (
178 | f"{disposition}; filename={download_meta.file_name}"
179 | )
180 |
181 | await response.prepare(request)
182 |
183 | async for chunk in self.download_manager.download(key):
184 | await response.write(chunk)
185 |
186 | await response.write_eof()
187 | return response
188 |
189 | async def on_shutdown(self, app: web.Application) -> None:
190 | """Called on shutdown.
191 |
192 | Args:
193 | app: App instance.
194 | """
195 |
196 | async def on_startup(self, app: web.Application) -> None:
197 | """Called on startup.
198 |
199 | Args:
200 | app: App instance.
201 | """
202 |
203 | self.console.print(LOGO, highlight=False)
204 | self.console.print(f"Serving {self.command!r} on {self.public_url}")
205 | self.console.print("\n[cyan]Press Ctrl+C to quit")
206 |
207 | def serve(self, debug: bool = False) -> None:
208 | """Serve the Textual application.
209 |
210 | This will run a local webserver until it is closed with Ctrl+C
211 |
212 | """
213 | self.debug = debug
214 | self.initialize_logging()
215 |
216 | loop = asyncio.get_event_loop()
217 | try:
218 | loop.add_signal_handler(signal.SIGINT, self.request_exit)
219 | loop.add_signal_handler(signal.SIGTERM, self.request_exit)
220 | except NotImplementedError:
221 | pass
222 |
223 | if self.debug:
224 | log.info("Running in debug mode. You may use textual dev tools.")
225 |
226 | web.run_app(
227 | self._make_app(),
228 | host=self.host,
229 | port=self.port,
230 | handle_signals=False,
231 | loop=loop,
232 | print=lambda *args: None,
233 | )
234 |
235 | @aiohttp_jinja2.template("app_index.html")
236 | async def handle_index(self, request: web.Request) -> dict[str, Any]:
237 | """Serves the HTML for an app.
238 |
239 | Args:
240 | request: Request object.
241 |
242 | Returns:
243 | Template data.
244 | """
245 | router = request.app.router
246 | font_size = to_int(request.query.get("fontsize", "16"), 16)
247 |
248 | def get_url(route: str, **args) -> str:
249 | """Get a URL from the aiohttp router."""
250 | path = router[route].url_for(**args)
251 | return f"{self.public_url}{path}"
252 |
253 | def get_websocket_url(route: str, **args) -> str:
254 | """Get a URL with a websocket prefix."""
255 | url = get_url(route, **args)
256 |
257 | if self.public_url.startswith("https"):
258 | return "wss:" + url.split(":", 1)[1]
259 | else:
260 | return "ws:" + url.split(":", 1)[1]
261 |
262 | context = {
263 | "font_size": font_size,
264 | "app_websocket_url": get_websocket_url("websocket"),
265 | }
266 | context["config"] = {
267 | "static": {
268 | "url": get_url("static", filename="/").rstrip("/") + "/",
269 | },
270 | }
271 | context["application"] = {
272 | "name": self.title,
273 | }
274 | return context
275 |
276 | async def _process_messages(
277 | self, websocket: web.WebSocketResponse, app_service: AppService
278 | ) -> None:
279 | """Process messages from the client browser websocket.
280 |
281 | Args:
282 | websocket: Websocket instance.
283 | app_service: App service.
284 | """
285 | TEXT = WSMsgType.TEXT
286 |
287 | async for message in websocket:
288 | if message.type != TEXT:
289 | continue
290 | envelope = message.json()
291 | assert isinstance(envelope, list)
292 | type_ = envelope[0]
293 | if type_ == "stdin":
294 | data = envelope[1]
295 | await app_service.send_bytes(data.encode("utf-8"))
296 | elif type_ == "resize":
297 | data = envelope[1]
298 | await app_service.set_terminal_size(data["width"], data["height"])
299 | elif type_ == "ping":
300 | data = envelope[1]
301 | await websocket.send_json(["pong", data])
302 | elif type_ == "blur":
303 | await app_service.blur()
304 | elif type_ == "focus":
305 | await app_service.focus()
306 |
307 | async def handle_websocket(self, request: web.Request) -> web.WebSocketResponse:
308 | """Handle the websocket that drives the remote process.
309 |
310 | This is called when the browser connects to the websocket.
311 |
312 | Args:
313 | request: Request object.
314 |
315 | Returns:
316 | Websocket response.
317 | """
318 | websocket = web.WebSocketResponse(heartbeat=15)
319 |
320 | width = to_int(request.query.get("width", "80"), 80)
321 | height = to_int(request.query.get("height", "24"), 24)
322 |
323 | app_service: AppService | None = None
324 | try:
325 | await websocket.prepare(request)
326 | app_service = AppService(
327 | self.command,
328 | write_bytes=websocket.send_bytes,
329 | write_str=websocket.send_str,
330 | close=websocket.close,
331 | download_manager=self.download_manager,
332 | debug=self.debug,
333 | )
334 | await app_service.start(width, height)
335 | try:
336 | await self._process_messages(websocket, app_service)
337 | finally:
338 | await app_service.stop()
339 |
340 | except asyncio.CancelledError:
341 | await websocket.close()
342 |
343 | except Exception as error:
344 | log.exception(error)
345 |
346 | finally:
347 | if app_service is not None:
348 | await app_service.stop()
349 |
350 | return websocket
351 |
--------------------------------------------------------------------------------
/src/textual_serve/static/css/xterm.css:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) 2014 The xterm.js authors. All rights reserved.
3 | * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
4 | * https://github.com/chjj/term.js
5 | * @license MIT
6 | *
7 | * Permission is hereby granted, free of charge, to any person obtaining a copy
8 | * of this software and associated documentation files (the "Software"), to deal
9 | * in the Software without restriction, including without limitation the rights
10 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11 | * copies of the Software, and to permit persons to whom the Software is
12 | * furnished to do so, subject to the following conditions:
13 | *
14 | * The above copyright notice and this permission notice shall be included in
15 | * all copies or substantial portions of the Software.
16 | *
17 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
23 | * THE SOFTWARE.
24 | *
25 | * Originally forked from (with the author's permission):
26 | * Fabrice Bellard's javascript vt100 for jslinux:
27 | * http://bellard.org/jslinux/
28 | * Copyright (c) 2011 Fabrice Bellard
29 | * The original design remains. The terminal itself
30 | * has been extended to include xterm CSI codes, among
31 | * other features.
32 | */
33 |
34 | /**
35 | * Default styles for xterm.js
36 | */
37 |
38 | .xterm {
39 | cursor: text;
40 | position: relative;
41 | user-select: none;
42 | -ms-user-select: none;
43 | -webkit-user-select: none;
44 | }
45 |
46 | .xterm.focus,
47 | .xterm:focus {
48 | outline: none;
49 | }
50 |
51 | .xterm .xterm-helpers {
52 | position: absolute;
53 | top: 0;
54 | /**
55 | * The z-index of the helpers must be higher than the canvases in order for
56 | * IMEs to appear on top.
57 | */
58 | z-index: 5;
59 | }
60 |
61 | .xterm .xterm-helper-textarea {
62 | padding: 0;
63 | border: 0;
64 | margin: 0;
65 | /* Move textarea out of the screen to the far left, so that the cursor is not visible */
66 | position: absolute;
67 | opacity: 0;
68 | left: -9999em;
69 | top: 0;
70 | width: 0;
71 | height: 0;
72 | z-index: -5;
73 | /** Prevent wrapping so the IME appears against the textarea at the correct position */
74 | white-space: nowrap;
75 | overflow: hidden;
76 | resize: none;
77 | }
78 |
79 | .xterm .composition-view {
80 | /* TODO: Composition position got messed up somewhere */
81 | background: #000;
82 | color: #FFF;
83 | display: none;
84 | position: absolute;
85 | white-space: nowrap;
86 | z-index: 1;
87 | }
88 |
89 | .xterm .composition-view.active {
90 | display: block;
91 | }
92 |
93 | .xterm .xterm-viewport {
94 | /* On OS X this is required in order for the scroll bar to appear fully opaque */
95 | background-color: #000;
96 | overflow-y: scroll;
97 | cursor: default;
98 | position: absolute;
99 | right: 0;
100 | left: 0;
101 | top: 0;
102 | bottom: 0;
103 | }
104 |
105 | .xterm .xterm-screen {
106 | position: relative;
107 | }
108 |
109 | .xterm .xterm-screen canvas {
110 | position: absolute;
111 | left: 0;
112 | top: 0;
113 | }
114 |
115 | .xterm .xterm-scroll-area {
116 | visibility: hidden;
117 | }
118 |
119 | .xterm-char-measure-element {
120 | display: inline-block;
121 | visibility: hidden;
122 | position: absolute;
123 | top: 0;
124 | left: -9999em;
125 | line-height: normal;
126 | }
127 |
128 | .xterm.enable-mouse-events {
129 | /* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
130 | cursor: default;
131 | }
132 |
133 | .xterm.xterm-cursor-pointer,
134 | .xterm .xterm-cursor-pointer {
135 | cursor: pointer;
136 | }
137 |
138 | .xterm.column-select.focus {
139 | /* Column selection mode */
140 | cursor: crosshair;
141 | }
142 |
143 | .xterm .xterm-accessibility,
144 | .xterm .xterm-message {
145 | position: absolute;
146 | left: 0;
147 | top: 0;
148 | bottom: 0;
149 | z-index: 10;
150 | color: transparent;
151 | }
152 |
153 | .xterm .live-region {
154 | position: absolute;
155 | left: -9999px;
156 | width: 1px;
157 | height: 1px;
158 | overflow: hidden;
159 | }
160 |
161 | .xterm-dim {
162 | opacity: 0.5;
163 | }
164 |
165 | .xterm-underline-1 { text-decoration: underline; }
166 | .xterm-underline-2 { text-decoration: double underline; }
167 | .xterm-underline-3 { text-decoration: wavy underline; }
168 | .xterm-underline-4 { text-decoration: dotted underline; }
169 | .xterm-underline-5 { text-decoration: dashed underline; }
170 |
171 | .xterm-strikethrough {
172 | text-decoration: line-through;
173 | }
174 |
175 | .xterm-screen .xterm-decoration-container .xterm-decoration {
176 | z-index: 6;
177 | position: absolute;
178 | }
179 |
180 | .xterm-decoration-overview-ruler {
181 | z-index: 7;
182 | position: absolute;
183 | top: 0;
184 | right: 0;
185 | pointer-events: none;
186 | }
187 |
188 | .xterm-decoration-top {
189 | z-index: 2;
190 | position: relative;
191 | }
192 |
--------------------------------------------------------------------------------
/src/textual_serve/static/fonts/RobotoMono-Italic-VariableFont_wght.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Textualize/textual-serve/06a8bd45d6b00a6ff8dea66252bae7fe6e7e2a2b/src/textual_serve/static/fonts/RobotoMono-Italic-VariableFont_wght.ttf
--------------------------------------------------------------------------------
/src/textual_serve/static/fonts/RobotoMono-VariableFont_wght.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Textualize/textual-serve/06a8bd45d6b00a6ff8dea66252bae7fe6e7e2a2b/src/textual_serve/static/fonts/RobotoMono-VariableFont_wght.ttf
--------------------------------------------------------------------------------
/src/textual_serve/static/images/background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Textualize/textual-serve/06a8bd45d6b00a6ff8dea66252bae7fe6e7e2a2b/src/textual_serve/static/images/background.png
--------------------------------------------------------------------------------
/src/textual_serve/templates/app_index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
10 |
11 |
103 |
121 |
122 |
123 |
124 |
125 |
126 |
132 |
138 |
139 |
140 |
{{ application.name or 'Textual Application' }}
141 |
Start
142 |
143 |
144 |
145 |
146 |
147 |
148 |
Session ended.
149 |
Restart
150 |
151 |
152 |
153 |
159 |
160 |
161 |
--------------------------------------------------------------------------------