├── .gitignore
├── CHANGELOG.md
├── LICENSE
├── README.md
├── examples
├── calculator.css
├── calculator.py
├── download.py
├── env.py
├── ganglion.toml
└── open_link.py
├── poetry.lock
├── pyproject.toml
└── src
└── textual_web
├── __init__.py
├── _two_way_dict.py
├── app_session.py
├── apps
├── merlin.py
├── signup.py
├── signup.tcss
└── welcome.py
├── cli.py
├── config.py
├── constants.py
├── environment.py
├── exit_poller.py
├── ganglion_client.py
├── identity.py
├── packets.py
├── poller.py
├── retry.py
├── session.py
├── session_manager.py
├── slugify.py
├── terminal_session.py
├── types.py
└── web.py
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | # Byte-compiled / optimized / DLL files
3 | __pycache__/
4 | *.py[cod]
5 | *$py.class
6 |
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 | pip-wheel-metadata/
25 | share/python-wheels/
26 | *.egg-info/
27 | .installed.cfg
28 | *.egg
29 | MANIFEST
30 |
31 | # PyInstaller
32 | # Usually these files are written by a python script from a template
33 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
34 | *.manifest
35 | *.spec
36 |
37 | # Installer logs
38 | pip-log.txt
39 | pip-delete-this-directory.txt
40 |
41 | # Unit test / coverage reports
42 | htmlcov/
43 | .tox/
44 | .nox/
45 | .coverage
46 | .coverage.*
47 | .cache
48 | nosetests.xml
49 | coverage.xml
50 | *.cover
51 | *.py,cover
52 | .hypothesis/
53 | .pytest_cache/
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 | target/
77 |
78 | # Jupyter Notebook
79 | .ipynb_checkpoints
80 |
81 | # IPython
82 | profile_default/
83 | ipython_config.py
84 |
85 | # pyenv
86 | .python-version
87 |
88 | # pipenv
89 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
90 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
91 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
92 | # install all needed dependencies.
93 | #Pipfile.lock
94 |
95 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
96 | __pypackages__/
97 |
98 | # Celery stuff
99 | celerybeat-schedule
100 | celerybeat.pid
101 |
102 | # SageMath parsed files
103 | *.sage.py
104 |
105 | # Environments
106 | .env
107 | .venv
108 | env/
109 | venv/
110 | ENV/
111 | env.bak/
112 | venv.bak/
113 | .vscode
114 |
115 | # Spyder project settings
116 | .spyderproject
117 | .spyproject
118 |
119 | # Rope project settings
120 | .ropeproject
121 |
122 | # mkdocs documentation
123 | /site
124 |
125 | # mypy
126 | .mypy_cache/
127 | .dmypy.json
128 | dmypy.json
129 |
130 | # Pyre type checker
131 | .pyre/
132 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | Version 1.1.0
2 | # Changelog
3 |
4 | All notable changes to this project will be documented in this file.
5 |
6 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
7 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8 |
9 |
10 | ## [0.7.0] - 2024-02-20
11 |
12 | ### Changed
13 |
14 | - Now requires Python >= 3.8
15 | - Bumped dependencies
16 |
17 | ## [0.6.0] - 2023-11-28
18 |
19 | ### Added
20 |
21 | - Added app focus
22 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 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-web
2 |
3 | Textual Web publishes [Textual](https://github.com/Textualize/textual) apps and terminals on the web.
4 |
5 | Currently in a beta phase — help us test!
6 |
7 | [Hacker News discussion](https://news.ycombinator.com/item?id=37418424)
8 |
9 | ## Getting Started
10 |
11 | Textual Web is a Python application, but you don't need to be a Python developer to run it.
12 |
13 | The easiest way to install Textual Web is via [pipx](https://pypa.github.io/pipx/).
14 | Once you have pipx installed, run the following command:
15 |
16 | ```python
17 | pipx install textual-web
18 | ```
19 |
20 | You will now have the `textual-web` command on your path.
21 |
22 | ## Run a test
23 |
24 | To see what Textual Web does, run the following at the command line:
25 |
26 | ```bash
27 | textual-web
28 | ```
29 |
30 | You should see something like the following:
31 |
32 |
33 |
34 |
35 | Click the blue links to launch the example Textual apps (you may need to hold cmd or ctrl on some terminals).
36 | Or copy the link to your browser if your terminal doesn't support links.
37 |
38 | You should see something like this in your browser:
39 |
40 |
41 |
42 |
43 |
44 | These Textual apps are running on your machine, but have public URLs.
45 | You could send the URLs to anyone with internet access, and they would see the same thing.
46 |
47 | Hit ctrl+C in the terminal to stop serving the welcome application.
48 |
49 | ## Serving a terminal
50 |
51 | Textual Web can also serve your terminal. For quick access add the `-t` switch:
52 |
53 | ```bash
54 | textual-web -t
55 | ```
56 |
57 | This will generate another URL, which will present you with your terminal in your browser:
58 |
59 |
60 |
61 |
62 |
63 | When you serve a terminal in this way it will generate a random public URL.
64 |
65 | > [!WARNING]
66 | > Don't share this with anyone you wouldn't trust to have access to your machine.
67 |
68 |
69 | ## Configuration
70 |
71 | Textual Web can serve multiple [Textual](https://github.com/Textualize/textual) apps and terminals (as many as you like).
72 |
73 | To demonstrate this, [install Textual](https://textual.textualize.io/getting_started/) and clone the repository.
74 | Navigate to the `textual/examples` directory and add the following TOML file:
75 |
76 | ```toml
77 | [app.Calculator]
78 | command = "python calculator.py"
79 |
80 | [app.Dictionary]
81 | command = "python dictionary.py"
82 | ```
83 |
84 | The name is unimportant, but let's say you called it "serve.toml".
85 | Use the `--config` switch to load the new configuration:
86 |
87 | ```bash
88 | textual-web --config serve.toml
89 | ```
90 |
91 | You should now get 3 links, one for each of the sections in the configuration:
92 |
93 |
94 |
95 |
96 |
97 | Click any of the links to serve the respective app:
98 |
99 |
100 |
101 |
102 | ### Slugs
103 |
104 | Textual Web will derive the slug (text in the URL) from the name of the app.
105 | You can also set it explicitly with the slug parameter.
106 |
107 | ```toml
108 | [app.Calculator]
109 | command = "python calculator.py"
110 | slug = "calc"
111 | ```
112 |
113 | ### Terminal configuration
114 |
115 | > [!NOTE]
116 | > Terminals currently work on macOS and Linux only. Windows support is planned for a future update.
117 |
118 | You can also add terminals to the configuration file, in a similar way.
119 |
120 | ```toml
121 | [terminal.Terminal]
122 | ```
123 |
124 | This will launch a terminal with your current shell.
125 | You can also add a `command` value to run a command other than your shell.
126 | For instance, let's say we want to serve the `htop` command.
127 | We could add the following to the configuration:
128 |
129 | ```toml
130 | [terminal.HTOP]
131 | command = "htop"
132 | ```
133 |
134 | ## Accounts
135 |
136 | In previous examples, the URLs all contained a random string of digits which will change from run to run.
137 | If you want to create a permanent URL you will need to create an account.
138 |
139 | To create an account, run the following command:
140 |
141 |
142 | ```bash
143 | textual-web --signup
144 | ```
145 |
146 | This will bring up a dialog in your terminal that looks something like this:
147 |
148 |
149 |
150 |
151 |
152 | If you fill in that dialog, it will create an account for you and generate a file called "ganglion.toml".
153 | At the top of that file you will see a section like the following:
154 |
155 | ```toml
156 | [account]
157 | api_key = "JSKK234LLNWEDSSD"
158 | ```
159 |
160 | You can add that to your configuration file, or edit "ganglion.toml" with your apps / terminals.
161 | Run it as you did previously:
162 |
163 | ```bash
164 | textual-web --config ganglion.toml
165 | ```
166 |
167 | Now the URLs generated by `textual-web` will contain your account slug in the first part of the path.
168 | The account slug won't change, so you will get the same URLs from one run to the next.
169 |
170 | ## Debugging
171 |
172 | For a little more visibility on what is going on "under the hood", set the `DEBUG` environment variable:
173 |
174 | ```
175 | DEBUG=1 textual-web --config ganglion.toml
176 | ```
177 |
178 | Note this may generate a lot of output, and it may even slow your apps down.
179 |
180 | ## Known problems
181 |
182 | You may encounter a glitch with apps that have a lot of colors.
183 | This is a bug in an upstream library, which we are expecting a fix for soon.
184 |
185 | The experience on mobile may vary.
186 | On iPhone Textual apps are quite usable, but other systems may have a few issues.
187 | We should be able to improve the mobile exprience in future updates.
188 |
189 | ## What's next?
190 |
191 | The goal of this project is to turn Textual apps into fully featured web applications.
192 |
193 | Currently serving Textual apps and terminals appears very similar.
194 | In fact, if you serve a terminal and then launch a Textual app, it will work just fine in the browser.
195 | Under the hood, however, Textual apps are served using a custom protocol.
196 | This protocol will be used to expose web application features to the Textual app.
197 |
198 | For example, a Textual app might generate a file (say a CSV with a server report).
199 | If you run that in the terminal, the file would be saved in your working directory.
200 | But in a Textual app it would be served and saved in your Downloads folder, like a regular web app.
201 |
202 | In the future, other web APIs can be exposed to Textual apps in a similar way.
203 |
204 | Also planned for the near future is *sessions*.
205 | Currently, if you close the browser tab it will also close the Textual app.
206 | In the future you will be able to close a tab and later resume where you left off.
207 | This will also allow us to upgrade servers without kicking anyone off.
208 |
209 | ## Help us test
210 |
211 | Currently testing is being coordinated via our [Discord server](https://discord.com/invite/Enf6Z3qhVr).
212 | Join us if you would like to participate.
213 |
--------------------------------------------------------------------------------
/examples/calculator.css:
--------------------------------------------------------------------------------
1 | Screen {
2 | overflow: auto;
3 | }
4 |
5 | #calculator {
6 | layout: grid;
7 | grid-size: 4;
8 | grid-gutter: 1 2;
9 | grid-columns: 1fr;
10 | grid-rows: 2fr 1fr 1fr 1fr 1fr 1fr;
11 | margin: 1 2;
12 | min-height: 25;
13 | min-width: 26;
14 | height: 100%;
15 | }
16 |
17 | Button {
18 | width: 100%;
19 | height: 100%;
20 | }
21 |
22 | #numbers {
23 | column-span: 4;
24 | content-align: right middle;
25 | padding: 0 1;
26 | height: 100%;
27 | background: $primary-lighten-2;
28 | color: $text;
29 | }
30 |
31 | #number-0 {
32 | column-span: 2;
33 | }
34 |
--------------------------------------------------------------------------------
/examples/calculator.py:
--------------------------------------------------------------------------------
1 | from decimal import Decimal
2 |
3 | from textual import events
4 | from textual.app import App, ComposeResult
5 | from textual.containers import Container
6 | from textual.css.query import NoMatches
7 | from textual.reactive import var
8 | from textual.widgets import Button, Static
9 |
10 |
11 | class CalculatorApp(App):
12 | """A working 'desktop' calculator."""
13 |
14 | CSS_PATH = "calculator.css"
15 |
16 | numbers = var("0")
17 | show_ac = var(True)
18 | left = var(Decimal("0"))
19 | right = var(Decimal("0"))
20 | value = var("")
21 | operator = var("plus")
22 |
23 | NAME_MAP = {
24 | "asterisk": "multiply",
25 | "slash": "divide",
26 | "underscore": "plus-minus",
27 | "full_stop": "point",
28 | "plus_minus_sign": "plus-minus",
29 | "percent_sign": "percent",
30 | "equals_sign": "equals",
31 | "minus": "minus",
32 | "plus": "plus",
33 | }
34 |
35 | def watch_numbers(self, value: str) -> None:
36 | """Called when numbers is updated."""
37 | # Update the Numbers widget
38 | self.query_one("#numbers", Static).update(value)
39 |
40 | def compute_show_ac(self) -> bool:
41 | """Compute switch to show AC or C button"""
42 | return self.value in ("", "0") and self.numbers == "0"
43 |
44 | def watch_show_ac(self, show_ac: bool) -> None:
45 | """Called when show_ac changes."""
46 | self.query_one("#c").display = not show_ac
47 | self.query_one("#ac").display = show_ac
48 |
49 | def compose(self) -> ComposeResult:
50 | """Add our buttons."""
51 | with Container(id="calculator"):
52 | yield Static(id="numbers")
53 | yield Button("AC", id="ac", variant="primary")
54 | yield Button("C", id="c", variant="primary")
55 | yield Button("+/-", id="plus-minus", variant="primary")
56 | yield Button("%", id="percent", variant="primary")
57 | yield Button("÷", id="divide", variant="warning")
58 | yield Button("7", id="number-7")
59 | yield Button("8", id="number-8")
60 | yield Button("9", id="number-9")
61 | yield Button("×", id="multiply", variant="warning")
62 | yield Button("4", id="number-4")
63 | yield Button("5", id="number-5")
64 | yield Button("6", id="number-6")
65 | yield Button("-", id="minus", variant="warning")
66 | yield Button("1", id="number-1")
67 | yield Button("2", id="number-2")
68 | yield Button("3", id="number-3")
69 | yield Button("+", id="plus", variant="warning")
70 | yield Button("0", id="number-0")
71 | yield Button(".", id="point")
72 | yield Button("=", id="equals", variant="warning")
73 |
74 | def on_key(self, event: events.Key) -> None:
75 | """Called when the user presses a key."""
76 |
77 | def press(button_id: str) -> None:
78 | try:
79 | self.query_one(f"#{button_id}", Button).press()
80 | except NoMatches:
81 | pass
82 |
83 | key = event.key
84 | if key.isdecimal():
85 | press(f"number-{key}")
86 | elif key == "c":
87 | press("c")
88 | press("ac")
89 | else:
90 | button_id = self.NAME_MAP.get(key)
91 | if button_id is not None:
92 | press(self.NAME_MAP.get(key, key))
93 |
94 | def on_button_pressed(self, event: Button.Pressed) -> None:
95 | """Called when a button is pressed."""
96 |
97 | button_id = event.button.id
98 | assert button_id is not None
99 |
100 | def do_math() -> None:
101 | """Does the math: LEFT OPERATOR RIGHT"""
102 | try:
103 | if self.operator == "plus":
104 | self.left += self.right
105 | elif self.operator == "minus":
106 | self.left -= self.right
107 | elif self.operator == "divide":
108 | self.left /= self.right
109 | elif self.operator == "multiply":
110 | self.left *= self.right
111 | self.numbers = str(self.left)
112 | self.value = ""
113 | except Exception:
114 | self.numbers = "Error"
115 |
116 | if button_id.startswith("number-"):
117 | number = button_id.partition("-")[-1]
118 | self.numbers = self.value = self.value.lstrip("0") + number
119 | elif button_id == "plus-minus":
120 | self.numbers = self.value = str(Decimal(self.value or "0") * -1)
121 | elif button_id == "percent":
122 | self.numbers = self.value = str(Decimal(self.value or "0") / Decimal(100))
123 | elif button_id == "point":
124 | if "." not in self.value:
125 | self.numbers = self.value = (self.value or "0") + "."
126 | elif button_id == "ac":
127 | self.value = ""
128 | self.left = self.right = Decimal(0)
129 | self.operator = "plus"
130 | self.numbers = "0"
131 | elif button_id == "c":
132 | self.value = ""
133 | self.numbers = "0"
134 | elif button_id in ("plus", "minus", "divide", "multiply"):
135 | self.right = Decimal(self.value or "0")
136 | do_math()
137 | self.operator = button_id
138 | elif button_id == "equals":
139 | if self.value:
140 | self.right = Decimal(self.value)
141 | do_math()
142 |
143 |
144 | if __name__ == "__main__":
145 | CalculatorApp().run()
146 |
--------------------------------------------------------------------------------
/examples/download.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/env.py:
--------------------------------------------------------------------------------
1 | from textual.app import App, ComposeResult
2 | from textual.widgets import Pretty
3 |
4 | import os
5 |
6 |
7 | class TerminalEnv(App):
8 | def compose(self) -> ComposeResult:
9 | yield Pretty(dict(os.environ))
10 |
11 |
12 | if __name__ == "__main__":
13 | TerminalEnv().run()
14 |
--------------------------------------------------------------------------------
/examples/ganglion.toml:
--------------------------------------------------------------------------------
1 | [account]
2 |
3 | [app.Calculator]
4 | path = "./"
5 | command = "python calculator.py"
6 |
7 |
8 | [app.Easing]
9 | slug = "easing"
10 | path = "./"
11 | command = "textual easing"
12 |
13 |
14 | [app.Keys]
15 | slug = "keys"
16 | path = "./"
17 | command = "textual keys"
18 |
19 |
20 | [app.Borders]
21 | slug = "borders"
22 | path = "./"
23 | command = "textual borders"
24 |
25 |
26 | [app.Demo]
27 | name = "Demo"
28 | slug = "demo"
29 | path = "./"
30 | command = "python -m textual"
31 |
32 | [terminal.Terminal]
33 | name = "Terminal"
34 | path = "./"
35 | terminal = true
36 |
37 | [app.OpenLink]
38 | name = "Open Link"
39 | slug = "open-link"
40 | path = "./"
41 | command = "python open_link.py"
42 |
43 | [app.Download]
44 | name = "Download"
45 | slug = "download"
46 | path = "./"
47 | command = "python download.py"
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.poetry]
2 | name = "textual_web"
3 | version = "0.8.0"
4 | description = "Serve Textual apps"
5 | authors = ["Will McGugan "]
6 | license = "MIT"
7 | readme = "README.md"
8 |
9 |
10 | [tool.poetry.dependencies]
11 | python = "^3.8.1"
12 | textual = "^0.43.0"
13 | # textual = { path = "../textual/", develop = true }
14 | aiohttp = "^3.9.3"
15 | uvloop = { version = "^0.19.0", markers = "sys_platform != 'win32'" }
16 | click = "^8.1.3"
17 | aiohttp-jinja2 = "^1.5.1"
18 | pydantic = "^2.1.1"
19 | xdg = "^6.0.0"
20 | msgpack = "^1.0.5"
21 | importlib-metadata = ">=4.11.3"
22 | httpx = ">=0.24.1"
23 | tomli = "^2.0.1"
24 |
25 |
26 | [build-system]
27 | requires = ["poetry-core"]
28 | build-backend = "poetry.core.masonry.api"
29 |
30 | [tool.black]
31 | includes = "src"
32 |
33 | [tool.poetry.scripts]
34 | textual-web = "textual_web.cli:app"
35 |
--------------------------------------------------------------------------------
/src/textual_web/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Textualize/textual-web/7d6741c9f7869881722d7d8dcf70a286cc270db9/src/textual_web/__init__.py
--------------------------------------------------------------------------------
/src/textual_web/_two_way_dict.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import Generic, TypeVar
4 |
5 | Key = TypeVar("Key")
6 | Value = TypeVar("Value")
7 |
8 |
9 | class TwoWayDict(Generic[Key, Value]):
10 | """
11 | A two-way mapping offering O(1) access in both directions.
12 |
13 | Wraps two dictionaries and uses them to provide efficient access to
14 | both values (given keys) and keys (given values).
15 | """
16 |
17 | def __init__(self, initial: dict[Key, Value] | None = None) -> None:
18 | initial_data = {} if initial is None else initial
19 | self._forward: dict[Key, Value] = initial_data
20 | self._reverse: dict[Value, Key] = {
21 | value: key for key, value in initial_data.items()
22 | }
23 |
24 | def __setitem__(self, key: Key, value: Value) -> None:
25 | # TODO: Duplicate values need to be managed to ensure consistency,
26 | # decide on best approach.
27 | self._forward.__setitem__(key, value)
28 | self._reverse.__setitem__(value, key)
29 |
30 | def __delitem__(self, key: Key) -> None:
31 | value = self._forward[key]
32 | self._forward.__delitem__(key)
33 | self._reverse.__delitem__(value)
34 |
35 | def __iter__(self):
36 | return iter(self._forward)
37 |
38 | def get(self, key: Key) -> Value | None:
39 | """Given a key, efficiently lookup and return the associated value.
40 |
41 | Args:
42 | key: The key
43 |
44 | Returns:
45 | The value
46 | """
47 | return self._forward.get(key)
48 |
49 | def get_key(self, value: Value) -> Key | None:
50 | """Given a value, efficiently lookup and return the associated key.
51 |
52 | Args:
53 | value: The value
54 |
55 | Returns:
56 | The key
57 | """
58 | return self._reverse.get(value)
59 |
60 | def contains_value(self, value: Value) -> bool:
61 | """Check if `value` is a value within this TwoWayDict.
62 |
63 | Args:
64 | value: The value to check.
65 |
66 | Returns:
67 | True if the value is within the values of this dict.
68 | """
69 | return value in self._reverse
70 |
71 | def __len__(self):
72 | return len(self._forward)
73 |
74 | def __contains__(self, item: Key) -> bool:
75 | return item in self._forward
76 |
--------------------------------------------------------------------------------
/src/textual_web/app_session.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import asyncio
4 | from asyncio import StreamReader, StreamWriter, IncompleteReadError
5 | from asyncio.subprocess import Process
6 | from enum import Enum, auto
7 | import io
8 | import logging
9 | import json
10 | import logging
11 | import os
12 | from time import monotonic
13 | from datetime import timedelta
14 | from pathlib import Path
15 |
16 | from importlib_metadata import version
17 |
18 | import rich.repr
19 |
20 | from . import constants
21 | from .session import Session, SessionConnector
22 | from .types import Meta, SessionID
23 |
24 |
25 | log = logging.getLogger("textual-web")
26 |
27 |
28 | class ProcessState(Enum):
29 | """The state of a process."""
30 |
31 | PENDING = auto()
32 | RUNNING = auto()
33 | CLOSING = auto()
34 | CLOSED = auto()
35 |
36 | def __repr__(self) -> str:
37 | return self.name
38 |
39 |
40 | @rich.repr.auto(angular=True)
41 | class AppSession(Session):
42 | """Runs a single app process."""
43 |
44 | def __init__(
45 | self,
46 | working_directory: Path,
47 | command: str,
48 | session_id: SessionID,
49 | devtools: bool = False,
50 | ) -> None:
51 | self.working_directory = working_directory
52 | self.command = command
53 | self.session_id = session_id
54 | self.devtools = devtools
55 | self.start_time: float | None = None
56 | self.end_time: float | None = None
57 | self._process: Process | None = None
58 | self._task: asyncio.Task | None = None
59 |
60 | super().__init__()
61 | self._state = ProcessState.PENDING
62 |
63 | @property
64 | def process(self) -> Process:
65 | """The asyncio (sub)process"""
66 | assert self._process is not None
67 | return self._process
68 |
69 | @property
70 | def stdin(self) -> StreamWriter:
71 | """The processes stdin."""
72 | assert self._process is not None
73 | assert self._process.stdin is not None
74 | return self._process.stdin
75 |
76 | @property
77 | def stdout(self) -> StreamReader:
78 | """The process' stdout."""
79 | assert self._process is not None
80 | assert self._process.stdout is not None
81 | return self._process.stdout
82 |
83 | @property
84 | def stderr(self) -> StreamReader:
85 | """The process' stderr."""
86 | assert self._process is not None
87 | assert self._process.stderr is not None
88 | return self._process.stderr
89 |
90 | @property
91 | def task(self) -> asyncio.Task:
92 | """Session task."""
93 | assert self._task is not None
94 | return self._task
95 |
96 | @property
97 | def state(self) -> ProcessState:
98 | """Current running state."""
99 | return self._state
100 |
101 | @state.setter
102 | def state(self, state: ProcessState) -> None:
103 | self._state = state
104 | run_time = self.run_time
105 | log.debug(
106 | "%r state=%r run_time=%s",
107 | self,
108 | self.state,
109 | "0" if run_time is None else timedelta(seconds=int(run_time)),
110 | )
111 |
112 | @property
113 | def run_time(self) -> float | None:
114 | """Time process was running, or `None` if it hasn't started."""
115 | if self.end_time is not None:
116 | assert self.start_time is not None
117 | return self.end_time - self.start_time
118 | elif self.start_time is not None:
119 | return monotonic() - self.start_time
120 | else:
121 | return None
122 |
123 | def __rich_repr__(self) -> rich.repr.Result:
124 | yield self.command
125 | yield "id", self.session_id
126 | if self._process is not None:
127 | yield "returncode", self._process.returncode, None
128 |
129 | async def open(self, width: int = 80, height: int = 24) -> None:
130 | """Open the process."""
131 | environment = dict(os.environ.copy())
132 | environment["TEXTUAL_DRIVER"] = "textual.drivers.web_driver:WebDriver"
133 | environment["TEXTUAL_FPS"] = "60"
134 | environment["TEXTUAL_COLOR_SYSTEM"] = "truecolor"
135 | environment["TERM_PROGRAM"] = "textual-web"
136 | environment["TERM_PROGRAM_VERSION"] = version("textual-web")
137 | environment["COLUMNS"] = str(width)
138 | environment["ROWS"] = str(height)
139 | if self.devtools:
140 | environment["TEXTUAL"] = "debug,devtools"
141 | environment["TEXTUAL_LOG"] = "textual.log"
142 |
143 | cwd = os.getcwd()
144 | os.chdir(str(self.working_directory))
145 | try:
146 | self._process = await asyncio.create_subprocess_shell(
147 | self.command,
148 | stdin=asyncio.subprocess.PIPE,
149 | stdout=asyncio.subprocess.PIPE,
150 | stderr=asyncio.subprocess.PIPE,
151 | env=environment,
152 | )
153 | finally:
154 | os.chdir(cwd)
155 | await self.set_terminal_size(width, height)
156 | log.debug("opened %r; %r", self.command, self._process)
157 | self.start_time = monotonic()
158 |
159 | async def start(self, connector: SessionConnector) -> asyncio.Task:
160 | """Start a task to run the process."""
161 | self._connector = connector
162 | assert self._task is None
163 | self._task = asyncio.create_task(self.run())
164 | return self._task
165 |
166 | async def close(self) -> None:
167 | """Close the process."""
168 | self.state = ProcessState.CLOSING
169 | await self.send_meta({"type": "quit"})
170 |
171 | async def wait(self) -> None:
172 | """Wait for the process to finish (call close first)."""
173 | if self._task:
174 | await self._task
175 | self._task = None
176 |
177 | async def set_terminal_size(self, width: int, height: int) -> None:
178 | """Set the terminal size for the process.
179 |
180 | Args:
181 | width: Width in cells.
182 | height: Height in cells.
183 | """
184 | await self.send_meta(
185 | {
186 | "type": "resize",
187 | "width": width,
188 | "height": height,
189 | }
190 | )
191 |
192 | async def run(self) -> None:
193 | """This loop reads stdout from the process and relays it through the websocket."""
194 |
195 | self.state = ProcessState.RUNNING
196 |
197 | META = b"M"
198 | DATA = b"D"
199 | BINARY_ENCODED = b"P"
200 |
201 | stderr_data = io.BytesIO()
202 |
203 | async def read_stderr() -> None:
204 | """Task to read stderr."""
205 | try:
206 | while True:
207 | data = await self.stderr.read(1024 * 4)
208 | if not data:
209 | break
210 | stderr_data.write(data)
211 | except asyncio.CancelledError:
212 | pass
213 |
214 | stderr_task = asyncio.create_task(read_stderr())
215 | readexactly = self.stdout.readexactly
216 | from_bytes = int.from_bytes
217 |
218 | on_data = self._connector.on_data
219 | on_meta = self._connector.on_meta
220 | on_binary_encoded_message = self._connector.on_binary_encoded_message
221 | try:
222 | ready = False
223 | for _ in range(10):
224 | line = await self.stdout.readline()
225 | if not line:
226 | break
227 | if line == b"__GANGLION__\n":
228 | ready = True
229 | break
230 | if ready:
231 | while True:
232 | type_bytes = await readexactly(1)
233 | size_bytes = await readexactly(4)
234 | size = from_bytes(size_bytes, "big")
235 | payload = await readexactly(size)
236 | if type_bytes == DATA:
237 | await on_data(payload)
238 | elif type_bytes == META:
239 | meta_data = json.loads(payload)
240 | meta_type = meta_data.get("type")
241 | if meta_type in {"exit", "blur", "focus"}:
242 | await self.send_meta({"type": meta_type})
243 | else:
244 | await on_meta(json.loads(payload))
245 | elif type_bytes == BINARY_ENCODED:
246 | await on_binary_encoded_message(payload)
247 |
248 | except IncompleteReadError:
249 | # Incomplete read means that the stream was closed
250 | pass
251 | except asyncio.CancelledError:
252 | pass
253 | finally:
254 | stderr_task.cancel()
255 | await stderr_task
256 |
257 | self.end_time = monotonic()
258 | self.state = ProcessState.CLOSED
259 |
260 | stderr_message = stderr_data.getvalue().decode("utf-8", errors="replace")
261 | if self._process is not None and self._process.returncode != 0:
262 | if constants.DEBUG and stderr_message:
263 | log.warning(stderr_message)
264 |
265 | await self._connector.on_close()
266 |
267 | @classmethod
268 | def encode_packet(cls, packet_type: bytes, payload: bytes) -> bytes:
269 | """Encode a packet.
270 |
271 | Args:
272 | packet_type: The packet type (b"D" for data or b"M" for meta)
273 | payload: The payload.
274 |
275 | Returns:
276 | Data as bytes.
277 | """
278 | return b"%s%s%s" % (packet_type, len(payload).to_bytes(4, "big"), payload)
279 |
280 | async def send_bytes(self, data: bytes) -> bool:
281 | """Send bytes to process.
282 |
283 | Args:
284 | data: Data to send.
285 |
286 | Returns:
287 | True if the data was sent, otherwise False.
288 | """
289 | stdin = self.stdin
290 | try:
291 | stdin.write(self.encode_packet(b"D", data))
292 | except RuntimeError:
293 | return False
294 | await stdin.drain()
295 | return True
296 |
297 | async def send_meta(self, data: Meta) -> bool:
298 | """Send meta information to process.
299 |
300 | Args:
301 | data: Meta dict to send.
302 |
303 | Returns:
304 | True if the data was sent, otherwise False.
305 | """
306 | stdin = self.stdin
307 | data_bytes = json.dumps(data).encode("utf-8")
308 | try:
309 | stdin.write(self.encode_packet(b"M", data_bytes))
310 | except RuntimeError:
311 | return False
312 | await stdin.drain()
313 | return True
314 |
--------------------------------------------------------------------------------
/src/textual_web/apps/merlin.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import random
4 | from datetime import timedelta
5 | from time import monotonic
6 |
7 | from textual import events
8 | from textual.app import App, ComposeResult
9 | from textual.color import Color
10 | from textual.containers import Grid
11 | from textual.reactive import var
12 | from textual.renderables.gradient import LinearGradient
13 | from textual.widget import Widget
14 | from textual.widgets import Digits, Label, Switch
15 |
16 | COLORS = [
17 | "#881177",
18 | "#aa3355",
19 | "#cc6666",
20 | "#ee9944",
21 | "#eedd00",
22 | "#99dd55",
23 | "#44dd88",
24 | "#22ccbb",
25 | "#00bbcc",
26 | "#0099cc",
27 | "#3366bb",
28 | "#663399",
29 | ]
30 |
31 |
32 | TOGGLES: dict[int, tuple[int, ...]] = {
33 | 1: (2, 4, 5),
34 | 2: (1, 3),
35 | 3: (2, 5, 6),
36 | 4: (1, 7),
37 | 5: (2, 4, 6, 8),
38 | 6: (3, 9),
39 | 7: (4, 5, 8),
40 | 8: (7, 9),
41 | 9: (5, 6, 8),
42 | }
43 |
44 |
45 | class LabelSwitch(Widget):
46 | """Switch with a numeric label."""
47 |
48 | DEFAULT_CSS = """
49 | LabelSwitch Label {
50 | text-align: center;
51 | width: 1fr;
52 | text-style: bold;
53 | }
54 |
55 | LabelSwitch Label#label-5 {
56 | color: $text-disabled;
57 | }
58 | """
59 |
60 | def __init__(self, switch_no: int) -> None:
61 | self.switch_no = switch_no
62 | super().__init__()
63 |
64 | def compose(self) -> ComposeResult:
65 | yield Label(str(self.switch_no), id=f"label-{self.switch_no}")
66 | yield Switch(id=f"switch-{self.switch_no}", name=str(self.switch_no))
67 |
68 |
69 | class Timer(Digits):
70 | DEFAULT_CSS = """
71 | Timer {
72 | text-align: center;
73 | width: auto;
74 | margin: 2 8;
75 | color: $warning;
76 | }
77 | """
78 | start_time = var(0)
79 | running = var(True)
80 |
81 | def on_mount(self) -> None:
82 | self.start_time = monotonic()
83 | self.set_interval(1, self.tick)
84 | self.tick()
85 |
86 | def tick(self) -> None:
87 | if self.start_time == 0 or not self.running:
88 | return
89 | time_elapsed = timedelta(seconds=int(monotonic() - self.start_time))
90 | self.update(str(time_elapsed))
91 |
92 |
93 | class MerlinApp(App):
94 | """A simple reproduction of one game on the Merlin hand held console."""
95 |
96 | CSS = """
97 | Screen {
98 | align: center middle;
99 | }
100 |
101 | Screen.-win {
102 | background: transparent;
103 | }
104 |
105 | Screen.-win Timer {
106 | color: $success;
107 | }
108 |
109 | Grid {
110 | width: auto;
111 | height: auto;
112 | border: thick $primary;
113 | padding: 1 2;
114 | grid-size: 3 3;
115 | grid-rows: auto;
116 | grid-columns: auto;
117 | grid-gutter: 1 1;
118 | background: $surface;
119 | }
120 | """
121 |
122 | def render(self) -> LinearGradient:
123 | stops = [(i / (len(COLORS) - 1), Color.parse(c)) for i, c in enumerate(COLORS)]
124 | return LinearGradient(30.0, stops)
125 |
126 | def compose(self) -> ComposeResult:
127 | yield Timer()
128 | with Grid():
129 | for switch in (7, 8, 9, 4, 5, 6, 1, 2, 3):
130 | yield LabelSwitch(switch)
131 |
132 | def on_mount(self) -> None:
133 | for switch_no in range(1, 10):
134 | if random.randint(0, 1):
135 | self.query_one(f"#switch-{switch_no}", Switch).toggle()
136 |
137 | def check_win(self) -> bool:
138 | on_switches = {
139 | int(switch.name or "0") for switch in self.query(Switch) if switch.value
140 | }
141 | return on_switches == {1, 2, 3, 4, 6, 7, 8, 9}
142 |
143 | def on_switch_changed(self, event: Switch.Changed) -> None:
144 | switch_no = int(event.switch.name or "0")
145 | with self.prevent(Switch.Changed):
146 | for toggle_no in TOGGLES[switch_no]:
147 | self.query_one(f"#switch-{toggle_no}", Switch).toggle()
148 | if self.check_win():
149 | self.query_one("Screen").add_class("-win")
150 | self.query_one(Timer).running = False
151 |
152 | def on_key(self, event: events.Key) -> None:
153 | if event.character and event.character.isdigit():
154 | self.query_one(f"#switch-{event.character}", Switch).toggle()
155 |
156 |
157 | if __name__ == "__main__":
158 | MerlinApp().run()
159 |
--------------------------------------------------------------------------------
/src/textual_web/apps/signup.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import json
4 | from pathlib import Path
5 | import re
6 | import unicodedata
7 |
8 | import httpx
9 | from rich.console import Console, RenderableType
10 | from rich.panel import Panel
11 | import xdg
12 |
13 | from textual import on
14 | from textual import work
15 | from textual.app import App, ComposeResult
16 | from textual import events
17 | from textual.containers import Vertical, Container
18 | from textual.renderables.bar import Bar
19 | from textual.reactive import reactive
20 | from textual.widget import Widget
21 | from textual.widgets import Label, Input, Button, LoadingIndicator
22 | from textual.screen import Screen
23 |
24 |
25 | from ..environment import Environment
26 |
27 |
28 | class Form(Container):
29 | DEFAULT_CSS = """
30 | Form {
31 | color: $text;
32 | width: auto;
33 | height: auto;
34 | background: $boost;
35 | padding: 1 2;
36 | layout: grid;
37 | grid-size: 2;
38 | grid-columns: auto 50;
39 | grid-rows: auto;
40 | grid-gutter: 1;
41 | }
42 |
43 | Form .title {
44 | color: $text;
45 | text-align: center;
46 | text-style: bold;
47 | margin-bottom: 2;
48 | width: 100%;
49 | column-span: 2;
50 | }
51 |
52 | Form Button {
53 | width: 100%;
54 | margin: 1 1 0 0;
55 | column-span: 2;
56 | }
57 | LoadingIndicator {
58 | width: 100%;
59 | height: 3 !important;
60 | margin: 2 1 0 1;
61 | display: none;
62 | }
63 | Form:disabled Button {
64 | display: none;
65 |
66 | }
67 | Form:disabled LoadingIndicator {
68 | display: block;
69 | column-span: 2;
70 | padding-bottom: 1;
71 | }
72 |
73 | Form Label {
74 | width: 100%;
75 | text-align: right;
76 | padding: 1 0 0 1;
77 | }
78 |
79 | Form .group {
80 | height: auto;
81 | width: 100%;
82 | }
83 |
84 | Form .group > PasswordStrength {
85 | padding: 0 1;
86 | color: $text-muted;
87 | }
88 |
89 | Form Input {
90 | border: tall transparent;
91 | }
92 |
93 | Form Label.info {
94 | text-align: left;
95 | padding: 0 1 0 1;
96 | color: $warning 70%;
97 | }
98 |
99 | """
100 |
101 |
102 | class PasswordStrength(Widget):
103 | DEFAULT_CSS = """
104 | PasswordStrength {
105 | height: 1;
106 | padding-left: 0;
107 | padding-top: 0;
108 | }
109 | PasswordStrength > .password-strength--highlight {
110 | color: $error;
111 |
112 | }
113 | PasswordStrength > .password-strength--back {
114 | color: $foreground 10%;
115 | }
116 |
117 | PasswordStrength > .password-strength--success {
118 | color: $success;
119 | }
120 | """
121 | COMPONENT_CLASSES = {
122 | "password-strength--highlight",
123 | "password-strength--back",
124 | "password-strength--success",
125 | }
126 | password = reactive("")
127 |
128 | def render(self) -> RenderableType:
129 | if self.password:
130 | steps = 8
131 | progress = len(self.password) / steps
132 | if progress >= 1:
133 | highlight_style = self.get_component_rich_style(
134 | "password-strength--success"
135 | )
136 | else:
137 | highlight_style = self.get_component_rich_style(
138 | "password-strength--highlight"
139 | )
140 | back_style = self.get_component_rich_style("password-strength--back")
141 | return Bar(
142 | highlight_range=(0, progress * self.size.width),
143 | width=None,
144 | background_style=back_style,
145 | highlight_style=highlight_style,
146 | )
147 | else:
148 | return "Minimum 8 characters, no common words"
149 |
150 |
151 | def slugify(value: str, allow_unicode=False) -> str:
152 | """
153 | Convert to ASCII if 'allow_unicode' is False. Convert spaces or repeated
154 | dashes to single dashes. Remove characters that aren't alphanumerics,
155 | underscores, or hyphens. Convert to lowercase. Also strip leading and
156 | trailing whitespace, dashes, and underscores.
157 | """
158 | value = str(value)
159 | if allow_unicode:
160 | value = unicodedata.normalize("NFKC", value)
161 | else:
162 | value = (
163 | unicodedata.normalize("NFKD", value)
164 | .encode("ascii", "ignore")
165 | .decode("ascii")
166 | )
167 | value = re.sub(r"[^\w\s-]", "", value.lower())
168 | return re.sub(r"[-\s]+", "-", value).strip("-_")
169 |
170 |
171 | class SignupInput(Vertical):
172 | DEFAULT_CSS = """
173 |
174 | SignupInput {
175 | width: 100%;
176 | height: auto;
177 | }
178 | """
179 |
180 |
181 | class ErrorLabel(Label):
182 | DEFAULT_CSS = """
183 | SignupScreen ErrorLabel {
184 | color: $error;
185 | text-align: left !important;
186 | padding-left: 1;
187 | padding-top: 0;
188 | display: none;
189 | }
190 | SignupScreen ErrorLabel.-show-error {
191 | display: block;
192 | }
193 | """
194 |
195 |
196 | class SignupScreen(Screen):
197 | @property
198 | def app(self) -> SignUpApp:
199 | app = super().app
200 | assert isinstance(app, SignUpApp)
201 | return app
202 |
203 | def compose(self) -> ComposeResult:
204 | with Form():
205 | yield Label("Textual-web Signup", classes="title")
206 |
207 | yield Label("Your name*")
208 | with SignupInput(id="name"):
209 | yield Input()
210 | yield ErrorLabel()
211 |
212 | yield Label("Account slug*")
213 | with SignupInput(id="account_slug"):
214 | with Vertical(classes="group"):
215 | yield Input(placeholder="Identifier used in URLs")
216 | yield Label("First come, first serve (pick wisely)", classes="info")
217 | yield ErrorLabel()
218 |
219 | yield Label("Email*")
220 | with SignupInput(id="email"):
221 | yield Input()
222 | yield ErrorLabel()
223 |
224 | yield Label("Password*")
225 | with SignupInput(id="password"):
226 | with Vertical(classes="group"):
227 | yield Input(password=True)
228 | yield PasswordStrength()
229 | yield ErrorLabel()
230 |
231 | yield Label("Password (again)")
232 | with SignupInput(id="password_check"):
233 | yield Input(password=True)
234 | yield ErrorLabel()
235 |
236 | yield Button("Signup", variant="primary", id="signup")
237 | yield LoadingIndicator()
238 |
239 | @on(Button.Pressed, "#signup")
240 | def signup(self):
241 | """Initiate signup process."""
242 | self.disabled = True
243 | data = {
244 | input.id: input.query_one(Input).value
245 | for input in self.query(SignupInput)
246 | if input.id is not None
247 | }
248 | self.send_signup(data)
249 |
250 | @work
251 | async def send_signup(self, data: dict[str, str]) -> None:
252 | """Send a post request to the Ganglion server.
253 |
254 | Args:
255 | data: Form data.
256 | """
257 | try:
258 | async with httpx.AsyncClient() as client:
259 | response = await client.post(
260 | f"{self.app.environment.api_url}signup/", data=data
261 | )
262 | result = response.json()
263 |
264 | except Exception as request_error:
265 | self.notify(
266 | "Unable to reach server. Please try again later.", severity="error"
267 | )
268 | self.log(request_error)
269 | return
270 | finally:
271 | self.disabled = False
272 |
273 | try:
274 | result = response.json()
275 | except Exception:
276 | self.notify(
277 | "Server returned an invalid response. Please try again later.",
278 | severity="error",
279 | )
280 | return
281 |
282 | for error_label in self.query(ErrorLabel):
283 | error_label.update("")
284 | error_label.remove_class("-show-error")
285 |
286 | result_type = result["type"]
287 |
288 | if result_type == "success":
289 | self.dismiss(result)
290 | elif result_type == "fail":
291 | for field, errors in result.get("errors", {}).items():
292 | if field == "_":
293 | for error in errors:
294 | self.notify(error, severity="error")
295 | else:
296 | error_label = self.query_one(f"#{field} ErrorLabel", ErrorLabel)
297 | error_label.add_class("-show-error")
298 | error_label.update("\n".join(errors))
299 |
300 | @on(Input.Changed, "#password Input")
301 | def input_changed(self, event: Input.Changed):
302 | self.query_one(PasswordStrength).password = event.input.value
303 |
304 | @on(events.DescendantBlur, "#password_check")
305 | def password_check(self, event: Input.Changed) -> None:
306 | password = self.query_one("#password", Input).value
307 | if password:
308 | password_check = self.query_one("#password-check", Input).value
309 | if password != password_check:
310 | self.notify("Passwords do not match", severity="error")
311 |
312 | @on(events.DescendantFocus, "#account_slug Input")
313 | def update_account_slug(self) -> None:
314 | org_name = self.query_one("#account_slug Input", Input).value
315 | if not org_name:
316 | name = self.query_one("#name Input", Input).value
317 | self.query_one("#account_slug Input", Input).insert_text_at_cursor(
318 | slugify(name)
319 | )
320 |
321 |
322 | class SignUpApp(App):
323 | CSS_PATH = "signup.tcss"
324 |
325 | def __init__(self, environment: Environment) -> None:
326 | self.environment = environment
327 | super().__init__()
328 |
329 | def on_ready(self) -> None:
330 | self.push_screen(SignupScreen(), callback=self.exit)
331 |
332 | @classmethod
333 | def signup(cls, environment: Environment) -> None:
334 | console = Console()
335 | app = SignUpApp(environment)
336 | result = app.run()
337 |
338 | if result is None:
339 | return
340 |
341 | console.print(
342 | Panel.fit("[bold]You have signed up to textual-web!", border_style="green")
343 | )
344 |
345 | home_path = xdg.xdg_config_home()
346 | config_path = home_path / "textual-web"
347 | config_path.mkdir(parents=True, exist_ok=True)
348 | auth_path = config_path / "auth.json"
349 | auth = {
350 | "email": result["user"]["email"],
351 | "auth": result["auth_token"]["key"],
352 | }
353 | auth_path.write_text(json.dumps(auth))
354 |
355 | console.print(f" • Wrote auth to {str(auth_path)!r}")
356 | api_key = result["api_key"]["key"]
357 | console.print(f" • Your API key is {api_key!r}")
358 | ganglion_path = Path("./ganglion.toml")
359 |
360 | CONFIG = f"""\
361 | [account]
362 | api_key = "{api_key}"
363 | """
364 | if ganglion_path.exists():
365 | console.print(
366 | f" • [red]Not writing to existing {str(ganglion_path)!r}, please update manually."
367 | )
368 | else:
369 | ganglion_path.write_text(CONFIG)
370 | console.print(f" • [green]Wrote {str(ganglion_path)!r}")
371 |
372 | console.print()
373 |
374 | console.print("Run 'textual-web --config ganglion.toml' to get started.")
375 |
--------------------------------------------------------------------------------
/src/textual_web/apps/signup.tcss:
--------------------------------------------------------------------------------
1 | SignupScreen {
2 | align: center middle;
3 |
4 | }
5 |
--------------------------------------------------------------------------------
/src/textual_web/apps/welcome.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 |
3 | from textual.app import App, ComposeResult
4 | from textual.widgets import Digits, Label
5 |
6 |
7 | class WelcomeApp(App):
8 | CSS = """
9 | Screen {
10 | align: center middle;
11 | }
12 | #clock {
13 | width: auto;
14 | }
15 | Digits {
16 | color: $success;
17 | }
18 | Label {
19 | text-align: center;
20 | }
21 | """
22 |
23 | def compose(self) -> ComposeResult:
24 | yield Label("Welcome to textual-web!")
25 | yield Digits("", id="clock")
26 |
27 | def on_ready(self) -> None:
28 | self.update_clock()
29 | self.set_interval(1, self.update_clock)
30 |
31 | def update_clock(self) -> None:
32 | clock = datetime.now().time()
33 | self.query_one(Digits).update(f"{clock:%T}")
34 |
35 |
36 | if __name__ == "__main__":
37 | app = WelcomeApp()
38 | app.run()
39 |
--------------------------------------------------------------------------------
/src/textual_web/cli.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import asyncio
4 | import click
5 | from pathlib import Path
6 | import logging
7 | import os
8 | import platform
9 | from rich.panel import Panel
10 | import sys
11 |
12 | from . import constants
13 | from . import identity
14 | from .environment import ENVIRONMENTS
15 | from .ganglion_client import GanglionClient
16 |
17 | from rich.console import Console
18 | from rich.logging import RichHandler
19 | from rich.text import Text
20 |
21 | from importlib_metadata import version
22 |
23 | WINDOWS = platform.system() == "Windows"
24 |
25 | if constants.DEBUG:
26 | FORMAT = "%(message)s"
27 | logging.basicConfig(
28 | level="DEBUG",
29 | format=FORMAT,
30 | datefmt="[%X]",
31 | handlers=[RichHandler(show_path=False)],
32 | )
33 | else:
34 | FORMAT = "%(message)s"
35 | logging.basicConfig(
36 | level="INFO",
37 | format=FORMAT,
38 | datefmt="[%X]",
39 | handlers=[RichHandler(show_path=False)],
40 | )
41 |
42 | log = logging.getLogger("textual-web")
43 |
44 |
45 | def print_disclaimer() -> None:
46 | """Print a disclaimer message."""
47 | from rich import print
48 | from rich import box
49 |
50 | panel = Panel.fit(
51 | Text.from_markup(
52 | "[b]textual-web is currently under active development, and not suitable for production use.[/b]\n\n"
53 | "For support, please join the [blue][link=https://discord.gg/Enf6Z3qhVr]Discord server[/link]",
54 | ),
55 | border_style="red",
56 | box=box.HEAVY,
57 | title="[b]Disclaimer",
58 | padding=(1, 2),
59 | )
60 | print(panel)
61 |
62 |
63 | @click.command()
64 | @click.version_option(version("textual-web"))
65 | @click.option("-c", "--config", help="Location of TOML config file.", metavar="PATH")
66 | @click.option(
67 | "-e",
68 | "--environment",
69 | help="Environment switch.",
70 | type=click.Choice(list(ENVIRONMENTS)),
71 | default=constants.ENVIRONMENT,
72 | )
73 | @click.option("-a", "--api-key", help="API key", default=constants.API_KEY)
74 | @click.option(
75 | "-r",
76 | "--run",
77 | help="Command to run a Textual app.",
78 | multiple=True,
79 | metavar="COMMAND",
80 | )
81 | @click.option(
82 | "--dev", is_flag=True, help="Enable devtools in Textual apps.", default=False
83 | )
84 | @click.option(
85 | "-t", "--terminal", is_flag=True, help="Publish a remote terminal on a random URL."
86 | )
87 | @click.option(
88 | "-x",
89 | "--exit-on-idle",
90 | type=int,
91 | metavar="WAIT",
92 | default=0,
93 | help="Exit textual-web when no apps have been launched in WAIT seconds",
94 | )
95 | @click.option("-w", "--web-interface", is_flag=True, help="Enable web interface")
96 | @click.option("-s", "--signup", is_flag=True, help="Create a textual-web account.")
97 | @click.option("--welcome", is_flag=True, help="Launch an example app.")
98 | @click.option("--merlin", is_flag=True, help="Launch Merlin game.")
99 | def app(
100 | config: str | None,
101 | environment: str,
102 | run: list[str],
103 | dev: bool,
104 | terminal: bool,
105 | exit_on_idle: int,
106 | web_interface: bool,
107 | api_key: str,
108 | signup: bool,
109 | welcome: bool,
110 | merlin: bool,
111 | ) -> None:
112 | """Textual-web can server Textual apps and terminals."""
113 |
114 | # Args:
115 | # config: Path to config.
116 | # environment: environment switch.
117 | # devtools: Enable devtools.
118 | # terminal: Enable a terminal.
119 | # api_key: API key.
120 | # signup: Signup dialog.
121 | # welcome: Welcome app.
122 | # merlin: Merlin app.
123 |
124 | error_console = Console(stderr=True)
125 | from .config import load_config, default_config
126 | from .environment import get_environment
127 |
128 | _environment = get_environment(environment)
129 |
130 | if signup:
131 | from .apps.signup import SignUpApp
132 |
133 | SignUpApp.signup(_environment)
134 | return
135 |
136 | if welcome:
137 | from .apps.welcome import WelcomeApp
138 |
139 | WelcomeApp().run()
140 | return
141 |
142 | if merlin:
143 | from .apps.merlin import MerlinApp
144 |
145 | MerlinApp().run()
146 | return
147 |
148 | VERSION = version("textual-web")
149 |
150 | print_disclaimer()
151 | log.info(f"version='{VERSION}'")
152 | if constants.DEBUG:
153 | log.info(f"environment={_environment!r}")
154 | else:
155 | log.info(f"environment={_environment.name!r}")
156 |
157 | if constants.DEBUG:
158 | log.warning("DEBUG env var is set; logs may be verbose!")
159 |
160 | if config is not None:
161 | path = Path(config).absolute()
162 | log.info(f"loading config from {str(path)!r}")
163 | try:
164 | _config = load_config(path)
165 | except FileNotFoundError:
166 | log.critical("Config not found")
167 | return
168 | except Exception as error:
169 | error_console.print(f"Failed to load config from {str(path)!r}; {error!r}")
170 | return
171 | else:
172 | log.info("No --config specified, using defaults.")
173 | _config = default_config()
174 |
175 | if constants.DEBUG:
176 | from rich import print
177 |
178 | print(_config)
179 |
180 | if dev:
181 | log.info("Devtools enabled in Textual apps (run textual console)")
182 |
183 | ganglion_client = GanglionClient(
184 | config or "./",
185 | _config,
186 | _environment,
187 | api_key=api_key or None,
188 | devtools=dev,
189 | exit_on_idle=exit_on_idle,
190 | web_interface=web_interface,
191 | )
192 |
193 | for app_command in run:
194 | ganglion_client.add_app(app_command, app_command, "")
195 |
196 | if terminal:
197 | ganglion_client.add_terminal(
198 | "Terminal",
199 | os.environ.get("SHELL", "bin/sh"),
200 | "",
201 | )
202 |
203 | if not ganglion_client.app_count:
204 | ganglion_client.add_app("Welcome", "textual-web --welcome", "welcome")
205 | ganglion_client.add_app("Merlin Tribute", "textual-web --merlin", "merlin")
206 |
207 | if WINDOWS:
208 | asyncio.run(ganglion_client.run())
209 | else:
210 | try:
211 | import uvloop
212 | except ImportError:
213 | asyncio.run(ganglion_client.run())
214 | else:
215 | if sys.version_info >= (3, 11):
216 | with asyncio.Runner(loop_factory=uvloop.new_event_loop) as runner:
217 | runner.run(ganglion_client.run())
218 | else:
219 | uvloop.install()
220 | asyncio.run(ganglion_client.run())
221 |
222 |
223 | if __name__ == "__main__":
224 | app()
225 |
--------------------------------------------------------------------------------
/src/textual_web/config.py:
--------------------------------------------------------------------------------
1 | from os.path import expandvars
2 | from typing import Optional, Dict, List
3 |
4 | from typing_extensions import Annotated
5 | from pathlib import Path
6 | import tomli
7 |
8 |
9 | from pydantic import BaseModel, Field
10 | from pydantic.functional_validators import AfterValidator
11 |
12 | from .identity import generate
13 | from .slugify import slugify
14 |
15 | ExpandVarsStr = Annotated[str, AfterValidator(expandvars)]
16 |
17 |
18 | class Account(BaseModel):
19 | api_key: Optional[str] = None
20 |
21 |
22 | class App(BaseModel):
23 | """Defines an application."""
24 |
25 | name: str
26 | slug: str = ""
27 | path: ExpandVarsStr = "./"
28 | color: str = ""
29 | command: ExpandVarsStr = ""
30 | terminal: bool = False
31 |
32 |
33 | class Config(BaseModel):
34 | """Root configuration model."""
35 |
36 | account: Account
37 | apps: List[App] = Field(default_factory=list)
38 |
39 |
40 | def default_config() -> Config:
41 | """Get a default empty configuration.
42 |
43 | Returns:
44 | Configuration object.
45 | """
46 | return Config(account=Account())
47 |
48 |
49 | def load_config(config_path: Path) -> Config:
50 | """Load config from a path.
51 |
52 | Args:
53 | config_path: Path to TOML configuration.
54 |
55 | Returns:
56 | Config object.
57 | """
58 | with Path(config_path).open("rb") as config_file:
59 | config_data = tomli.load(config_file)
60 |
61 | account = Account(**config_data.get("account", {}))
62 |
63 | def make_app(name, data: Dict[str, object], terminal: bool = False) -> App:
64 | data["name"] = name
65 | data["terminal"] = terminal
66 | if terminal:
67 | data["slug"] = generate().lower()
68 | elif not data.get("slug", ""):
69 | data["slug"] = slugify(name)
70 |
71 | return App(**data)
72 |
73 | apps = [make_app(name, app) for name, app in config_data.get("app", {}).items()]
74 |
75 | apps += [
76 | make_app(name, app, terminal=True)
77 | for name, app in config_data.get("terminal", {}).items()
78 | ]
79 |
80 | config = Config(account=account, apps=apps)
81 |
82 | return config
83 |
--------------------------------------------------------------------------------
/src/textual_web/constants.py:
--------------------------------------------------------------------------------
1 | """
2 | Constants that we might want to expose via the public API.
3 | """
4 |
5 | from __future__ import annotations
6 |
7 | import os
8 |
9 | from typing_extensions import Final
10 |
11 | get_environ = os.environ.get
12 |
13 |
14 | def get_environ_bool(name: str) -> bool:
15 | """Check an environment variable switch.
16 |
17 | Args:
18 | name: Name of environment variable.
19 |
20 | Returns:
21 | `True` if the env var is "1", otherwise `False`.
22 | """
23 | has_environ = get_environ(name) == "1"
24 | return has_environ
25 |
26 |
27 | def get_environ_int(name: str, default: int) -> int:
28 | """Retrieves an integer environment variable.
29 |
30 | Args:
31 | name: Name of environment variable.
32 | default: The value to use if the value is not set, or set to something other
33 | than a valid integer.
34 |
35 | Returns:
36 | The integer associated with the environment variable if it's set to a valid int
37 | or the default value otherwise.
38 | """
39 | try:
40 | return int(os.environ[name])
41 | except KeyError:
42 | return default
43 | except ValueError:
44 | return default
45 |
46 |
47 | DEBUG: Final = get_environ_bool("DEBUG")
48 | """Enable debug mode."""
49 |
50 | ENVIRONMENT: Final[str] = get_environ("GANGLION_ENVIRONMENT", "prod")
51 | """Select alternative environment."""
52 |
53 | API_KEY: Final[str] = get_environ("GANGLION_API_KEY", "")
54 |
--------------------------------------------------------------------------------
/src/textual_web/environment.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from dataclasses import dataclass
4 |
5 |
6 | @dataclass
7 | class Environment:
8 | """Data structure to describe the environment (dev, prod, local)."""
9 |
10 | name: str
11 | """Name of the environment, used in switch."""
12 | api_url: str
13 | """Endpoint for API."""
14 | url: str
15 | """Websocket endpoint for client."""
16 |
17 |
18 | ENVIRONMENTS = {
19 | "prod": Environment(
20 | name="prod",
21 | api_url="https://textual-web.io/api/",
22 | url="wss://textual-web.io/app-service/",
23 | ),
24 | "local": Environment(
25 | name="local",
26 | api_url="ws://127.0.0.1:8080/api/",
27 | url="ws://127.0.0.1:8080/app-service/",
28 | ),
29 | "dev": Environment(
30 | name="dev",
31 | api_url="https://textualize-dev.io/api/",
32 | url="wss://textualize-dev.io/app-service/",
33 | ),
34 | }
35 |
36 |
37 | def get_environment(environment: str) -> Environment:
38 | """Get an Environment instance for the given environment name.
39 |
40 | Returns:
41 | A Environment instance.
42 |
43 | """
44 | try:
45 | run_environment = ENVIRONMENTS[environment]
46 | except KeyError:
47 | raise RuntimeError(f"Invalid environment {environment!r}")
48 | return run_environment
49 |
--------------------------------------------------------------------------------
/src/textual_web/exit_poller.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import asyncio
4 | import logging
5 | from time import monotonic
6 | from typing import TYPE_CHECKING
7 |
8 | EXIT_POLL_RATE = 5
9 |
10 | log = logging.getLogger("textual-web")
11 |
12 | if TYPE_CHECKING:
13 | from .ganglion_client import GanglionClient
14 |
15 |
16 | class ExitPoller:
17 | """Monitors the client for an idle state, and exits."""
18 |
19 | def __init__(self, client: GanglionClient, idle_wait: int) -> None:
20 | self.client = client
21 | self.idle_wait = idle_wait
22 | self._task: asyncio.Task | None = None
23 | self._idle_start_time: float | None = None
24 |
25 | def start(self) -> None:
26 | """Start polling."""
27 | self._task = asyncio.create_task(self.run())
28 |
29 | def stop(self) -> None:
30 | """Stop polling"""
31 | if self._task is not None:
32 | self._task.cancel()
33 |
34 | async def run(self) -> None:
35 | """Run the poller."""
36 | if not self.idle_wait:
37 | return
38 | try:
39 | while True:
40 | await asyncio.sleep(EXIT_POLL_RATE)
41 | is_idle = not self.client.session_manager.sessions
42 | if is_idle:
43 | if self._idle_start_time is not None:
44 | if monotonic() - self._idle_start_time > self.idle_wait:
45 | log.info("Exiting due to --exit-on-idle")
46 | self.client.force_exit()
47 | else:
48 | self._idle_start_time = monotonic()
49 | else:
50 | self._idle_start_time = None
51 |
52 | except asyncio.CancelledError:
53 | pass
54 |
--------------------------------------------------------------------------------
/src/textual_web/ganglion_client.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import asyncio
4 | import logging
5 | import signal
6 | from functools import partial
7 | from pathlib import Path
8 | import platform
9 | from typing import TYPE_CHECKING, Union, cast
10 |
11 | import aiohttp
12 | import msgpack
13 | from aiohttp.client_exceptions import WSServerHandshakeError
14 |
15 | from . import constants, packets
16 | from .environment import Environment
17 | from .exit_poller import ExitPoller
18 | from .identity import generate
19 | from .packets import (
20 | Blur,
21 | Focus,
22 | PACKET_MAP,
23 | Handlers,
24 | NotifyTerminalSize,
25 | OpenUrl,
26 | Packet,
27 | RoutePing,
28 | RoutePong,
29 | SessionClose,
30 | SessionData,
31 | )
32 | from .poller import Poller
33 | from .retry import Retry
34 | from .session import SessionConnector
35 | from .session_manager import SessionManager
36 | from .types import Meta, RouteKey, SessionID
37 | from .web import run_web_interface
38 |
39 |
40 | if TYPE_CHECKING:
41 | from .config import Config
42 |
43 | WINDOWS = platform.system() == "Windows"
44 |
45 | log = logging.getLogger("textual-web")
46 |
47 |
48 | PacketDataType = Union[int, bytes, str, None]
49 |
50 |
51 | class PacketError(Exception):
52 | """A packet error."""
53 |
54 |
55 | class _ClientConnector(SessionConnector):
56 | def __init__(
57 | self, client: GanglionClient, session_id: SessionID, route_key: RouteKey
58 | ) -> None:
59 | self.client = client
60 | self.session_id = session_id
61 | self.route_key = route_key
62 |
63 | async def on_data(self, data: bytes) -> None:
64 | """Data received from the process."""
65 | await self.client.send(packets.SessionData(self.route_key, data))
66 |
67 | async def on_meta(self, meta: Meta) -> None:
68 | """On receiving a meta dict from the running process, send it to the Ganglion server."""
69 | meta_type = meta.get("type")
70 | if meta_type == "open_url":
71 | await self.client.send(
72 | packets.OpenUrl(
73 | route_key=self.route_key,
74 | url=meta["url"],
75 | new_tab=meta["new_tab"],
76 | )
77 | )
78 | elif meta_type == "deliver_file_start":
79 | await self.client.send(
80 | packets.DeliverFileStart(
81 | route_key=self.route_key,
82 | delivery_key=meta["key"],
83 | file_name=Path(meta["path"]).name,
84 | open_method=meta["open_method"],
85 | mime_type=meta["mime_type"],
86 | encoding=meta["encoding"],
87 | )
88 | )
89 | else:
90 | log.warning(
91 | f"Unknown meta type: {meta_type!r}. Full meta: {meta!r}.\n"
92 | "You may be running a version of Textual unsupported by this version of Textual Web."
93 | )
94 |
95 | async def on_binary_encoded_message(self, payload: bytes) -> None:
96 | """Handle binary encoded data from the process.
97 |
98 | This data is forwarded directly to Ganglion.
99 |
100 | Args:
101 | payload: Binary encoded data to forward to Ganglion.
102 | """
103 | await self.client.send(
104 | packets.BinaryEncodedMessage(route_key=self.route_key, data=payload)
105 | )
106 |
107 | async def on_close(self) -> None:
108 | await self.client.send(packets.SessionClose(self.session_id, self.route_key))
109 | self.client.session_manager.on_session_end(self.session_id)
110 |
111 |
112 | class GanglionClient(Handlers):
113 | """Manages a connection to a ganglion server."""
114 |
115 | def __init__(
116 | self,
117 | config_path: str,
118 | config: Config,
119 | environment: Environment,
120 | api_key: str | None,
121 | devtools: bool = False,
122 | exit_on_idle: int = 0,
123 | web_interface: bool = False,
124 | ) -> None:
125 | self.environment = environment
126 | self.websocket_url = environment.url
127 | self.exit_on_idle = exit_on_idle
128 | self.web_interface = web_interface
129 |
130 | abs_path = Path(config_path).absolute()
131 | path = abs_path if abs_path.is_dir() else abs_path.parent
132 | self.config = config
133 | self.api_key = api_key
134 | self._devtools = devtools
135 | self._websocket: aiohttp.ClientWebSocketResponse | None = None
136 | self._poller = Poller()
137 | self.session_manager = SessionManager(self._poller, path, config.apps)
138 | self.exit_event = asyncio.Event()
139 | self._task: asyncio.Task | None = None
140 | self._exit_poller = ExitPoller(self, exit_on_idle)
141 | self._connected_event = asyncio.Event()
142 |
143 | @property
144 | def app_count(self) -> int:
145 | """The number of configured apps."""
146 | return len(self.session_manager.apps)
147 |
148 | def add_app(self, name: str, command: str, slug: str = "") -> None:
149 | """Add a new app
150 |
151 | Args:
152 | name: Name of the app.
153 | command: Command to run the app.
154 | slug: Slug used in URL, or blank to auto-generate on server.
155 | """
156 | slug = slug or generate().lower()
157 | self.session_manager.add_app(name, command, slug=slug)
158 |
159 | def add_terminal(self, name: str, command: str, slug: str = "") -> None:
160 | """Add a new terminal.
161 |
162 | Args:
163 | name: Name of the app.
164 | command: Command to run the app.
165 | slug: Slug used in URL, or blank to auto-generate on server.
166 | """
167 | if WINDOWS:
168 | log.warning(
169 | "Sorry, textual-web does not currently support terminals on Windows"
170 | )
171 | else:
172 | slug = slug or generate().lower()
173 | self.session_manager.add_app(name, command, slug=slug, terminal=True)
174 |
175 | @classmethod
176 | def decode_envelope(
177 | cls, packet_envelope: tuple[PacketDataType, ...]
178 | ) -> Packet | None:
179 | """Decode a packet envelope.
180 |
181 | Packet envelopes are a list where the first value is an integer denoting the type.
182 | The type is used to look up the appropriate Packet class which is instantiated with
183 | the rest of the data.
184 |
185 | If the envelope contains *more* data than required, then that data is silently dropped.
186 | This is to provide an extension mechanism.
187 |
188 | Raises:
189 | PacketError: If the packet_envelope is empty.
190 | PacketError: If the packet type is not an int.
191 |
192 | Returns:
193 | One of the Packet classes defined in packets.py or None if the packet was of an unknown type.
194 | """
195 | if not packet_envelope:
196 | raise PacketError("Packet data is empty")
197 |
198 | packet_data: list[PacketDataType]
199 | packet_type, *packet_data = packet_envelope
200 | if not isinstance(packet_type, int):
201 | raise PacketError(f"Packet id expected int, found {packet_type!r}")
202 | packet_class = PACKET_MAP.get(packet_type, None)
203 | if packet_class is None:
204 | return None
205 | try:
206 | packet = packet_class.build(*packet_data[: len(packet_class._attributes)])
207 | except TypeError as error:
208 | raise PacketError(f"Packet failed to validate; {error}")
209 | return packet
210 |
211 | async def run(self) -> None:
212 | """Run the connection loop."""
213 |
214 | try:
215 | self._exit_poller.start()
216 | await self._run()
217 | finally:
218 | self._exit_poller.stop()
219 | # Shut down the poller thread
220 | if not WINDOWS:
221 | try:
222 | self._poller.exit()
223 | except Exception:
224 | pass
225 |
226 | def on_keyboard_interrupt(self) -> None:
227 | """Signal handler to respond to keyboard interrupt."""
228 | print(
229 | "\r\033[F"
230 | ) # Move to start of line, to overwrite "^C" written by the shell (?)
231 | log.info("Exit requested")
232 | self.exit_event.set()
233 | if self._task is not None:
234 | self._task.cancel()
235 |
236 | async def _run(self) -> None:
237 | loop = asyncio.get_event_loop()
238 | if WINDOWS:
239 |
240 | def exit_handler(signal_handler, stack_frame) -> None:
241 | """Signal handler."""
242 | self.on_keyboard_interrupt()
243 |
244 | signal.signal(signal.SIGINT, exit_handler)
245 | else:
246 | loop.add_signal_handler(signal.SIGINT, self.on_keyboard_interrupt)
247 | self._poller.set_loop(loop)
248 | self._poller.start()
249 |
250 | if self.web_interface:
251 | app = await run_web_interface(self._connected_event)
252 | try:
253 | self._task = asyncio.create_task(self.connect())
254 | finally:
255 | await app.shutdown()
256 | else:
257 | self._task = asyncio.create_task(self.connect())
258 |
259 | await self._task
260 |
261 | def force_exit(self) -> None:
262 | """Force the app to exit."""
263 | self.exit_event.set()
264 | if self._task is not None:
265 | self._task.cancel()
266 |
267 | async def connect(self) -> None:
268 | """Connect to the Ganglion server."""
269 | try:
270 | await self._connect()
271 | except asyncio.CancelledError:
272 | pass
273 |
274 | async def _connect(self) -> None:
275 | """Internal connect."""
276 | api_key = self.config.account.api_key or self.api_key or None
277 | if api_key:
278 | headers = {"GANGLIONAPIKEY": api_key}
279 | else:
280 | headers = {}
281 |
282 | retry = Retry()
283 |
284 | async for retry_count in retry:
285 | self._connected_event.clear()
286 | if self.exit_event.is_set():
287 | break
288 | try:
289 | if retry_count == 1:
290 | log.info("connecting to Ganglion")
291 | async with aiohttp.ClientSession() as session:
292 | async with session.ws_connect(
293 | self.websocket_url,
294 | headers=headers,
295 | heartbeat=15, # Sends a regular ping
296 | compress=12, # Enables websocket compression
297 | ) as websocket:
298 | self._websocket = websocket
299 | retry.success()
300 | await self.post_connect()
301 | try:
302 | await self.run_websocket(websocket, retry)
303 | finally:
304 | self._websocket = None
305 | log.info("Disconnected from Ganglion")
306 | if self.exit_event.is_set():
307 | break
308 | except asyncio.CancelledError:
309 | raise
310 | except WSServerHandshakeError:
311 | if retry_count == 1:
312 | log.warning("Received forbidden response, check your API Key")
313 | except Exception as error:
314 | if retry_count == 1:
315 | log.warning(
316 | "Unable to connect to Ganglion server. Will reattempt connection soon."
317 | )
318 | if constants.DEBUG:
319 | log.error("Unable to connect; %s", error)
320 |
321 | async def run_websocket(
322 | self, websocket: aiohttp.ClientWebSocketResponse, retry: Retry
323 | ) -> None:
324 | """Run the websocket loop.
325 |
326 | Args:
327 | websocket: Websocket.
328 | """
329 | unpackb = partial(msgpack.unpackb, use_list=True, raw=False)
330 | BINARY = aiohttp.WSMsgType.BINARY
331 |
332 | async def run_messages() -> None:
333 | """Read, decode, and dispatch websocket messages."""
334 | async for message in websocket:
335 | if message.type == BINARY:
336 | try:
337 | envelope = unpackb(message.data)
338 | except Exception:
339 | log.error(f"Unable to decode {message.data!r}")
340 | else:
341 | packet = self.decode_envelope(envelope)
342 | log.debug(" %r", packet)
343 | if packet is not None:
344 | try:
345 | await self.dispatch_packet(packet)
346 | except Exception:
347 | log.exception("error processing %r", packet)
348 |
349 | elif message.type == aiohttp.WSMsgType.ERROR:
350 | break
351 |
352 | try:
353 | await run_messages()
354 | except asyncio.CancelledError:
355 | retry.done()
356 | await self.session_manager.close_all()
357 | await websocket.close(message=b"Close requested")
358 | try:
359 | await run_messages()
360 | except asyncio.CancelledError:
361 | pass
362 | except ConnectionResetError:
363 | log.info("connection reset")
364 | except Exception as error:
365 | log.exception(str(error))
366 |
367 | async def post_connect(self) -> None:
368 | """Called immediately after connecting to the Ganglion server."""
369 | # Inform the server about our apps
370 | try:
371 | apps = [
372 | app.model_dump(include={"name", "slug", "color", "terminal"})
373 | for app in self.config.apps
374 | ]
375 | if WINDOWS:
376 | filter_apps = [app for app in apps if not app["terminal"]]
377 | if filter_apps != apps:
378 | log.warn(
379 | "Sorry, textual-web does not currently support terminals on Windows"
380 | )
381 | apps = filter_apps
382 |
383 | await self.send(packets.DeclareApps(apps))
384 | finally:
385 | self._connected_event.set()
386 |
387 | async def send(self, packet: Packet) -> bool:
388 | """Send a packet to the Ganglion server through the websocket.
389 |
390 | Args:
391 | packet: Packet to send.
392 |
393 | Returns:
394 | bool: `True` if the packet was sent, otherwise `False`.
395 | """
396 | if self._websocket is None:
397 | log.warning("Failed to send %r", packet)
398 | return False
399 | packet_bytes = msgpack.packb(packet, use_bin_type=True)
400 | try:
401 | await self._websocket.send_bytes(packet_bytes)
402 | except Exception as error:
403 | log.warning("Failed to send %r; %s", packet, error)
404 | return False
405 | else:
406 | log.debug(" %r", packet)
407 | return True
408 |
409 | async def on_ping(self, packet: packets.Ping) -> None:
410 | """Sent by the server."""
411 | # Reply to a Ping with an immediate Pong.
412 | await self.send(packets.Pong(packet.data))
413 |
414 | async def on_log(self, packet: packets.Log) -> None:
415 | """A log message sent by the server."""
416 | log.debug(f" {packet.message}")
417 |
418 | async def on_info(self, packet: packets.Info) -> None:
419 | """An info message (higher priority log) sent by the server."""
420 | log.info(f" {packet.message}")
421 |
422 | async def on_session_open(self, packet: packets.SessionOpen) -> None:
423 | route_key = packet.route_key
424 | session_process = await self.session_manager.new_session(
425 | packet.application_slug,
426 | SessionID(packet.session_id),
427 | RouteKey(packet.route_key),
428 | devtools=self._devtools,
429 | size=(packet.width, packet.height),
430 | )
431 | if session_process is None:
432 | log.debug("Failed to create session")
433 | return
434 |
435 | connector = _ClientConnector(
436 | self, cast(SessionID, packet.session_id), cast(RouteKey, route_key)
437 | )
438 |
439 | await session_process.start(connector)
440 |
441 | async def on_session_close(self, packet: SessionClose) -> None:
442 | session_id = SessionID(packet.session_id)
443 | session_process = self.session_manager.get_session(session_id)
444 | if session_process is not None:
445 | await self.session_manager.close_session(session_id)
446 |
447 | async def on_session_data(self, packet: SessionData) -> None:
448 | session_process = self.session_manager.get_session_by_route_key(
449 | RouteKey(packet.route_key)
450 | )
451 | if session_process is not None:
452 | await session_process.send_bytes(packet.data)
453 |
454 | async def on_notify_terminal_size(self, packet: NotifyTerminalSize) -> None:
455 | session_process = self.session_manager.get_session(SessionID(packet.session_id))
456 | if session_process is not None:
457 | await session_process.set_terminal_size(packet.width, packet.height)
458 |
459 | async def on_route_ping(self, packet: RoutePing) -> None:
460 | await self.send(RoutePong(packet.route_key, packet.data))
461 |
462 | async def on_focus(self, packet: Focus) -> None:
463 | """The remote app was focused."""
464 | session_process = self.session_manager.get_session_by_route_key(
465 | RouteKey(packet.route_key)
466 | )
467 | if session_process is not None:
468 | await session_process.send_meta({"type": "focus"})
469 |
470 | async def on_blur(self, packet: Blur) -> None:
471 | """The remote app lost focus."""
472 | session_process = self.session_manager.get_session_by_route_key(
473 | RouteKey(packet.route_key)
474 | )
475 | if session_process is not None:
476 | await session_process.send_meta({"type": "blur"})
477 |
478 | async def on_request_deliver_chunk(
479 | self, packet: packets.RequestDeliverChunk
480 | ) -> None:
481 | """The Ganglion server requested a chunk of a file. Forward that to the running app session.
482 |
483 | When the meta is sent to the Textual app, it will be handled inside the WebDriver.
484 | """
485 | route_key = RouteKey(packet.route_key)
486 | session_process = self.session_manager.get_session_by_route_key(route_key)
487 | if session_process is not None:
488 | meta = {
489 | "type": "deliver_chunk_request",
490 | "key": packet.delivery_key,
491 | "size": packet.chunk_size,
492 | }
493 | await session_process.send_meta(meta)
494 |
--------------------------------------------------------------------------------
/src/textual_web/identity.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | SEPARATOR = "-"
4 | IDENTITY_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTUVWYZ"
5 | IDENTITY_SIZE = 12
6 |
7 |
8 | def generate(size: int = IDENTITY_SIZE) -> str:
9 | """Generate a random identifier."""
10 | alphabet = IDENTITY_ALPHABET
11 | return "".join(alphabet[byte % 31] for byte in os.urandom(size))
12 |
--------------------------------------------------------------------------------
/src/textual_web/packets.py:
--------------------------------------------------------------------------------
1 | """
2 | This file is auto-generated from packets.yml and packets.py.template
3 |
4 | Time: Wed Aug 21 10:16:06 2024
5 | Version: 1
6 |
7 | To regenerate run `make packets.py` (in src directory)
8 |
9 | **Do not hand edit.**
10 |
11 |
12 | """
13 |
14 | from __future__ import annotations
15 |
16 | from enum import IntEnum
17 | from operator import attrgetter
18 | from typing import ClassVar, Type
19 |
20 | import rich.repr
21 |
22 | MAX_STRING = 20
23 |
24 |
25 | def abbreviate_repr(input: object) -> str:
26 | """Abbreviate any long strings."""
27 | if isinstance(input, (bytes, str)) and len(input) > MAX_STRING:
28 | cropped = len(input) - MAX_STRING
29 | return f"{input[:MAX_STRING]!r}+{cropped}"
30 | return repr(input)
31 |
32 |
33 | class PacketType(IntEnum):
34 | """Enumeration of packet types."""
35 |
36 | # A null packet (never sent).
37 | NULL = 0
38 | # Request packet data to be returned via a Pong.
39 | PING = 1 # See Ping()
40 |
41 | # Response to a Ping packet. The data from Ping should be sent back in the Pong.
42 | PONG = 2 # See Pong()
43 |
44 | # A message to be written to debug logs. This is a debugging aid, and will be disabled in production.
45 | LOG = 3 # See Log()
46 |
47 | # Info message to be written in to logs. Unlike Log, these messages will be used in production.
48 | INFO = 4 # See Info()
49 |
50 | # Declare the apps exposed.
51 | DECLARE_APPS = 5 # See DeclareApps()
52 |
53 | # Notification sent by a client when an app session was opened
54 | SESSION_OPEN = 6 # See SessionOpen()
55 |
56 | # Close an existing app session.
57 | SESSION_CLOSE = 7 # See SessionClose()
58 |
59 | # Data for a session.
60 | SESSION_DATA = 8 # See SessionData()
61 |
62 | # Session ping
63 | ROUTE_PING = 9 # See RoutePing()
64 |
65 | # Session pong
66 | ROUTE_PONG = 10 # See RoutePong()
67 |
68 | # Notify the client that the terminal has change dimensions.
69 | NOTIFY_TERMINAL_SIZE = 11 # See NotifyTerminalSize()
70 |
71 | # App has focus.
72 | FOCUS = 12 # See Focus()
73 |
74 | # App was blurred.
75 | BLUR = 13 # See Blur()
76 |
77 | # Open a URL in the browser.
78 | OPEN_URL = 14 # See OpenUrl()
79 |
80 | # A message that has been binary encoded.
81 | BINARY_ENCODED_MESSAGE = 15 # See BinaryEncodedMessage()
82 |
83 | # The app indicates to the server that it is ready to send a file.
84 | DELIVER_FILE_START = 16 # See DeliverFileStart()
85 |
86 | # The server requests a chunk of a file from the running app.
87 | REQUEST_DELIVER_CHUNK = 17 # See RequestDeliverChunk()
88 |
89 |
90 | class Packet(tuple):
91 | """Base class for a packet.
92 |
93 | Should never be sent. Use one of the derived classes.
94 |
95 | """
96 |
97 | sender: ClassVar[str] = "both"
98 | handler_name: ClassVar[str] = ""
99 | type: ClassVar[PacketType] = PacketType.NULL
100 |
101 | _attributes: ClassVar[list[tuple[str, Type]]] = []
102 | _attribute_count = 0
103 | _get_handler = attrgetter("foo")
104 |
105 |
106 | # PacketType.PING (1)
107 | class Ping(Packet):
108 | """Request packet data to be returned via a Pong.
109 |
110 | Args:
111 | data (bytes): Opaque data.
112 |
113 | """
114 |
115 | sender: ClassVar[str] = "both"
116 | """Permitted sender, should be "client", "server", or "both"."""
117 | handler_name: ClassVar[str] = "on_ping"
118 | """Name of the method used to handle this packet."""
119 | type: ClassVar[PacketType] = PacketType.PING
120 | """The packet type enumeration."""
121 |
122 | _attributes: ClassVar[list[tuple[str, Type]]] = [
123 | ("data", bytes),
124 | ]
125 | _attribute_count = 1
126 | _get_handler = attrgetter("on_ping")
127 |
128 | def __new__(cls, data: bytes) -> "Ping":
129 | return tuple.__new__(cls, (PacketType.PING, data))
130 |
131 | @classmethod
132 | def build(cls, data: bytes) -> "Ping":
133 | """Build and validate a packet from its attributes."""
134 | if not isinstance(data, bytes):
135 | raise TypeError(
136 | f'packets.Ping Type of "data" incorrect; expected bytes, found {type(data)}'
137 | )
138 | return tuple.__new__(cls, (PacketType.PING, data))
139 |
140 | def __repr__(self) -> str:
141 | _type, data = self
142 | return f"Ping({abbreviate_repr(data)})"
143 |
144 | def __rich_repr__(self) -> rich.repr.Result:
145 | yield "data", self.data
146 |
147 | @property
148 | def data(self) -> bytes:
149 | """Opaque data."""
150 | return self[1]
151 |
152 |
153 | # PacketType.PONG (2)
154 | class Pong(Packet):
155 | """Response to a Ping packet. The data from Ping should be sent back in the Pong.
156 |
157 | Args:
158 | data (bytes): Data received from PING
159 |
160 | """
161 |
162 | sender: ClassVar[str] = "both"
163 | """Permitted sender, should be "client", "server", or "both"."""
164 | handler_name: ClassVar[str] = "on_pong"
165 | """Name of the method used to handle this packet."""
166 | type: ClassVar[PacketType] = PacketType.PONG
167 | """The packet type enumeration."""
168 |
169 | _attributes: ClassVar[list[tuple[str, Type]]] = [
170 | ("data", bytes),
171 | ]
172 | _attribute_count = 1
173 | _get_handler = attrgetter("on_pong")
174 |
175 | def __new__(cls, data: bytes) -> "Pong":
176 | return tuple.__new__(cls, (PacketType.PONG, data))
177 |
178 | @classmethod
179 | def build(cls, data: bytes) -> "Pong":
180 | """Build and validate a packet from its attributes."""
181 | if not isinstance(data, bytes):
182 | raise TypeError(
183 | f'packets.Pong Type of "data" incorrect; expected bytes, found {type(data)}'
184 | )
185 | return tuple.__new__(cls, (PacketType.PONG, data))
186 |
187 | def __repr__(self) -> str:
188 | _type, data = self
189 | return f"Pong({abbreviate_repr(data)})"
190 |
191 | def __rich_repr__(self) -> rich.repr.Result:
192 | yield "data", self.data
193 |
194 | @property
195 | def data(self) -> bytes:
196 | """Data received from PING"""
197 | return self[1]
198 |
199 |
200 | # PacketType.LOG (3)
201 | class Log(Packet):
202 | """A message to be written to debug logs. This is a debugging aid, and will be disabled in production.
203 |
204 | Args:
205 | message (str): Message to log.
206 |
207 | """
208 |
209 | sender: ClassVar[str] = "both"
210 | """Permitted sender, should be "client", "server", or "both"."""
211 | handler_name: ClassVar[str] = "on_log"
212 | """Name of the method used to handle this packet."""
213 | type: ClassVar[PacketType] = PacketType.LOG
214 | """The packet type enumeration."""
215 |
216 | _attributes: ClassVar[list[tuple[str, Type]]] = [
217 | ("message", str),
218 | ]
219 | _attribute_count = 1
220 | _get_handler = attrgetter("on_log")
221 |
222 | def __new__(cls, message: str) -> "Log":
223 | return tuple.__new__(cls, (PacketType.LOG, message))
224 |
225 | @classmethod
226 | def build(cls, message: str) -> "Log":
227 | """Build and validate a packet from its attributes."""
228 | if not isinstance(message, str):
229 | raise TypeError(
230 | f'packets.Log Type of "message" incorrect; expected str, found {type(message)}'
231 | )
232 | return tuple.__new__(cls, (PacketType.LOG, message))
233 |
234 | def __repr__(self) -> str:
235 | _type, message = self
236 | return f"Log({abbreviate_repr(message)})"
237 |
238 | def __rich_repr__(self) -> rich.repr.Result:
239 | yield "message", self.message
240 |
241 | @property
242 | def message(self) -> str:
243 | """Message to log."""
244 | return self[1]
245 |
246 |
247 | # PacketType.INFO (4)
248 | class Info(Packet):
249 | """Info message to be written in to logs. Unlike Log, these messages will be used in production.
250 |
251 | Args:
252 | message (str): Info message
253 |
254 | """
255 |
256 | sender: ClassVar[str] = "server"
257 | """Permitted sender, should be "client", "server", or "both"."""
258 | handler_name: ClassVar[str] = "on_info"
259 | """Name of the method used to handle this packet."""
260 | type: ClassVar[PacketType] = PacketType.INFO
261 | """The packet type enumeration."""
262 |
263 | _attributes: ClassVar[list[tuple[str, Type]]] = [
264 | ("message", str),
265 | ]
266 | _attribute_count = 1
267 | _get_handler = attrgetter("on_info")
268 |
269 | def __new__(cls, message: str) -> "Info":
270 | return tuple.__new__(cls, (PacketType.INFO, message))
271 |
272 | @classmethod
273 | def build(cls, message: str) -> "Info":
274 | """Build and validate a packet from its attributes."""
275 | if not isinstance(message, str):
276 | raise TypeError(
277 | f'packets.Info Type of "message" incorrect; expected str, found {type(message)}'
278 | )
279 | return tuple.__new__(cls, (PacketType.INFO, message))
280 |
281 | def __repr__(self) -> str:
282 | _type, message = self
283 | return f"Info({abbreviate_repr(message)})"
284 |
285 | def __rich_repr__(self) -> rich.repr.Result:
286 | yield "message", self.message
287 |
288 | @property
289 | def message(self) -> str:
290 | """Info message"""
291 | return self[1]
292 |
293 |
294 | # PacketType.DECLARE_APPS (5)
295 | class DeclareApps(Packet):
296 | """Declare the apps exposed.
297 |
298 | Args:
299 | apps (list): Apps served by this client.
300 |
301 | """
302 |
303 | sender: ClassVar[str] = "client"
304 | """Permitted sender, should be "client", "server", or "both"."""
305 | handler_name: ClassVar[str] = "on_declare_apps"
306 | """Name of the method used to handle this packet."""
307 | type: ClassVar[PacketType] = PacketType.DECLARE_APPS
308 | """The packet type enumeration."""
309 |
310 | _attributes: ClassVar[list[tuple[str, Type]]] = [
311 | ("apps", list),
312 | ]
313 | _attribute_count = 1
314 | _get_handler = attrgetter("on_declare_apps")
315 |
316 | def __new__(cls, apps: list) -> "DeclareApps":
317 | return tuple.__new__(cls, (PacketType.DECLARE_APPS, apps))
318 |
319 | @classmethod
320 | def build(cls, apps: list) -> "DeclareApps":
321 | """Build and validate a packet from its attributes."""
322 | if not isinstance(apps, list):
323 | raise TypeError(
324 | f'packets.DeclareApps Type of "apps" incorrect; expected list, found {type(apps)}'
325 | )
326 | return tuple.__new__(cls, (PacketType.DECLARE_APPS, apps))
327 |
328 | def __repr__(self) -> str:
329 | _type, apps = self
330 | return f"DeclareApps({abbreviate_repr(apps)})"
331 |
332 | def __rich_repr__(self) -> rich.repr.Result:
333 | yield "apps", self.apps
334 |
335 | @property
336 | def apps(self) -> list:
337 | """Apps served by this client."""
338 | return self[1]
339 |
340 |
341 | # PacketType.SESSION_OPEN (6)
342 | class SessionOpen(Packet):
343 | """Notification sent by a client when an app session was opened
344 |
345 | Args:
346 | session_id (str): Session ID
347 | app_id (str): Application identity.
348 | application_slug (str): Application slug.
349 | route_key (str): Route key
350 | width (int): Terminal width.
351 | height (int): Terminal height.
352 |
353 | """
354 |
355 | sender: ClassVar[str] = "server"
356 | """Permitted sender, should be "client", "server", or "both"."""
357 | handler_name: ClassVar[str] = "on_session_open"
358 | """Name of the method used to handle this packet."""
359 | type: ClassVar[PacketType] = PacketType.SESSION_OPEN
360 | """The packet type enumeration."""
361 |
362 | _attributes: ClassVar[list[tuple[str, Type]]] = [
363 | ("session_id", str),
364 | ("app_id", str),
365 | ("application_slug", str),
366 | ("route_key", str),
367 | ("width", int),
368 | ("height", int),
369 | ]
370 | _attribute_count = 6
371 | _get_handler = attrgetter("on_session_open")
372 |
373 | def __new__(
374 | cls,
375 | session_id: str,
376 | app_id: str,
377 | application_slug: str,
378 | route_key: str,
379 | width: int,
380 | height: int,
381 | ) -> "SessionOpen":
382 | return tuple.__new__(
383 | cls,
384 | (
385 | PacketType.SESSION_OPEN,
386 | session_id,
387 | app_id,
388 | application_slug,
389 | route_key,
390 | width,
391 | height,
392 | ),
393 | )
394 |
395 | @classmethod
396 | def build(
397 | cls,
398 | session_id: str,
399 | app_id: str,
400 | application_slug: str,
401 | route_key: str,
402 | width: int,
403 | height: int,
404 | ) -> "SessionOpen":
405 | """Build and validate a packet from its attributes."""
406 | if not isinstance(session_id, str):
407 | raise TypeError(
408 | f'packets.SessionOpen Type of "session_id" incorrect; expected str, found {type(session_id)}'
409 | )
410 | if not isinstance(app_id, str):
411 | raise TypeError(
412 | f'packets.SessionOpen Type of "app_id" incorrect; expected str, found {type(app_id)}'
413 | )
414 | if not isinstance(application_slug, str):
415 | raise TypeError(
416 | f'packets.SessionOpen Type of "application_slug" incorrect; expected str, found {type(application_slug)}'
417 | )
418 | if not isinstance(route_key, str):
419 | raise TypeError(
420 | f'packets.SessionOpen Type of "route_key" incorrect; expected str, found {type(route_key)}'
421 | )
422 | if not isinstance(width, int):
423 | raise TypeError(
424 | f'packets.SessionOpen Type of "width" incorrect; expected int, found {type(width)}'
425 | )
426 | if not isinstance(height, int):
427 | raise TypeError(
428 | f'packets.SessionOpen Type of "height" incorrect; expected int, found {type(height)}'
429 | )
430 | return tuple.__new__(
431 | cls,
432 | (
433 | PacketType.SESSION_OPEN,
434 | session_id,
435 | app_id,
436 | application_slug,
437 | route_key,
438 | width,
439 | height,
440 | ),
441 | )
442 |
443 | def __repr__(self) -> str:
444 | _type, session_id, app_id, application_slug, route_key, width, height = self
445 | return f"SessionOpen({abbreviate_repr(session_id)}, {abbreviate_repr(app_id)}, {abbreviate_repr(application_slug)}, {abbreviate_repr(route_key)}, {abbreviate_repr(width)}, {abbreviate_repr(height)})"
446 |
447 | def __rich_repr__(self) -> rich.repr.Result:
448 | yield "session_id", self.session_id
449 | yield "app_id", self.app_id
450 | yield "application_slug", self.application_slug
451 | yield "route_key", self.route_key
452 | yield "width", self.width
453 | yield "height", self.height
454 |
455 | @property
456 | def session_id(self) -> str:
457 | """Session ID"""
458 | return self[1]
459 |
460 | @property
461 | def app_id(self) -> str:
462 | """Application identity."""
463 | return self[2]
464 |
465 | @property
466 | def application_slug(self) -> str:
467 | """Application slug."""
468 | return self[3]
469 |
470 | @property
471 | def route_key(self) -> str:
472 | """Route key"""
473 | return self[4]
474 |
475 | @property
476 | def width(self) -> int:
477 | """Terminal width."""
478 | return self[5]
479 |
480 | @property
481 | def height(self) -> int:
482 | """Terminal height."""
483 | return self[6]
484 |
485 |
486 | # PacketType.SESSION_CLOSE (7)
487 | class SessionClose(Packet):
488 | """Close an existing app session.
489 |
490 | Args:
491 | session_id (str): Session identity
492 | route_key (str): Route key
493 |
494 | """
495 |
496 | sender: ClassVar[str] = "server"
497 | """Permitted sender, should be "client", "server", or "both"."""
498 | handler_name: ClassVar[str] = "on_session_close"
499 | """Name of the method used to handle this packet."""
500 | type: ClassVar[PacketType] = PacketType.SESSION_CLOSE
501 | """The packet type enumeration."""
502 |
503 | _attributes: ClassVar[list[tuple[str, Type]]] = [
504 | ("session_id", str),
505 | ("route_key", str),
506 | ]
507 | _attribute_count = 2
508 | _get_handler = attrgetter("on_session_close")
509 |
510 | def __new__(cls, session_id: str, route_key: str) -> "SessionClose":
511 | return tuple.__new__(cls, (PacketType.SESSION_CLOSE, session_id, route_key))
512 |
513 | @classmethod
514 | def build(cls, session_id: str, route_key: str) -> "SessionClose":
515 | """Build and validate a packet from its attributes."""
516 | if not isinstance(session_id, str):
517 | raise TypeError(
518 | f'packets.SessionClose Type of "session_id" incorrect; expected str, found {type(session_id)}'
519 | )
520 | if not isinstance(route_key, str):
521 | raise TypeError(
522 | f'packets.SessionClose Type of "route_key" incorrect; expected str, found {type(route_key)}'
523 | )
524 | return tuple.__new__(cls, (PacketType.SESSION_CLOSE, session_id, route_key))
525 |
526 | def __repr__(self) -> str:
527 | _type, session_id, route_key = self
528 | return (
529 | f"SessionClose({abbreviate_repr(session_id)}, {abbreviate_repr(route_key)})"
530 | )
531 |
532 | def __rich_repr__(self) -> rich.repr.Result:
533 | yield "session_id", self.session_id
534 | yield "route_key", self.route_key
535 |
536 | @property
537 | def session_id(self) -> str:
538 | """Session identity"""
539 | return self[1]
540 |
541 | @property
542 | def route_key(self) -> str:
543 | """Route key"""
544 | return self[2]
545 |
546 |
547 | # PacketType.SESSION_DATA (8)
548 | class SessionData(Packet):
549 | """Data for a session.
550 |
551 | Args:
552 | route_key (str): Route index.
553 | data (bytes): Data for a remote app
554 |
555 | """
556 |
557 | sender: ClassVar[str] = "both"
558 | """Permitted sender, should be "client", "server", or "both"."""
559 | handler_name: ClassVar[str] = "on_session_data"
560 | """Name of the method used to handle this packet."""
561 | type: ClassVar[PacketType] = PacketType.SESSION_DATA
562 | """The packet type enumeration."""
563 |
564 | _attributes: ClassVar[list[tuple[str, Type]]] = [
565 | ("route_key", str),
566 | ("data", bytes),
567 | ]
568 | _attribute_count = 2
569 | _get_handler = attrgetter("on_session_data")
570 |
571 | def __new__(cls, route_key: str, data: bytes) -> "SessionData":
572 | return tuple.__new__(cls, (PacketType.SESSION_DATA, route_key, data))
573 |
574 | @classmethod
575 | def build(cls, route_key: str, data: bytes) -> "SessionData":
576 | """Build and validate a packet from its attributes."""
577 | if not isinstance(route_key, str):
578 | raise TypeError(
579 | f'packets.SessionData Type of "route_key" incorrect; expected str, found {type(route_key)}'
580 | )
581 | if not isinstance(data, bytes):
582 | raise TypeError(
583 | f'packets.SessionData Type of "data" incorrect; expected bytes, found {type(data)}'
584 | )
585 | return tuple.__new__(cls, (PacketType.SESSION_DATA, route_key, data))
586 |
587 | def __repr__(self) -> str:
588 | _type, route_key, data = self
589 | return f"SessionData({abbreviate_repr(route_key)}, {abbreviate_repr(data)})"
590 |
591 | def __rich_repr__(self) -> rich.repr.Result:
592 | yield "route_key", self.route_key
593 | yield "data", self.data
594 |
595 | @property
596 | def route_key(self) -> str:
597 | """Route index."""
598 | return self[1]
599 |
600 | @property
601 | def data(self) -> bytes:
602 | """Data for a remote app"""
603 | return self[2]
604 |
605 |
606 | # PacketType.ROUTE_PING (9)
607 | class RoutePing(Packet):
608 | """Session ping
609 |
610 | Args:
611 | route_key (str): Route index.
612 | data (str): Opaque data.
613 |
614 | """
615 |
616 | sender: ClassVar[str] = "server"
617 | """Permitted sender, should be "client", "server", or "both"."""
618 | handler_name: ClassVar[str] = "on_route_ping"
619 | """Name of the method used to handle this packet."""
620 | type: ClassVar[PacketType] = PacketType.ROUTE_PING
621 | """The packet type enumeration."""
622 |
623 | _attributes: ClassVar[list[tuple[str, Type]]] = [
624 | ("route_key", str),
625 | ("data", str),
626 | ]
627 | _attribute_count = 2
628 | _get_handler = attrgetter("on_route_ping")
629 |
630 | def __new__(cls, route_key: str, data: str) -> "RoutePing":
631 | return tuple.__new__(cls, (PacketType.ROUTE_PING, route_key, data))
632 |
633 | @classmethod
634 | def build(cls, route_key: str, data: str) -> "RoutePing":
635 | """Build and validate a packet from its attributes."""
636 | if not isinstance(route_key, str):
637 | raise TypeError(
638 | f'packets.RoutePing Type of "route_key" incorrect; expected str, found {type(route_key)}'
639 | )
640 | if not isinstance(data, str):
641 | raise TypeError(
642 | f'packets.RoutePing Type of "data" incorrect; expected str, found {type(data)}'
643 | )
644 | return tuple.__new__(cls, (PacketType.ROUTE_PING, route_key, data))
645 |
646 | def __repr__(self) -> str:
647 | _type, route_key, data = self
648 | return f"RoutePing({abbreviate_repr(route_key)}, {abbreviate_repr(data)})"
649 |
650 | def __rich_repr__(self) -> rich.repr.Result:
651 | yield "route_key", self.route_key
652 | yield "data", self.data
653 |
654 | @property
655 | def route_key(self) -> str:
656 | """Route index."""
657 | return self[1]
658 |
659 | @property
660 | def data(self) -> str:
661 | """Opaque data."""
662 | return self[2]
663 |
664 |
665 | # PacketType.ROUTE_PONG (10)
666 | class RoutePong(Packet):
667 | """Session pong
668 |
669 | Args:
670 | route_key (str): Route index.
671 | data (str): Opaque data.
672 |
673 | """
674 |
675 | sender: ClassVar[str] = "both"
676 | """Permitted sender, should be "client", "server", or "both"."""
677 | handler_name: ClassVar[str] = "on_route_pong"
678 | """Name of the method used to handle this packet."""
679 | type: ClassVar[PacketType] = PacketType.ROUTE_PONG
680 | """The packet type enumeration."""
681 |
682 | _attributes: ClassVar[list[tuple[str, Type]]] = [
683 | ("route_key", str),
684 | ("data", str),
685 | ]
686 | _attribute_count = 2
687 | _get_handler = attrgetter("on_route_pong")
688 |
689 | def __new__(cls, route_key: str, data: str) -> "RoutePong":
690 | return tuple.__new__(cls, (PacketType.ROUTE_PONG, route_key, data))
691 |
692 | @classmethod
693 | def build(cls, route_key: str, data: str) -> "RoutePong":
694 | """Build and validate a packet from its attributes."""
695 | if not isinstance(route_key, str):
696 | raise TypeError(
697 | f'packets.RoutePong Type of "route_key" incorrect; expected str, found {type(route_key)}'
698 | )
699 | if not isinstance(data, str):
700 | raise TypeError(
701 | f'packets.RoutePong Type of "data" incorrect; expected str, found {type(data)}'
702 | )
703 | return tuple.__new__(cls, (PacketType.ROUTE_PONG, route_key, data))
704 |
705 | def __repr__(self) -> str:
706 | _type, route_key, data = self
707 | return f"RoutePong({abbreviate_repr(route_key)}, {abbreviate_repr(data)})"
708 |
709 | def __rich_repr__(self) -> rich.repr.Result:
710 | yield "route_key", self.route_key
711 | yield "data", self.data
712 |
713 | @property
714 | def route_key(self) -> str:
715 | """Route index."""
716 | return self[1]
717 |
718 | @property
719 | def data(self) -> str:
720 | """Opaque data."""
721 | return self[2]
722 |
723 |
724 | # PacketType.NOTIFY_TERMINAL_SIZE (11)
725 | class NotifyTerminalSize(Packet):
726 | """Notify the client that the terminal has change dimensions.
727 |
728 | Args:
729 | session_id (str): Session identity.
730 | width (int): Width of the terminal.
731 | height (int): Height of the terminal.
732 |
733 | """
734 |
735 | sender: ClassVar[str] = "server"
736 | """Permitted sender, should be "client", "server", or "both"."""
737 | handler_name: ClassVar[str] = "on_notify_terminal_size"
738 | """Name of the method used to handle this packet."""
739 | type: ClassVar[PacketType] = PacketType.NOTIFY_TERMINAL_SIZE
740 | """The packet type enumeration."""
741 |
742 | _attributes: ClassVar[list[tuple[str, Type]]] = [
743 | ("session_id", str),
744 | ("width", int),
745 | ("height", int),
746 | ]
747 | _attribute_count = 3
748 | _get_handler = attrgetter("on_notify_terminal_size")
749 |
750 | def __new__(cls, session_id: str, width: int, height: int) -> "NotifyTerminalSize":
751 | return tuple.__new__(
752 | cls, (PacketType.NOTIFY_TERMINAL_SIZE, session_id, width, height)
753 | )
754 |
755 | @classmethod
756 | def build(cls, session_id: str, width: int, height: int) -> "NotifyTerminalSize":
757 | """Build and validate a packet from its attributes."""
758 | if not isinstance(session_id, str):
759 | raise TypeError(
760 | f'packets.NotifyTerminalSize Type of "session_id" incorrect; expected str, found {type(session_id)}'
761 | )
762 | if not isinstance(width, int):
763 | raise TypeError(
764 | f'packets.NotifyTerminalSize Type of "width" incorrect; expected int, found {type(width)}'
765 | )
766 | if not isinstance(height, int):
767 | raise TypeError(
768 | f'packets.NotifyTerminalSize Type of "height" incorrect; expected int, found {type(height)}'
769 | )
770 | return tuple.__new__(
771 | cls, (PacketType.NOTIFY_TERMINAL_SIZE, session_id, width, height)
772 | )
773 |
774 | def __repr__(self) -> str:
775 | _type, session_id, width, height = self
776 | return f"NotifyTerminalSize({abbreviate_repr(session_id)}, {abbreviate_repr(width)}, {abbreviate_repr(height)})"
777 |
778 | def __rich_repr__(self) -> rich.repr.Result:
779 | yield "session_id", self.session_id
780 | yield "width", self.width
781 | yield "height", self.height
782 |
783 | @property
784 | def session_id(self) -> str:
785 | """Session identity."""
786 | return self[1]
787 |
788 | @property
789 | def width(self) -> int:
790 | """Width of the terminal."""
791 | return self[2]
792 |
793 | @property
794 | def height(self) -> int:
795 | """Height of the terminal."""
796 | return self[3]
797 |
798 |
799 | # PacketType.FOCUS (12)
800 | class Focus(Packet):
801 | """App has focus.
802 |
803 | Args:
804 | route_key (str): Route key.
805 |
806 | """
807 |
808 | sender: ClassVar[str] = "both"
809 | """Permitted sender, should be "client", "server", or "both"."""
810 | handler_name: ClassVar[str] = "on_focus"
811 | """Name of the method used to handle this packet."""
812 | type: ClassVar[PacketType] = PacketType.FOCUS
813 | """The packet type enumeration."""
814 |
815 | _attributes: ClassVar[list[tuple[str, Type]]] = [
816 | ("route_key", str),
817 | ]
818 | _attribute_count = 1
819 | _get_handler = attrgetter("on_focus")
820 |
821 | def __new__(cls, route_key: str) -> "Focus":
822 | return tuple.__new__(cls, (PacketType.FOCUS, route_key))
823 |
824 | @classmethod
825 | def build(cls, route_key: str) -> "Focus":
826 | """Build and validate a packet from its attributes."""
827 | if not isinstance(route_key, str):
828 | raise TypeError(
829 | f'packets.Focus Type of "route_key" incorrect; expected str, found {type(route_key)}'
830 | )
831 | return tuple.__new__(cls, (PacketType.FOCUS, route_key))
832 |
833 | def __repr__(self) -> str:
834 | _type, route_key = self
835 | return f"Focus({abbreviate_repr(route_key)})"
836 |
837 | def __rich_repr__(self) -> rich.repr.Result:
838 | yield "route_key", self.route_key
839 |
840 | @property
841 | def route_key(self) -> str:
842 | """Route key."""
843 | return self[1]
844 |
845 |
846 | # PacketType.BLUR (13)
847 | class Blur(Packet):
848 | """App was blurred.
849 |
850 | Args:
851 | route_key (str): Route key.
852 |
853 | """
854 |
855 | sender: ClassVar[str] = "both"
856 | """Permitted sender, should be "client", "server", or "both"."""
857 | handler_name: ClassVar[str] = "on_blur"
858 | """Name of the method used to handle this packet."""
859 | type: ClassVar[PacketType] = PacketType.BLUR
860 | """The packet type enumeration."""
861 |
862 | _attributes: ClassVar[list[tuple[str, Type]]] = [
863 | ("route_key", str),
864 | ]
865 | _attribute_count = 1
866 | _get_handler = attrgetter("on_blur")
867 |
868 | def __new__(cls, route_key: str) -> "Blur":
869 | return tuple.__new__(cls, (PacketType.BLUR, route_key))
870 |
871 | @classmethod
872 | def build(cls, route_key: str) -> "Blur":
873 | """Build and validate a packet from its attributes."""
874 | if not isinstance(route_key, str):
875 | raise TypeError(
876 | f'packets.Blur Type of "route_key" incorrect; expected str, found {type(route_key)}'
877 | )
878 | return tuple.__new__(cls, (PacketType.BLUR, route_key))
879 |
880 | def __repr__(self) -> str:
881 | _type, route_key = self
882 | return f"Blur({abbreviate_repr(route_key)})"
883 |
884 | def __rich_repr__(self) -> rich.repr.Result:
885 | yield "route_key", self.route_key
886 |
887 | @property
888 | def route_key(self) -> str:
889 | """Route key."""
890 | return self[1]
891 |
892 |
893 | # PacketType.OPEN_URL (14)
894 | class OpenUrl(Packet):
895 | """Open a URL in the browser.
896 |
897 | Args:
898 | route_key (str): Route key.
899 | url (str): URL to open.
900 | new_tab (bool): Open in new tab.
901 |
902 | """
903 |
904 | sender: ClassVar[str] = "client"
905 | """Permitted sender, should be "client", "server", or "both"."""
906 | handler_name: ClassVar[str] = "on_open_url"
907 | """Name of the method used to handle this packet."""
908 | type: ClassVar[PacketType] = PacketType.OPEN_URL
909 | """The packet type enumeration."""
910 |
911 | _attributes: ClassVar[list[tuple[str, Type]]] = [
912 | ("route_key", str),
913 | ("url", str),
914 | ("new_tab", bool),
915 | ]
916 | _attribute_count = 3
917 | _get_handler = attrgetter("on_open_url")
918 |
919 | def __new__(cls, route_key: str, url: str, new_tab: bool) -> "OpenUrl":
920 | return tuple.__new__(cls, (PacketType.OPEN_URL, route_key, url, new_tab))
921 |
922 | @classmethod
923 | def build(cls, route_key: str, url: str, new_tab: bool) -> "OpenUrl":
924 | """Build and validate a packet from its attributes."""
925 | if not isinstance(route_key, str):
926 | raise TypeError(
927 | f'packets.OpenUrl Type of "route_key" incorrect; expected str, found {type(route_key)}'
928 | )
929 | if not isinstance(url, str):
930 | raise TypeError(
931 | f'packets.OpenUrl Type of "url" incorrect; expected str, found {type(url)}'
932 | )
933 | if not isinstance(new_tab, bool):
934 | raise TypeError(
935 | f'packets.OpenUrl Type of "new_tab" incorrect; expected bool, found {type(new_tab)}'
936 | )
937 | return tuple.__new__(cls, (PacketType.OPEN_URL, route_key, url, new_tab))
938 |
939 | def __repr__(self) -> str:
940 | _type, route_key, url, new_tab = self
941 | return f"OpenUrl({abbreviate_repr(route_key)}, {abbreviate_repr(url)}, {abbreviate_repr(new_tab)})"
942 |
943 | def __rich_repr__(self) -> rich.repr.Result:
944 | yield "route_key", self.route_key
945 | yield "url", self.url
946 | yield "new_tab", self.new_tab
947 |
948 | @property
949 | def route_key(self) -> str:
950 | """Route key."""
951 | return self[1]
952 |
953 | @property
954 | def url(self) -> str:
955 | """URL to open."""
956 | return self[2]
957 |
958 | @property
959 | def new_tab(self) -> bool:
960 | """Open in new tab."""
961 | return self[3]
962 |
963 |
964 | # PacketType.BINARY_ENCODED_MESSAGE (15)
965 | class BinaryEncodedMessage(Packet):
966 | """A message that has been binary encoded.
967 |
968 | Args:
969 | route_key (str): Route key.
970 | data (bytes): The binary encoded bytes.
971 |
972 | """
973 |
974 | sender: ClassVar[str] = "client"
975 | """Permitted sender, should be "client", "server", or "both"."""
976 | handler_name: ClassVar[str] = "on_binary_encoded_message"
977 | """Name of the method used to handle this packet."""
978 | type: ClassVar[PacketType] = PacketType.BINARY_ENCODED_MESSAGE
979 | """The packet type enumeration."""
980 |
981 | _attributes: ClassVar[list[tuple[str, Type]]] = [
982 | ("route_key", str),
983 | ("data", bytes),
984 | ]
985 | _attribute_count = 2
986 | _get_handler = attrgetter("on_binary_encoded_message")
987 |
988 | def __new__(cls, route_key: str, data: bytes) -> "BinaryEncodedMessage":
989 | return tuple.__new__(cls, (PacketType.BINARY_ENCODED_MESSAGE, route_key, data))
990 |
991 | @classmethod
992 | def build(cls, route_key: str, data: bytes) -> "BinaryEncodedMessage":
993 | """Build and validate a packet from its attributes."""
994 | if not isinstance(route_key, str):
995 | raise TypeError(
996 | f'packets.BinaryEncodedMessage Type of "route_key" incorrect; expected str, found {type(route_key)}'
997 | )
998 | if not isinstance(data, bytes):
999 | raise TypeError(
1000 | f'packets.BinaryEncodedMessage Type of "data" incorrect; expected bytes, found {type(data)}'
1001 | )
1002 | return tuple.__new__(cls, (PacketType.BINARY_ENCODED_MESSAGE, route_key, data))
1003 |
1004 | def __repr__(self) -> str:
1005 | _type, route_key, data = self
1006 | return f"BinaryEncodedMessage({abbreviate_repr(route_key)}, {abbreviate_repr(data)})"
1007 |
1008 | def __rich_repr__(self) -> rich.repr.Result:
1009 | yield "route_key", self.route_key
1010 | yield "data", self.data
1011 |
1012 | @property
1013 | def route_key(self) -> str:
1014 | """Route key."""
1015 | return self[1]
1016 |
1017 | @property
1018 | def data(self) -> bytes:
1019 | """The binary encoded bytes."""
1020 | return self[2]
1021 |
1022 |
1023 | # PacketType.DELIVER_FILE_START (16)
1024 | class DeliverFileStart(Packet):
1025 | """The app indicates to the server that it is ready to send a file.
1026 |
1027 | Args:
1028 | route_key (str): Route key.
1029 | delivery_key (str): Delivery key.
1030 | file_name (str): File name.
1031 | open_method (str): Open method.
1032 | mime_type (str): MIME type.
1033 | encoding (str): Encoding.
1034 |
1035 | """
1036 |
1037 | sender: ClassVar[str] = "client"
1038 | """Permitted sender, should be "client", "server", or "both"."""
1039 | handler_name: ClassVar[str] = "on_deliver_file_start"
1040 | """Name of the method used to handle this packet."""
1041 | type: ClassVar[PacketType] = PacketType.DELIVER_FILE_START
1042 | """The packet type enumeration."""
1043 |
1044 | _attributes: ClassVar[list[tuple[str, Type]]] = [
1045 | ("route_key", str),
1046 | ("delivery_key", str),
1047 | ("file_name", str),
1048 | ("open_method", str),
1049 | ("mime_type", str),
1050 | ("encoding", str),
1051 | ]
1052 | _attribute_count = 6
1053 | _get_handler = attrgetter("on_deliver_file_start")
1054 |
1055 | def __new__(
1056 | cls,
1057 | route_key: str,
1058 | delivery_key: str,
1059 | file_name: str,
1060 | open_method: str,
1061 | mime_type: str,
1062 | encoding: str,
1063 | ) -> "DeliverFileStart":
1064 | return tuple.__new__(
1065 | cls,
1066 | (
1067 | PacketType.DELIVER_FILE_START,
1068 | route_key,
1069 | delivery_key,
1070 | file_name,
1071 | open_method,
1072 | mime_type,
1073 | encoding,
1074 | ),
1075 | )
1076 |
1077 | @classmethod
1078 | def build(
1079 | cls,
1080 | route_key: str,
1081 | delivery_key: str,
1082 | file_name: str,
1083 | open_method: str,
1084 | mime_type: str,
1085 | encoding: str,
1086 | ) -> "DeliverFileStart":
1087 | """Build and validate a packet from its attributes."""
1088 | if not isinstance(route_key, str):
1089 | raise TypeError(
1090 | f'packets.DeliverFileStart Type of "route_key" incorrect; expected str, found {type(route_key)}'
1091 | )
1092 | if not isinstance(delivery_key, str):
1093 | raise TypeError(
1094 | f'packets.DeliverFileStart Type of "delivery_key" incorrect; expected str, found {type(delivery_key)}'
1095 | )
1096 | if not isinstance(file_name, str):
1097 | raise TypeError(
1098 | f'packets.DeliverFileStart Type of "file_name" incorrect; expected str, found {type(file_name)}'
1099 | )
1100 | if not isinstance(open_method, str):
1101 | raise TypeError(
1102 | f'packets.DeliverFileStart Type of "open_method" incorrect; expected str, found {type(open_method)}'
1103 | )
1104 | if not isinstance(mime_type, str):
1105 | raise TypeError(
1106 | f'packets.DeliverFileStart Type of "mime_type" incorrect; expected str, found {type(mime_type)}'
1107 | )
1108 | if not isinstance(encoding, str):
1109 | raise TypeError(
1110 | f'packets.DeliverFileStart Type of "encoding" incorrect; expected str, found {type(encoding)}'
1111 | )
1112 | return tuple.__new__(
1113 | cls,
1114 | (
1115 | PacketType.DELIVER_FILE_START,
1116 | route_key,
1117 | delivery_key,
1118 | file_name,
1119 | open_method,
1120 | mime_type,
1121 | encoding,
1122 | ),
1123 | )
1124 |
1125 | def __repr__(self) -> str:
1126 | _type, route_key, delivery_key, file_name, open_method, mime_type, encoding = (
1127 | self
1128 | )
1129 | return f"DeliverFileStart({abbreviate_repr(route_key)}, {abbreviate_repr(delivery_key)}, {abbreviate_repr(file_name)}, {abbreviate_repr(open_method)}, {abbreviate_repr(mime_type)}, {abbreviate_repr(encoding)})"
1130 |
1131 | def __rich_repr__(self) -> rich.repr.Result:
1132 | yield "route_key", self.route_key
1133 | yield "delivery_key", self.delivery_key
1134 | yield "file_name", self.file_name
1135 | yield "open_method", self.open_method
1136 | yield "mime_type", self.mime_type
1137 | yield "encoding", self.encoding
1138 |
1139 | @property
1140 | def route_key(self) -> str:
1141 | """Route key."""
1142 | return self[1]
1143 |
1144 | @property
1145 | def delivery_key(self) -> str:
1146 | """Delivery key."""
1147 | return self[2]
1148 |
1149 | @property
1150 | def file_name(self) -> str:
1151 | """File name."""
1152 | return self[3]
1153 |
1154 | @property
1155 | def open_method(self) -> str:
1156 | """Open method."""
1157 | return self[4]
1158 |
1159 | @property
1160 | def mime_type(self) -> str:
1161 | """MIME type."""
1162 | return self[5]
1163 |
1164 | @property
1165 | def encoding(self) -> str:
1166 | """Encoding."""
1167 | return self[6]
1168 |
1169 |
1170 | # PacketType.REQUEST_DELIVER_CHUNK (17)
1171 | class RequestDeliverChunk(Packet):
1172 | """The server requests a chunk of a file from the running app.
1173 |
1174 | Args:
1175 | route_key (str): Route key.
1176 | delivery_key (str): Delivery key.
1177 | chunk_size (int): Chunk size.
1178 |
1179 | """
1180 |
1181 | sender: ClassVar[str] = "server"
1182 | """Permitted sender, should be "client", "server", or "both"."""
1183 | handler_name: ClassVar[str] = "on_request_deliver_chunk"
1184 | """Name of the method used to handle this packet."""
1185 | type: ClassVar[PacketType] = PacketType.REQUEST_DELIVER_CHUNK
1186 | """The packet type enumeration."""
1187 |
1188 | _attributes: ClassVar[list[tuple[str, Type]]] = [
1189 | ("route_key", str),
1190 | ("delivery_key", str),
1191 | ("chunk_size", int),
1192 | ]
1193 | _attribute_count = 3
1194 | _get_handler = attrgetter("on_request_deliver_chunk")
1195 |
1196 | def __new__(
1197 | cls, route_key: str, delivery_key: str, chunk_size: int
1198 | ) -> "RequestDeliverChunk":
1199 | return tuple.__new__(
1200 | cls, (PacketType.REQUEST_DELIVER_CHUNK, route_key, delivery_key, chunk_size)
1201 | )
1202 |
1203 | @classmethod
1204 | def build(
1205 | cls, route_key: str, delivery_key: str, chunk_size: int
1206 | ) -> "RequestDeliverChunk":
1207 | """Build and validate a packet from its attributes."""
1208 | if not isinstance(route_key, str):
1209 | raise TypeError(
1210 | f'packets.RequestDeliverChunk Type of "route_key" incorrect; expected str, found {type(route_key)}'
1211 | )
1212 | if not isinstance(delivery_key, str):
1213 | raise TypeError(
1214 | f'packets.RequestDeliverChunk Type of "delivery_key" incorrect; expected str, found {type(delivery_key)}'
1215 | )
1216 | if not isinstance(chunk_size, int):
1217 | raise TypeError(
1218 | f'packets.RequestDeliverChunk Type of "chunk_size" incorrect; expected int, found {type(chunk_size)}'
1219 | )
1220 | return tuple.__new__(
1221 | cls, (PacketType.REQUEST_DELIVER_CHUNK, route_key, delivery_key, chunk_size)
1222 | )
1223 |
1224 | def __repr__(self) -> str:
1225 | _type, route_key, delivery_key, chunk_size = self
1226 | return f"RequestDeliverChunk({abbreviate_repr(route_key)}, {abbreviate_repr(delivery_key)}, {abbreviate_repr(chunk_size)})"
1227 |
1228 | def __rich_repr__(self) -> rich.repr.Result:
1229 | yield "route_key", self.route_key
1230 | yield "delivery_key", self.delivery_key
1231 | yield "chunk_size", self.chunk_size
1232 |
1233 | @property
1234 | def route_key(self) -> str:
1235 | """Route key."""
1236 | return self[1]
1237 |
1238 | @property
1239 | def delivery_key(self) -> str:
1240 | """Delivery key."""
1241 | return self[2]
1242 |
1243 | @property
1244 | def chunk_size(self) -> int:
1245 | """Chunk size."""
1246 | return self[3]
1247 |
1248 |
1249 | # A mapping of the packet id on to the packet class
1250 | PACKET_MAP: dict[int, type[Packet]] = {
1251 | 1: Ping,
1252 | 2: Pong,
1253 | 3: Log,
1254 | 4: Info,
1255 | 5: DeclareApps,
1256 | 6: SessionOpen,
1257 | 7: SessionClose,
1258 | 8: SessionData,
1259 | 9: RoutePing,
1260 | 10: RoutePong,
1261 | 11: NotifyTerminalSize,
1262 | 12: Focus,
1263 | 13: Blur,
1264 | 14: OpenUrl,
1265 | 15: BinaryEncodedMessage,
1266 | 16: DeliverFileStart,
1267 | 17: RequestDeliverChunk,
1268 | }
1269 |
1270 | # A mapping of the packet name on to the packet class
1271 | PACKET_NAME_MAP: dict[str, type[Packet]] = {
1272 | "ping": Ping,
1273 | "pong": Pong,
1274 | "log": Log,
1275 | "info": Info,
1276 | "declareapps": DeclareApps,
1277 | "sessionopen": SessionOpen,
1278 | "sessionclose": SessionClose,
1279 | "sessiondata": SessionData,
1280 | "routeping": RoutePing,
1281 | "routepong": RoutePong,
1282 | "notifyterminalsize": NotifyTerminalSize,
1283 | "focus": Focus,
1284 | "blur": Blur,
1285 | "openurl": OpenUrl,
1286 | "binaryencodedmessage": BinaryEncodedMessage,
1287 | "deliverfilestart": DeliverFileStart,
1288 | "requestdeliverchunk": RequestDeliverChunk,
1289 | }
1290 |
1291 |
1292 | class Handlers:
1293 | """Base class for handlers."""
1294 |
1295 | async def dispatch_packet(self, packet: Packet) -> None:
1296 | """Dispatch a packet to the appropriate handler.
1297 |
1298 | Args:
1299 | packet (Packet): A packet object.
1300 |
1301 | """
1302 |
1303 | await packet._get_handler(self)(packet)
1304 |
1305 | async def on_ping(self, packet: Ping) -> None:
1306 | """Request packet data to be returned via a Pong."""
1307 | await self.on_default(packet)
1308 |
1309 | async def on_pong(self, packet: Pong) -> None:
1310 | """Response to a Ping packet. The data from Ping should be sent back in the Pong."""
1311 | await self.on_default(packet)
1312 |
1313 | async def on_log(self, packet: Log) -> None:
1314 | """A message to be written to debug logs. This is a debugging aid, and will be disabled in production."""
1315 | await self.on_default(packet)
1316 |
1317 | async def on_info(self, packet: Info) -> None:
1318 | """Info message to be written in to logs. Unlike Log, these messages will be used in production."""
1319 | await self.on_default(packet)
1320 |
1321 | async def on_declare_apps(self, packet: DeclareApps) -> None:
1322 | """Declare the apps exposed."""
1323 | await self.on_default(packet)
1324 |
1325 | async def on_session_open(self, packet: SessionOpen) -> None:
1326 | """Notification sent by a client when an app session was opened"""
1327 | await self.on_default(packet)
1328 |
1329 | async def on_session_close(self, packet: SessionClose) -> None:
1330 | """Close an existing app session."""
1331 | await self.on_default(packet)
1332 |
1333 | async def on_session_data(self, packet: SessionData) -> None:
1334 | """Data for a session."""
1335 | await self.on_default(packet)
1336 |
1337 | async def on_route_ping(self, packet: RoutePing) -> None:
1338 | """Session ping"""
1339 | await self.on_default(packet)
1340 |
1341 | async def on_route_pong(self, packet: RoutePong) -> None:
1342 | """Session pong"""
1343 | await self.on_default(packet)
1344 |
1345 | async def on_notify_terminal_size(self, packet: NotifyTerminalSize) -> None:
1346 | """Notify the client that the terminal has change dimensions."""
1347 | await self.on_default(packet)
1348 |
1349 | async def on_focus(self, packet: Focus) -> None:
1350 | """App has focus."""
1351 | await self.on_default(packet)
1352 |
1353 | async def on_blur(self, packet: Blur) -> None:
1354 | """App was blurred."""
1355 | await self.on_default(packet)
1356 |
1357 | async def on_open_url(self, packet: OpenUrl) -> None:
1358 | """Open a URL in the browser."""
1359 | await self.on_default(packet)
1360 |
1361 | async def on_binary_encoded_message(self, packet: BinaryEncodedMessage) -> None:
1362 | """A message that has been binary encoded."""
1363 | await self.on_default(packet)
1364 |
1365 | async def on_deliver_file_start(self, packet: DeliverFileStart) -> None:
1366 | """The app indicates to the server that it is ready to send a file."""
1367 | await self.on_default(packet)
1368 |
1369 | async def on_request_deliver_chunk(self, packet: RequestDeliverChunk) -> None:
1370 | """The server requests a chunk of a file from the running app."""
1371 | await self.on_default(packet)
1372 |
1373 | async def on_default(self, packet: Packet) -> None:
1374 | """Called when a packet is not handled."""
1375 |
1376 |
1377 | if __name__ == "__main__":
1378 | print("packets.py imported successfully")
1379 |
--------------------------------------------------------------------------------
/src/textual_web/poller.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import asyncio
4 | from dataclasses import dataclass, field
5 | from collections import deque
6 | import os
7 | import selectors
8 | from threading import Thread, Event
9 |
10 |
11 | @dataclass
12 | class Write:
13 | """Data in a write queue."""
14 |
15 | data: bytes
16 | position: int = 0
17 | done_event: asyncio.Event = field(default_factory=asyncio.Event)
18 |
19 |
20 | class Poller(Thread):
21 | """A thread which reads from file descriptors and posts read data to a queue."""
22 |
23 | def __init__(self) -> None:
24 | super().__init__()
25 | self._loop: asyncio.AbstractEventLoop | None = None
26 | self._selector = selectors.DefaultSelector()
27 | self._read_queues: dict[int, asyncio.Queue[bytes | None]] = {}
28 | self._write_queues: dict[int, deque[Write]] = {}
29 | self._exit_event = Event()
30 |
31 | def add_file(self, file_descriptor: int) -> asyncio.Queue:
32 | """Add a file descriptor to the poller.
33 |
34 | Args:
35 | file_descriptor: File descriptor.
36 |
37 | Returns:
38 | Async queue.
39 | """
40 | self._selector.register(
41 | file_descriptor, selectors.EVENT_READ | selectors.EVENT_WRITE
42 | )
43 | queue = self._read_queues[file_descriptor] = asyncio.Queue()
44 | return queue
45 |
46 | def remove_file(self, file_descriptor: int) -> None:
47 | """Remove a file descriptor from the poller.
48 |
49 | Args:
50 | file_descriptor: File descriptor.
51 | """
52 | self._selector.unregister(file_descriptor)
53 | self._read_queues.pop(file_descriptor, None)
54 | self._write_queues.pop(file_descriptor, None)
55 |
56 | async def write(self, file_descriptor: int, data: bytes) -> None:
57 | """Write data to a file descriptor.
58 |
59 | Args:
60 | file_descriptor: File descriptor.
61 | data: Data to write.
62 | """
63 | if file_descriptor not in self._write_queues:
64 | self._write_queues[file_descriptor] = deque()
65 | new_write = Write(data)
66 | self._write_queues[file_descriptor].append(new_write)
67 | self._selector.modify(
68 | file_descriptor, selectors.EVENT_READ | selectors.EVENT_WRITE
69 | )
70 | await new_write.done_event.wait()
71 |
72 | def set_loop(self, loop: asyncio.AbstractEventLoop) -> None:
73 | """Set the asyncio loop.
74 |
75 | Args:
76 | loop: Async loop.
77 | """
78 | self._loop = loop
79 |
80 | def run(self) -> None:
81 | """Run the Poller thread."""
82 |
83 | readable_events = selectors.EVENT_READ
84 | writeable_events = selectors.EVENT_WRITE
85 |
86 | loop = self._loop
87 | selector = self._selector
88 | assert loop is not None
89 | while not self._exit_event.is_set():
90 | events = selector.select(1)
91 |
92 | for selector_key, event_mask in events:
93 | file_descriptor = selector_key.fileobj
94 | assert isinstance(file_descriptor, int)
95 |
96 | queue = self._read_queues.get(file_descriptor, None)
97 | if queue is not None:
98 | if event_mask & readable_events:
99 | try:
100 | data = os.read(file_descriptor, 1024 * 32) or None
101 | except Exception:
102 | loop.call_soon_threadsafe(queue.put_nowait, None)
103 | else:
104 | loop.call_soon_threadsafe(queue.put_nowait, data)
105 |
106 | if event_mask & writeable_events:
107 | write_queue = self._write_queues.get(file_descriptor, None)
108 | if write_queue:
109 | write = write_queue[0]
110 | bytes_written = os.write(
111 | file_descriptor, write.data[write.position :]
112 | )
113 | if bytes_written == len(write.data):
114 | write_queue.popleft()
115 | loop.call_soon_threadsafe(write.done_event.set)
116 | else:
117 | write.position += bytes_written
118 | else:
119 | selector.modify(file_descriptor, readable_events)
120 |
121 | def exit(self) -> None:
122 | """Exit and block until finished."""
123 | for queue in self._read_queues.values():
124 | queue.put_nowait(None)
125 | self._exit_event.set()
126 | self.join()
127 | self._read_queues.clear()
128 | self._write_queues.clear()
129 |
--------------------------------------------------------------------------------
/src/textual_web/retry.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import AsyncGenerator
4 | from asyncio import Event, TimeoutError, wait_for
5 | from random import random
6 | import logging
7 |
8 | log = logging.getLogger("textual-web")
9 |
10 |
11 | class Retry:
12 | """Manage exponential backoff."""
13 |
14 | def __init__(
15 | self,
16 | done_event: Event | None = None,
17 | min_wait: float = 2.0,
18 | max_wait: float = 16.0,
19 | ) -> None:
20 | """
21 | Args:
22 | done_event: An event to exit the retry loop.
23 | min_wait: Minimum delay in seconds.
24 | max_wait: Maximum delay in seconds.
25 | """
26 | self.min_wait = min_wait
27 | self.max_wait = max_wait
28 | self._done_event = Event() if done_event is None else done_event
29 | self.retry_count = 0
30 |
31 | def success(self) -> None:
32 | """Call when connection was successful."""
33 | self.retry_count = 0
34 |
35 | def done(self) -> None:
36 | """Exit retry loop."""
37 | self._done_event.set()
38 |
39 | async def __aiter__(self) -> AsyncGenerator[int, object]:
40 | """Async iterator to manage timeouts."""
41 | while not self._done_event.is_set():
42 | self.retry_count = self.retry_count + 1
43 | yield self.retry_count
44 |
45 | retry_squared = self.retry_count**2
46 | sleep_for = random() * max(self.min_wait, min(self.max_wait, retry_squared))
47 |
48 | log.debug("Retrying after %dms", int(sleep_for * 1000.0))
49 |
50 | try:
51 | await wait_for(self._done_event.wait(), sleep_for)
52 | except TimeoutError:
53 | pass
54 |
--------------------------------------------------------------------------------
/src/textual_web/session.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from abc import ABC, abstractmethod
4 | import asyncio
5 | from .types import Meta
6 |
7 |
8 | class SessionConnector:
9 | """Connect a session with a client."""
10 |
11 | async def on_data(self, data: bytes) -> None:
12 | """Handle data from session.
13 |
14 | Args:
15 | data: Bytes to handle.
16 | """
17 |
18 | async def on_meta(self, meta: Meta) -> None:
19 | """Handle meta from session.
20 |
21 | Args:
22 | meta: Mapping of meta information.
23 | """
24 |
25 | async def on_binary_encoded_message(self, payload: bytes) -> None:
26 | """Handle binary encoded data from the process.
27 |
28 | Args:
29 | payload: Binary encoded data to handle.
30 | """
31 |
32 | async def on_close(self) -> None:
33 | """Handle session close."""
34 |
35 |
36 | class Session(ABC):
37 | """Virtual base class for a session."""
38 |
39 | def __init__(self) -> None:
40 | self._connector = SessionConnector()
41 |
42 | @abstractmethod
43 | async def open(self, width: int = 80, height: int = 24) -> None:
44 | """Open the session."""
45 | ...
46 |
47 | @abstractmethod
48 | async def start(self, connector: SessionConnector) -> asyncio.Task:
49 | """Start the session.
50 |
51 | Returns:
52 | Running task.
53 | """
54 | ...
55 |
56 | @abstractmethod
57 | async def close(self) -> None:
58 | """Close the session."""
59 |
60 | @abstractmethod
61 | async def wait(self) -> None:
62 | """Wait for session to end."""
63 |
64 | @abstractmethod
65 | async def set_terminal_size(self, width: int, height: int) -> None:
66 | """Set the terminal size.
67 |
68 | Args:
69 | width: New width.
70 | height: New height.
71 | """
72 | ...
73 |
74 | @abstractmethod
75 | async def send_bytes(self, data: bytes) -> bool:
76 | """Send bytes to the process.
77 |
78 | Args:
79 | data: Bytes to send.
80 |
81 | Returns:
82 | True on success, or False if the data was not sent.
83 | """
84 | ...
85 |
86 | @abstractmethod
87 | async def send_meta(self, data: Meta) -> bool:
88 | """Send meta to the process.
89 |
90 | Args:
91 | meta: Meta information.
92 |
93 | Returns:
94 | True on success, or False if the data was not sent.
95 | """
96 | ...
97 |
--------------------------------------------------------------------------------
/src/textual_web/session_manager.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import asyncio
4 | import logging
5 | from pathlib import Path
6 | import platform
7 |
8 | from . import config
9 | from .identity import generate
10 |
11 | from .app_session import AppSession
12 | from .session import Session
13 |
14 | from .poller import Poller
15 | from .types import SessionID, RouteKey
16 | from ._two_way_dict import TwoWayDict
17 |
18 | WINDOWS = platform.system() == "Windows"
19 |
20 |
21 | log = logging.getLogger("textual-web")
22 |
23 |
24 | if not WINDOWS:
25 | from .terminal_session import TerminalSession
26 |
27 |
28 | class SessionManager:
29 | """Manage sessions (Textual apps or terminals)."""
30 |
31 | def __init__(self, poller: Poller, path: Path, apps: list[config.App]) -> None:
32 | self.poller = poller
33 | self.path = path
34 | self.apps = apps
35 | self.apps_by_slug = {app.slug: app for app in apps}
36 | self.sessions: dict[SessionID, Session] = {}
37 | self.routes: TwoWayDict[RouteKey, SessionID] = TwoWayDict()
38 |
39 | def add_app(
40 | self, name: str, command: str, slug: str, terminal: bool = False
41 | ) -> None:
42 | """Add a new app
43 |
44 | Args:
45 | name: Name of the app.
46 | command: Command to run the app.
47 | slug: Slug used in URL, or blank to auto-generate on server.
48 | """
49 | slug = slug or generate().lower()
50 | new_app = config.App(
51 | name=name, slug=slug, path="./", command=command, terminal=terminal
52 | )
53 | self.apps.append(new_app)
54 | self.apps_by_slug[slug] = new_app
55 |
56 | def on_session_end(self, session_id: SessionID) -> None:
57 | """Called by sessions."""
58 | self.sessions.pop(session_id)
59 | route_key = self.routes.get_key(session_id)
60 | if route_key is not None:
61 | del self.routes[route_key]
62 |
63 | async def close_all(self, timeout: float = 3.0) -> None:
64 | """Close app sessions.
65 |
66 | Args:
67 | timeout: Time (in seconds) to wait before giving up.
68 |
69 | """
70 | sessions = list(self.sessions.values())
71 |
72 | if not sessions:
73 | return
74 | log.info("Closing %s session(s)", len(sessions))
75 |
76 | async def do_close() -> int:
77 | """Close all sessions, return number unclosed after timeout
78 |
79 | Returns:
80 | Number of sessions not yet closed.
81 | """
82 |
83 | async def close_wait(session: Session) -> None:
84 | await session.close()
85 | await session.wait()
86 |
87 | _done, remaining = await asyncio.wait(
88 | [asyncio.create_task(close_wait(session)) for session in sessions],
89 | timeout=timeout,
90 | )
91 | return len(remaining)
92 |
93 | remaining = await do_close()
94 | if remaining:
95 | log.warning("%s session(s) didn't close after %s seconds", timeout)
96 |
97 | async def new_session(
98 | self,
99 | slug: str,
100 | session_id: SessionID,
101 | route_key: RouteKey,
102 | devtools: bool = False,
103 | size: tuple[int, int] = (80, 24),
104 | ) -> Session | None:
105 | """Create a new seession.
106 |
107 | Args:
108 | slug: Slug for app.
109 | session_id: Session identity.
110 | route_key: Route key.
111 | devtools: Enable devtools in Textual apps
112 |
113 | Returns:
114 | New session, or `None` if no app / terminal configured.
115 | """
116 | app = self.apps_by_slug.get(slug)
117 | if app is None:
118 | return None
119 |
120 | session_process: Session
121 | if app.terminal:
122 | if WINDOWS:
123 | log.warn(
124 | "Sorry, textual-web does not currently support terminals on Windows"
125 | )
126 | return None
127 | else:
128 | session_process = TerminalSession(
129 | self.poller,
130 | session_id,
131 | app.command,
132 | )
133 | else:
134 | session_process = AppSession(
135 | self.path,
136 | app.command,
137 | session_id,
138 | devtools=devtools,
139 | )
140 | self.sessions[session_id] = session_process
141 | self.routes[route_key] = session_id
142 |
143 | await session_process.open(*size)
144 |
145 | return session_process
146 |
147 | async def close_session(self, session_id: SessionID) -> None:
148 | """Close a session.
149 |
150 | Args:
151 | session_id: Session identity.
152 | """
153 | session_process = self.sessions.get(session_id, None)
154 | if session_process is None:
155 | return
156 | await session_process.close()
157 |
158 | def get_session(self, session_id: SessionID) -> Session | None:
159 | """Get a session from a session ID.
160 |
161 | Args:
162 | session_id: Session identity.
163 |
164 | Returns:
165 | A session or `None` if it doesn't exist.
166 | """
167 | return self.sessions.get(session_id)
168 |
169 | def get_session_by_route_key(self, route_key: RouteKey) -> Session | None:
170 | """Get a session from a route key.
171 |
172 | Args:
173 | route_key: A route key.
174 |
175 | Returns:
176 | A session or `None` if it doesn't exist.
177 |
178 | """
179 | session_id = self.routes.get(route_key)
180 | if session_id is not None:
181 | return self.sessions.get(session_id)
182 | else:
183 | return None
184 |
--------------------------------------------------------------------------------
/src/textual_web/slugify.py:
--------------------------------------------------------------------------------
1 | import re
2 | import unicodedata
3 |
4 |
5 | def slugify(value: str, allow_unicode=False) -> str:
6 | """
7 | Convert to ASCII if 'allow_unicode' is False. Convert spaces or repeated
8 | dashes to single dashes. Remove characters that aren't alphanumerics,
9 | underscores, or hyphens. Convert to lowercase. Also strip leading and
10 | trailing whitespace, dashes, and underscores.
11 | """
12 | value = str(value)
13 | if allow_unicode:
14 | value = unicodedata.normalize("NFKC", value)
15 | else:
16 | value = (
17 | unicodedata.normalize("NFKD", value)
18 | .encode("ascii", "ignore")
19 | .decode("ascii")
20 | )
21 | value = re.sub(r"[^\w\s-]", "", value.lower())
22 | return re.sub(r"[-\s]+", "-", value).strip("-_")
23 |
--------------------------------------------------------------------------------
/src/textual_web/terminal_session.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import asyncio
4 | import array
5 | import fcntl
6 | import logging
7 | import os
8 | import pty
9 | import signal
10 | import termios
11 |
12 | from importlib_metadata import version
13 | import rich.repr
14 |
15 | from .poller import Poller
16 | from .session import Session, SessionConnector
17 | from .types import Meta, SessionID
18 |
19 | log = logging.getLogger("textual-web")
20 |
21 | @rich.repr.auto
22 | class TerminalSession(Session):
23 | """A session that manages a terminal."""
24 |
25 | def __init__(
26 | self,
27 | poller: Poller,
28 | session_id: SessionID,
29 | command: str,
30 | ) -> None:
31 | self.poller = poller
32 | self.session_id = session_id
33 | self.command = command or os.environ.get("SHELL", "sh")
34 | self.master_fd: int | None = None
35 | self.pid: int | None = None
36 | self._task: asyncio.Task | None = None
37 | super().__init__()
38 |
39 | def __rich_repr__(self) -> rich.repr.Result:
40 | yield "session_id", self.session_id
41 | yield "command", self.command
42 |
43 | async def open(self, width: int = 80, height: int = 24) -> None:
44 | pid, master_fd = pty.fork()
45 | self.pid = pid
46 | self.master_fd = master_fd
47 | if pid == pty.CHILD:
48 | os.environ["TERM_PROGRAM"] = "textual-web"
49 | os.environ["TERM_PROGRAM_VERSION"] = version("textual-web")
50 | argv = [self.command]
51 | try:
52 | os.execlp(argv[0], *argv) ## Exits the app
53 | except Exception:
54 | os._exit(0)
55 | self._set_terminal_size(width, height)
56 |
57 | def _set_terminal_size(self, width: int, height: int) -> None:
58 | buf = array.array("h", [height, width, 0, 0])
59 | assert self.master_fd is not None
60 | fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, buf)
61 |
62 | async def set_terminal_size(self, width: int, height: int) -> None:
63 | self._set_terminal_size(width, height)
64 |
65 | async def start(self, connector: SessionConnector) -> asyncio.Task:
66 | self._connector = connector
67 | assert self.master_fd is not None
68 | assert self._task is None
69 | self._task = self._task = asyncio.create_task(self.run())
70 | return self._task
71 |
72 | async def run(self) -> None:
73 | assert self.master_fd is not None
74 | queue = self.poller.add_file(self.master_fd)
75 | on_data = self._connector.on_data
76 | on_close = self._connector.on_close
77 | try:
78 | while True:
79 | data = await queue.get() or None
80 | if data is None:
81 | break
82 | await on_data(data)
83 | except Exception:
84 | log.exception("error in terminal.run")
85 | finally:
86 | await on_close()
87 | os.close(self.master_fd)
88 | self.poller.remove_file(self.master_fd)
89 | self.master_fd = None
90 |
91 | async def send_bytes(self, data: bytes) -> bool:
92 | if self.master_fd is None:
93 | return False
94 | await self.poller.write(self.master_fd, data)
95 | return True
96 |
97 | async def send_meta(self, data: Meta) -> bool:
98 | return True
99 |
100 | async def close(self) -> None:
101 | if self.pid is not None:
102 | os.kill(self.pid, signal.SIGHUP)
103 |
104 | async def wait(self) -> None:
105 | if self._task is not None:
106 | await self._task
107 |
--------------------------------------------------------------------------------
/src/textual_web/types.py:
--------------------------------------------------------------------------------
1 | from typing import NewType, Union, Dict
2 |
3 |
4 | AppID = NewType("AppID", str)
5 | Meta = Dict[str, Union[str, None, int, bool]]
6 | RouteKey = NewType("RouteKey", str)
7 | SessionID = NewType("SessionID", str)
8 |
--------------------------------------------------------------------------------
/src/textual_web/web.py:
--------------------------------------------------------------------------------
1 | """
2 | An optional web interface to control textual-web
3 |
4 | Note: Currently just a stub.
5 |
6 | """
7 |
8 | import logging
9 |
10 | import asyncio
11 | from aiohttp import web
12 |
13 |
14 | log = logging.getLogger("textual-web")
15 |
16 |
17 | async def run_web_interface(connected_event: asyncio.Event) -> web.Application:
18 | """Run the web interface."""
19 |
20 | async def health_check(request) -> web.Response:
21 | await asyncio.wait_for(connected_event.wait(), 5.0)
22 | return web.Response(text="Hello, world")
23 |
24 | app = web.Application()
25 | app.add_routes([web.get("/health-check/", health_check)])
26 |
27 | runner = web.AppRunner(app)
28 | await runner.setup()
29 | site = web.TCPSite(runner, "0.0.0.0", 8080)
30 | await site.start()
31 | log.info("Web interface started on port 8080")
32 | return app
33 |
--------------------------------------------------------------------------------