commit a0e31d43fdeef3aeadb5e825fd9d7e868f52fcaa Author: Rui Carmo Date: Wed Jan 21 23:53:57 2026 +0000 merge diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..71987c0 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,92 @@ +name: Build and Push Docker Image + +on: + push: + tags: ['v*'] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build: + runs-on: ${{ matrix.runner }} + permissions: + contents: read + packages: write + strategy: + matrix: + include: + - runner: ubuntu-latest + platform: linux/amd64 + suffix: amd64 + - runner: ubuntu-24.04-arm + platform: linux/arm64 + suffix: arm64 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract version from tag + id: version + run: echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT + + - name: Build and push architecture-specific image + uses: docker/build-push-action@v5 + with: + context: . + platforms: ${{ matrix.platform }} + push: true + tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}-${{ matrix.suffix }} + cache-from: type=gha,scope=${{ matrix.suffix }} + cache-to: type=gha,mode=max,scope=${{ matrix.suffix }} + + manifest: + needs: build + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract version from tag + id: version + run: | + version=${GITHUB_REF#refs/tags/v} + echo "version=$version" >> $GITHUB_OUTPUT + echo "major_minor=${version%.*}" >> $GITHUB_OUTPUT + + - name: Create and push multi-arch manifest + run: | + docker manifest create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }} \ + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}-amd64 \ + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}-arm64 + docker manifest push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }} + + docker manifest create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.major_minor }} \ + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}-amd64 \ + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}-arm64 + docker manifest push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.major_minor }} + + docker manifest create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \ + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}-amd64 \ + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}-arm64 + docker manifest push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7c2719e --- /dev/null +++ b/.gitignore @@ -0,0 +1,131 @@ +# macOS +.DS_Store +.AppleDouble +.LSOverride + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Ruff +.ruff_cache/ + +# Translations +*.mo +*.pot + +# Logs +*.log + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# Poetry +# poetry.lock is committed for applications, but can be gitignored for libraries +# Uncomment if this is a library: +# poetry.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Environments +.env +.env.* +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDEs and editors +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pyright +.pyright/ + +# pdm +.pdm.toml +.pdm-python +.pdm-build/ + +# textual-webterm specific +textual.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..38d2d59 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +# Minimal image for serving a web terminal +FROM python:3.12-slim + +# Install minimal dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + make \ + && rm -rf /var/lib/apt/lists/* + +# Install textual-webterm +COPY . /app +WORKDIR /app +RUN make install + +# Expose the default port +EXPOSE 8080 + +# Run the terminal server +ENTRYPOINT ["textual-webterm"] +CMD ["--host", "0.0.0.0", "--port", "8080"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..eeccecd --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Rui Carmo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b3f05b5 --- /dev/null +++ b/Makefile @@ -0,0 +1,31 @@ +.PHONY: help install install-dev lint format test coverage check clean + +PYTHON ?= python3 +PIP ?= $(PYTHON) -m pip + +help: + @echo "Targets: install install-dev lint format test coverage check clean" + +install: + $(PIP) install -e . + +install-dev: + $(PIP) install -e . + $(PIP) install pytest pytest-asyncio pytest-cov pytest-timeout ruff + +lint: + ruff check src tests + +format: + ruff format src tests + +test: + pytest + +coverage: + pytest --cov=src/textual_webterm --cov-report=term-missing + +check: lint coverage + +clean: + rm -rf .pytest_cache .coverage htmlcov .ruff_cache diff --git a/README.md b/README.md new file mode 100644 index 0000000..d834c73 --- /dev/null +++ b/README.md @@ -0,0 +1,163 @@ +# textual-webterm + +![Icon](docs/icon-256.png) + +Serve terminal sessions and Textual apps over the web with a simple CLI command. + +This is heavily based on [textual-web](https://github.com/Textualize/textual-web), but specifically focused on serving a persistent terminal session in a way that you can host behind a reverse proxy (and some form of authentication). + +Built on top of [textual-serve](https://github.com/Textualize/textual-serve), this package provides an easy way to expose terminal sessions and Textual applications via HTTP/WebSocket with automatic reconnection support. + +## Features + +- ๐Ÿ–ฅ๏ธ **Web-based terminal** - Access your terminal from any browser +- ๐Ÿ **Textual app support** - Serve Textual apps directly from Python modules +- ๐Ÿ”„ **Session reconnection** - Refresh the page and reconnect to the same session +- ๐ŸŽจ **Full terminal emulation** - Colors, cursor, and ANSI codes work correctly +- ๐Ÿ“ **Auto-sizing** - Terminal automatically resizes to fit the browser window +- ๐Ÿš€ **Simple CLI** - One command to start serving + +## Non-Features + +- **No Authentication** - this is meant to be used inside a dedicated container, and you should set up an authenticating reverse proxy like `authelia` +- **No Encryption (TLS/HTTPS)** - again, this is meant to be fronted by something like `traefik` or `caddy` + +## Quick Start + +### Serve a Terminal + +Serve your default shell: + +```bash +textual-webterm +``` + +Serve a specific command: + +```bash +textual-webterm htop +``` + +### Serve a Textual App + +Serve a Textual app from an installed module: + +```bash +textual-webterm --app mypackage.mymodule:MyApp +``` + +Serve a Textual app from a Python file: + +```bash +textual-webterm --app ./calculator.py:CalculatorApp +``` + +### Options + +Specify host and port: + +```bash +textual-webterm --host 0.0.0.0 --port 8080 bash +``` + +Then open http://localhost:8080 in your browser. + +## Landing pages + +You can serve a landing page with multiple terminal tiles driven by a YAML manifest: + +```yaml +- name: My Service + slug: my-service + command: docker logs -f my-service +``` + +Run with: + +```bash +textual-webterm --landing-manifest landing.yaml +``` + +You can also point to a docker-compose file; services with the label `webterm-command` +become tiles. For example: + +```yaml +services: + db: + image: postgres + labels: + webterm-command: docker exec -it db psql +``` + +Start with: + +```bash +textual-webterm --compose-manifest compose.yaml +``` + +When a landing manifest is provided, the root (`/`) shows the grid; clicking a tile +opens a dedicated terminal session in a new tab. Without a manifest, the server +operates in single-terminal mode. + +## CLI Reference + +``` +Usage: textual-webterm [OPTIONS] [COMMAND] + + Serve a terminal or Textual app over HTTP/WebSocket. + + COMMAND: Shell command to run in terminal (default: $SHELL) + +Options: + -H, --host TEXT Host to bind to [default: 0.0.0.0] + -p, --port INTEGER Port to bind to [default: 8080] + -a, --app TEXT Load a Textual app from module:ClassName + Examples: 'mymodule:MyApp' or './app.py:MyApp' + -L, --landing-manifest PATH YAML manifest describing landing page tiles + (slug/name/command). + -C, --compose-manifest PATH Docker compose YAML; services with label + "webterm-command" become landing tiles. + --version Show the version and exit. + --help Show this message and exit. +``` + +## Development + +### Setup (Makefile-first) + +```bash +git clone https://github.com/rcarmo/textual-webterm.git +cd textual-webterm + +# Install with dev dependencies via Makefile +make install-dev +``` + +### Common tasks (use Makefile) + +- Lint: `make lint` +- Format: `make format` +- Tests: `make test` +- Coverage (fail_under=80): `make coverage` +- Full check (lint + coverage): `make check` + +### Notes + +- WebSocket protocol (browser <-> server) is JSON: `["stdin", data]`, `["resize", {"width": w, "height": h}]`, `["ping", data]`. +- Static assets are provided by `textual-serve`; this project does not add custom static files. +- `/screenshot.svg` replays the terminal buffer to SVG via Rich; width can be set with `?width=120` and is clamped for safety. + +## Requirements + +- Python 3.9+ +- Linux or macOS + +## License + +MIT License - see [LICENSE](LICENSE) for details. + +## Related Projects + +- [Textual](https://github.com/Textualize/textual) - TUI framework for Python +- [textual-serve](https://github.com/Textualize/textual-serve) - Serve Textual apps on the web +- [Rich](https://github.com/Textualize/rich) - Rich text formatting for terminals diff --git a/REFACTORING.md b/REFACTORING.md new file mode 100644 index 0000000..a66d3c4 --- /dev/null +++ b/REFACTORING.md @@ -0,0 +1,40 @@ +# Refactoring & Audit Checklist + +## Tooling +- [x] Makefile restored (install, lint, format, test, coverage, check) +- [x] Coverage gate at fail_under=80 (current ~88%) + +## Dead Code Removal +- [x] Removed packets/environment protocol code +- [x] Removed unused Account model +- [x] Removed retry.py and related tests + +## LocalServer / Protocol +- [x] JSON WS protocol enforced (stdin/resize/ping/pong) +- [x] Static assets delegated to textual-serve +- [x] /screenshot.svg renders replay buffer to SVG +- [x] Disconnect triggers resize to 132x45 +- [ ] Narrow WebSocket error handling (avoid bare Exception) +- [ ] Consider TaskGroup/cleanup context for aiohttp runner + +## Sessions +- [x] Session.is_running() added +- [x] AppSession double JSON parse fixed; payload capped (16MB) +- [x] TerminalSession replay buffer; resize on disconnect +- [ ] TerminalSession set_terminal_size is blocking; consider run_in_executor + +## Poller +- [x] OSError-only read handling; write error handling added +- [x] TwoWayDict enforces 1:1 mapping (raises on duplicate value) + +## CLI / Config +- [x] File-path detection helper deduped +- [x] Config uses tomllib (py311+) + +## Tests +- [x] 135 tests passing; coverage ~88% + +## Remaining Ideas (Low Priority) +- [ ] Consolidate WS dispatch table +- [ ] Simplify _get_ws_url_from_request +- [ ] Normalize logging style (f-string vs %%) diff --git a/docs/icon-256.png b/docs/icon-256.png new file mode 100644 index 0000000..c6bfc57 Binary files /dev/null and b/docs/icon-256.png differ diff --git a/docs/icon.png b/docs/icon.png new file mode 100644 index 0000000..c3e8a50 Binary files /dev/null and b/docs/icon.png differ diff --git a/examples/calculator.css b/examples/calculator.css new file mode 100644 index 0000000..9b3f6ea --- /dev/null +++ b/examples/calculator.css @@ -0,0 +1,33 @@ +Screen { + overflow: auto; +} + +#calculator { + layout: grid; + grid-size: 4; + grid-gutter: 1 2; + grid-columns: 1fr; + grid-rows: 2fr 1fr 1fr 1fr 1fr 1fr; + margin: 1 2; + min-height: 25; + min-width: 26; + height: 100%; +} + +Button { + width: 100%; + height: 100%; +} + +#numbers { + column-span: 4; + content-align: right middle; + padding: 0 1; + height: 100%; + background: $primary-lighten-2; + color: $text; +} + +#number-0 { + column-span: 2; +} diff --git a/examples/calculator.py b/examples/calculator.py new file mode 100644 index 0000000..3e421f3 --- /dev/null +++ b/examples/calculator.py @@ -0,0 +1,145 @@ +from contextlib import suppress +from decimal import Decimal +from typing import ClassVar + +from textual import events +from textual.app import App, ComposeResult +from textual.containers import Container +from textual.css.query import NoMatches +from textual.reactive import var +from textual.widgets import Button, Static + + +class CalculatorApp(App): + """A working 'desktop' calculator.""" + + CSS_PATH = "calculator.css" + + numbers = var("0") + show_ac = var(True) + left = var(Decimal("0")) + right = var(Decimal("0")) + value = var("") + operator = var("plus") + + NAME_MAP: ClassVar = { + "asterisk": "multiply", + "slash": "divide", + "underscore": "plus-minus", + "full_stop": "point", + "plus_minus_sign": "plus-minus", + "percent_sign": "percent", + "equals_sign": "equals", + "minus": "minus", + "plus": "plus", + } + + def watch_numbers(self, value: str) -> None: + """Called when numbers is updated.""" + # Update the Numbers widget + self.query_one("#numbers", Static).update(value) + + def compute_show_ac(self) -> bool: + """Compute switch to show AC or C button""" + return self.value in ("", "0") and self.numbers == "0" + + def watch_show_ac(self, show_ac: bool) -> None: + """Called when show_ac changes.""" + self.query_one("#c").display = not show_ac + self.query_one("#ac").display = show_ac + + def compose(self) -> ComposeResult: + """Add our buttons.""" + with Container(id="calculator"): + yield Static(id="numbers") + yield Button("AC", id="ac", variant="primary") + yield Button("C", id="c", variant="primary") + yield Button("+/-", id="plus-minus", variant="primary") + yield Button("%", id="percent", variant="primary") + yield Button("รท", id="divide", variant="warning") + yield Button("7", id="number-7") + yield Button("8", id="number-8") + yield Button("9", id="number-9") + yield Button("x", id="multiply", variant="warning") + yield Button("4", id="number-4") + yield Button("5", id="number-5") + yield Button("6", id="number-6") + yield Button("-", id="minus", variant="warning") + yield Button("1", id="number-1") + yield Button("2", id="number-2") + yield Button("3", id="number-3") + yield Button("+", id="plus", variant="warning") + yield Button("0", id="number-0") + yield Button(".", id="point") + yield Button("=", id="equals", variant="warning") + + def on_key(self, event: events.Key) -> None: + """Called when the user presses a key.""" + + def press(button_id: str) -> None: + with suppress(NoMatches): + self.query_one(f"#{button_id}", Button).press() + + key = event.key + if key.isdecimal(): + press(f"number-{key}") + elif key == "c": + press("c") + press("ac") + else: + button_id = self.NAME_MAP.get(key) + if button_id is not None: + press(self.NAME_MAP.get(key, key)) + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Called when a button is pressed.""" + + button_id = event.button.id + assert button_id is not None + + def do_math() -> None: + """Does the math: LEFT OPERATOR RIGHT""" + try: + if self.operator == "plus": + self.left += self.right + elif self.operator == "minus": + self.left -= self.right + elif self.operator == "divide": + self.left /= self.right + elif self.operator == "multiply": + self.left *= self.right + self.numbers = str(self.left) + self.value = "" + except Exception: + self.numbers = "Error" + + if button_id.startswith("number-"): + number = button_id.partition("-")[-1] + self.numbers = self.value = self.value.lstrip("0") + number + elif button_id == "plus-minus": + self.numbers = self.value = str(Decimal(self.value or "0") * -1) + elif button_id == "percent": + self.numbers = self.value = str(Decimal(self.value or "0") / Decimal(100)) + elif button_id == "point": + if "." not in self.value: + self.numbers = self.value = (self.value or "0") + "." + elif button_id == "ac": + self.value = "" + self.left = self.right = Decimal(0) + self.operator = "plus" + self.numbers = "0" + elif button_id == "c": + self.value = "" + self.numbers = "0" + elif button_id in ("plus", "minus", "divide", "multiply"): + self.right = Decimal(self.value or "0") + do_math() + self.operator = button_id + elif button_id == "equals": + if self.value: + self.right = Decimal(self.value) + do_math() + + +if __name__ == "__main__": + CalculatorApp().run() diff --git a/examples/download.py b/examples/download.py new file mode 100644 index 0000000..4397e49 --- /dev/null +++ b/examples/download.py @@ -0,0 +1,74 @@ +import io +from pathlib import Path + +from textual import on +from textual.app import App, ComposeResult +from textual.events import DeliveryComplete +from textual.widgets import Button, Input, Label + + +class ScreenshotApp(App[None]): + def compose(self) -> ComposeResult: + yield Button("screenshot: no filename or mime", id="button-1") + yield Button("screenshot: screenshot.svg / open in browser", id="button-2") + yield Button("screenshot: screenshot.svg / download", id="button-3") + yield Button( + "screenshot: screenshot.svg / open in browser / plaintext mime", + id="button-4", + ) + yield Label("Deliver custom file:") + yield Input(id="custom-path-input", placeholder="Path to file...") + + @on(Button.Pressed, selector="#button-1") + def on_button_pressed(self) -> None: + screenshot_string = self.export_screenshot() + string_io = io.StringIO(screenshot_string) + self.deliver_text(string_io) + + @on(Button.Pressed, selector="#button-2") + def on_button_pressed_2(self) -> None: + screenshot_string = self.export_screenshot() + string_io = io.StringIO(screenshot_string) + self.deliver_text( + string_io, save_filename="screenshot.svg", open_method="browser" + ) + + @on(Button.Pressed, selector="#button-3") + def on_button_pressed_3(self) -> None: + screenshot_string = self.export_screenshot() + string_io = io.StringIO(screenshot_string) + self.deliver_text( + string_io, save_filename="screenshot.svg", open_method="download" + ) + + @on(Button.Pressed, selector="#button-4") + def on_button_pressed_4(self) -> None: + screenshot_string = self.export_screenshot() + string_io = io.StringIO(screenshot_string) + self.deliver_text( + string_io, + save_filename="screenshot.svg", + open_method="browser", + mime_type="text/plain", + ) + + @on(DeliveryComplete) + def on_delivery_complete(self, event: DeliveryComplete) -> None: + self.notify(title="Download complete", message=event.key) + + @on(Input.Submitted) + def on_input_submitted(self, event: Input.Submitted) -> None: + path = Path(event.value) + if path.exists(): + self.deliver_binary(path) + else: + self.notify( + title="Invalid path", + message="The path does not exist.", + severity="error", + ) + + +app = ScreenshotApp() +if __name__ == "__main__": + app.run() diff --git a/examples/env.py b/examples/env.py new file mode 100644 index 0000000..e148ea0 --- /dev/null +++ b/examples/env.py @@ -0,0 +1,13 @@ +import os + +from textual.app import App, ComposeResult +from textual.widgets import Pretty + + +class TerminalEnv(App): + def compose(self) -> ComposeResult: + yield Pretty(dict(os.environ)) + + +if __name__ == "__main__": + TerminalEnv().run() diff --git a/examples/ganglion.toml b/examples/ganglion.toml new file mode 100644 index 0000000..f15619b --- /dev/null +++ b/examples/ganglion.toml @@ -0,0 +1,47 @@ +[account] + +[app.Calculator] +path = "./" +command = "python calculator.py" + + +[app.Easing] +slug = "easing" +path = "./" +command = "textual easing" + + +[app.Keys] +slug = "keys" +path = "./" +command = "textual keys" + + +[app.Borders] +slug = "borders" +path = "./" +command = "textual borders" + + +[app.Demo] +name = "Demo" +slug = "demo" +path = "./" +command = "python -m textual" + +[terminal.Terminal] +name = "Terminal" +path = "./" +terminal = true + +[app.OpenLink] +name = "Open Link" +slug = "open-link" +path = "./" +command = "python open_link.py" + +[app.Download] +name = "Download" +slug = "download" +path = "./" +command = "python download.py" \ No newline at end of file diff --git a/examples/open_link.py b/examples/open_link.py new file mode 100644 index 0000000..03510f2 --- /dev/null +++ b/examples/open_link.py @@ -0,0 +1,24 @@ +from textual import on +from textual.app import App, ComposeResult +from textual.widgets import Button + + +class OpenLink(App[None]): + """Demonstrates opening a URL in the same tab or a new tab.""" + + def compose(self) -> ComposeResult: + yield Button("Visit the Textual docs", id="open-link-same-tab") + yield Button("Visit the Textual docs in a new tab", id="open-link-new-tab") + + @on(Button.Pressed) + def open_link(self, event: Button.Pressed) -> None: + """Open the URL in the same tab or a new tab depending on which button was pressed.""" + self.open_url( + "https://textual.textualize.io", + new_tab=event.button.id == "open-link-new-tab", + ) + + +app = OpenLink() +if __name__ == "__main__": + app.run() diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..2291fc0 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,1168 @@ +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. + +[[package]] +name = "aiohappyeyeballs" +version = "2.4.0" +description = "Happy Eyeballs for asyncio" +optional = false +python-versions = ">=3.8" +files = [ + {file = "aiohappyeyeballs-2.4.0-py3-none-any.whl", hash = "sha256:7ce92076e249169a13c2f49320d1967425eaf1f407522d707d59cac7628d62bd"}, + {file = "aiohappyeyeballs-2.4.0.tar.gz", hash = "sha256:55a1714f084e63d49639800f95716da97a1f173d46a16dfcfda0016abb93b6b2"}, +] + +[[package]] +name = "aiohttp" +version = "3.10.5" +description = "Async http client/server framework (asyncio)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "aiohttp-3.10.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:18a01eba2574fb9edd5f6e5fb25f66e6ce061da5dab5db75e13fe1558142e0a3"}, + {file = "aiohttp-3.10.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:94fac7c6e77ccb1ca91e9eb4cb0ac0270b9fb9b289738654120ba8cebb1189c6"}, + {file = "aiohttp-3.10.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2f1f1c75c395991ce9c94d3e4aa96e5c59c8356a15b1c9231e783865e2772699"}, + {file = "aiohttp-3.10.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f7acae3cf1a2a2361ec4c8e787eaaa86a94171d2417aae53c0cca6ca3118ff6"}, + {file = "aiohttp-3.10.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:94c4381ffba9cc508b37d2e536b418d5ea9cfdc2848b9a7fea6aebad4ec6aac1"}, + {file = "aiohttp-3.10.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c31ad0c0c507894e3eaa843415841995bf8de4d6b2d24c6e33099f4bc9fc0d4f"}, + {file = "aiohttp-3.10.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0912b8a8fadeb32ff67a3ed44249448c20148397c1ed905d5dac185b4ca547bb"}, + {file = "aiohttp-3.10.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d93400c18596b7dc4794d48a63fb361b01a0d8eb39f28800dc900c8fbdaca91"}, + {file = "aiohttp-3.10.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3c5e0d764a5c9aa5a62d99728c56d455310bcc288a79cab10157b3af426f"}, + {file = "aiohttp-3.10.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:d742c36ed44f2798c8d3f4bc511f479b9ceef2b93f348671184139e7d708042c"}, + {file = "aiohttp-3.10.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:814375093edae5f1cb31e3407997cf3eacefb9010f96df10d64829362ae2df69"}, + {file = "aiohttp-3.10.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8224f98be68a84b19f48e0bdc14224b5a71339aff3a27df69989fa47d01296f3"}, + {file = "aiohttp-3.10.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d9a487ef090aea982d748b1b0d74fe7c3950b109df967630a20584f9a99c0683"}, + {file = "aiohttp-3.10.5-cp310-cp310-win32.whl", hash = "sha256:d9ef084e3dc690ad50137cc05831c52b6ca428096e6deb3c43e95827f531d5ef"}, + {file = "aiohttp-3.10.5-cp310-cp310-win_amd64.whl", hash = "sha256:66bf9234e08fe561dccd62083bf67400bdbf1c67ba9efdc3dac03650e97c6088"}, + {file = "aiohttp-3.10.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8c6a4e5e40156d72a40241a25cc226051c0a8d816610097a8e8f517aeacd59a2"}, + {file = "aiohttp-3.10.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2c634a3207a5445be65536d38c13791904fda0748b9eabf908d3fe86a52941cf"}, + {file = "aiohttp-3.10.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4aff049b5e629ef9b3e9e617fa6e2dfeda1bf87e01bcfecaf3949af9e210105e"}, + {file = "aiohttp-3.10.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1942244f00baaacaa8155eca94dbd9e8cc7017deb69b75ef67c78e89fdad3c77"}, + {file = "aiohttp-3.10.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e04a1f2a65ad2f93aa20f9ff9f1b672bf912413e5547f60749fa2ef8a644e061"}, + {file = "aiohttp-3.10.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7f2bfc0032a00405d4af2ba27f3c429e851d04fad1e5ceee4080a1c570476697"}, + {file = "aiohttp-3.10.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:424ae21498790e12eb759040bbb504e5e280cab64693d14775c54269fd1d2bb7"}, + {file = "aiohttp-3.10.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:975218eee0e6d24eb336d0328c768ebc5d617609affaca5dbbd6dd1984f16ed0"}, + {file = "aiohttp-3.10.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4120d7fefa1e2d8fb6f650b11489710091788de554e2b6f8347c7a20ceb003f5"}, + {file = "aiohttp-3.10.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b90078989ef3fc45cf9221d3859acd1108af7560c52397ff4ace8ad7052a132e"}, + {file = "aiohttp-3.10.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ba5a8b74c2a8af7d862399cdedce1533642fa727def0b8c3e3e02fcb52dca1b1"}, + {file = "aiohttp-3.10.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:02594361128f780eecc2a29939d9dfc870e17b45178a867bf61a11b2a4367277"}, + {file = "aiohttp-3.10.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8fb4fc029e135859f533025bc82047334e24b0d489e75513144f25408ecaf058"}, + {file = "aiohttp-3.10.5-cp311-cp311-win32.whl", hash = "sha256:e1ca1ef5ba129718a8fc827b0867f6aa4e893c56eb00003b7367f8a733a9b072"}, + {file = "aiohttp-3.10.5-cp311-cp311-win_amd64.whl", hash = "sha256:349ef8a73a7c5665cca65c88ab24abe75447e28aa3bc4c93ea5093474dfdf0ff"}, + {file = "aiohttp-3.10.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:305be5ff2081fa1d283a76113b8df7a14c10d75602a38d9f012935df20731487"}, + {file = "aiohttp-3.10.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3a1c32a19ee6bbde02f1cb189e13a71b321256cc1d431196a9f824050b160d5a"}, + {file = "aiohttp-3.10.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:61645818edd40cc6f455b851277a21bf420ce347baa0b86eaa41d51ef58ba23d"}, + {file = "aiohttp-3.10.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c225286f2b13bab5987425558baa5cbdb2bc925b2998038fa028245ef421e75"}, + {file = "aiohttp-3.10.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8ba01ebc6175e1e6b7275c907a3a36be48a2d487549b656aa90c8a910d9f3178"}, + {file = "aiohttp-3.10.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8eaf44ccbc4e35762683078b72bf293f476561d8b68ec8a64f98cf32811c323e"}, + {file = "aiohttp-3.10.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1c43eb1ab7cbf411b8e387dc169acb31f0ca0d8c09ba63f9eac67829585b44f"}, + {file = "aiohttp-3.10.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:de7a5299827253023c55ea549444e058c0eb496931fa05d693b95140a947cb73"}, + {file = "aiohttp-3.10.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4790f0e15f00058f7599dab2b206d3049d7ac464dc2e5eae0e93fa18aee9e7bf"}, + {file = "aiohttp-3.10.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:44b324a6b8376a23e6ba25d368726ee3bc281e6ab306db80b5819999c737d820"}, + {file = "aiohttp-3.10.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0d277cfb304118079e7044aad0b76685d30ecb86f83a0711fc5fb257ffe832ca"}, + {file = "aiohttp-3.10.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:54d9ddea424cd19d3ff6128601a4a4d23d54a421f9b4c0fff740505813739a91"}, + {file = "aiohttp-3.10.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4f1c9866ccf48a6df2b06823e6ae80573529f2af3a0992ec4fe75b1a510df8a6"}, + {file = "aiohttp-3.10.5-cp312-cp312-win32.whl", hash = "sha256:dc4826823121783dccc0871e3f405417ac116055bf184ac04c36f98b75aacd12"}, + {file = "aiohttp-3.10.5-cp312-cp312-win_amd64.whl", hash = "sha256:22c0a23a3b3138a6bf76fc553789cb1a703836da86b0f306b6f0dc1617398abc"}, + {file = "aiohttp-3.10.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7f6b639c36734eaa80a6c152a238242bedcee9b953f23bb887e9102976343092"}, + {file = "aiohttp-3.10.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f29930bc2921cef955ba39a3ff87d2c4398a0394ae217f41cb02d5c26c8b1b77"}, + {file = "aiohttp-3.10.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f489a2c9e6455d87eabf907ac0b7d230a9786be43fbe884ad184ddf9e9c1e385"}, + {file = "aiohttp-3.10.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:123dd5b16b75b2962d0fff566effb7a065e33cd4538c1692fb31c3bda2bfb972"}, + {file = "aiohttp-3.10.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b98e698dc34966e5976e10bbca6d26d6724e6bdea853c7c10162a3235aba6e16"}, + {file = "aiohttp-3.10.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3b9162bab7e42f21243effc822652dc5bb5e8ff42a4eb62fe7782bcbcdfacf6"}, + {file = "aiohttp-3.10.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1923a5c44061bffd5eebeef58cecf68096e35003907d8201a4d0d6f6e387ccaa"}, + {file = "aiohttp-3.10.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d55f011da0a843c3d3df2c2cf4e537b8070a419f891c930245f05d329c4b0689"}, + {file = "aiohttp-3.10.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:afe16a84498441d05e9189a15900640a2d2b5e76cf4efe8cbb088ab4f112ee57"}, + {file = "aiohttp-3.10.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f8112fb501b1e0567a1251a2fd0747baae60a4ab325a871e975b7bb67e59221f"}, + {file = "aiohttp-3.10.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1e72589da4c90337837fdfe2026ae1952c0f4a6e793adbbfbdd40efed7c63599"}, + {file = "aiohttp-3.10.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:4d46c7b4173415d8e583045fbc4daa48b40e31b19ce595b8d92cf639396c15d5"}, + {file = "aiohttp-3.10.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:33e6bc4bab477c772a541f76cd91e11ccb6d2efa2b8d7d7883591dfb523e5987"}, + {file = "aiohttp-3.10.5-cp313-cp313-win32.whl", hash = "sha256:c58c6837a2c2a7cf3133983e64173aec11f9c2cd8e87ec2fdc16ce727bcf1a04"}, + {file = "aiohttp-3.10.5-cp313-cp313-win_amd64.whl", hash = "sha256:38172a70005252b6893088c0f5e8a47d173df7cc2b2bd88650957eb84fcf5022"}, + {file = "aiohttp-3.10.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:f6f18898ace4bcd2d41a122916475344a87f1dfdec626ecde9ee802a711bc569"}, + {file = "aiohttp-3.10.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5ede29d91a40ba22ac1b922ef510aab871652f6c88ef60b9dcdf773c6d32ad7a"}, + {file = "aiohttp-3.10.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:673f988370f5954df96cc31fd99c7312a3af0a97f09e407399f61583f30da9bc"}, + {file = "aiohttp-3.10.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58718e181c56a3c02d25b09d4115eb02aafe1a732ce5714ab70326d9776457c3"}, + {file = "aiohttp-3.10.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b38b1570242fbab8d86a84128fb5b5234a2f70c2e32f3070143a6d94bc854cf"}, + {file = "aiohttp-3.10.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:074d1bff0163e107e97bd48cad9f928fa5a3eb4b9d33366137ffce08a63e37fe"}, + {file = "aiohttp-3.10.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd31f176429cecbc1ba499d4aba31aaccfea488f418d60376b911269d3b883c5"}, + {file = "aiohttp-3.10.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7384d0b87d4635ec38db9263e6a3f1eb609e2e06087f0aa7f63b76833737b471"}, + {file = "aiohttp-3.10.5-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:8989f46f3d7ef79585e98fa991e6ded55d2f48ae56d2c9fa5e491a6e4effb589"}, + {file = "aiohttp-3.10.5-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:c83f7a107abb89a227d6c454c613e7606c12a42b9a4ca9c5d7dad25d47c776ae"}, + {file = "aiohttp-3.10.5-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cde98f323d6bf161041e7627a5fd763f9fd829bcfcd089804a5fdce7bb6e1b7d"}, + {file = "aiohttp-3.10.5-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:676f94c5480d8eefd97c0c7e3953315e4d8c2b71f3b49539beb2aa676c58272f"}, + {file = "aiohttp-3.10.5-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:2d21ac12dc943c68135ff858c3a989f2194a709e6e10b4c8977d7fcd67dfd511"}, + {file = "aiohttp-3.10.5-cp38-cp38-win32.whl", hash = "sha256:17e997105bd1a260850272bfb50e2a328e029c941c2708170d9d978d5a30ad9a"}, + {file = "aiohttp-3.10.5-cp38-cp38-win_amd64.whl", hash = "sha256:1c19de68896747a2aa6257ae4cf6ef59d73917a36a35ee9d0a6f48cff0f94db8"}, + {file = "aiohttp-3.10.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7e2fe37ac654032db1f3499fe56e77190282534810e2a8e833141a021faaab0e"}, + {file = "aiohttp-3.10.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f5bf3ead3cb66ab990ee2561373b009db5bc0e857549b6c9ba84b20bc462e172"}, + {file = "aiohttp-3.10.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1b2c16a919d936ca87a3c5f0e43af12a89a3ce7ccbce59a2d6784caba945b68b"}, + {file = "aiohttp-3.10.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad146dae5977c4dd435eb31373b3fe9b0b1bf26858c6fc452bf6af394067e10b"}, + {file = "aiohttp-3.10.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8c5c6fa16412b35999320f5c9690c0f554392dc222c04e559217e0f9ae244b92"}, + {file = "aiohttp-3.10.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:95c4dc6f61d610bc0ee1edc6f29d993f10febfe5b76bb470b486d90bbece6b22"}, + {file = "aiohttp-3.10.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da452c2c322e9ce0cfef392e469a26d63d42860f829026a63374fde6b5c5876f"}, + {file = "aiohttp-3.10.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:898715cf566ec2869d5cb4d5fb4be408964704c46c96b4be267442d265390f32"}, + {file = "aiohttp-3.10.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:391cc3a9c1527e424c6865e087897e766a917f15dddb360174a70467572ac6ce"}, + {file = "aiohttp-3.10.5-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:380f926b51b92d02a34119d072f178d80bbda334d1a7e10fa22d467a66e494db"}, + {file = "aiohttp-3.10.5-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ce91db90dbf37bb6fa0997f26574107e1b9d5ff939315247b7e615baa8ec313b"}, + {file = "aiohttp-3.10.5-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:9093a81e18c45227eebe4c16124ebf3e0d893830c6aca7cc310bfca8fe59d857"}, + {file = "aiohttp-3.10.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ee40b40aa753d844162dcc80d0fe256b87cba48ca0054f64e68000453caead11"}, + {file = "aiohttp-3.10.5-cp39-cp39-win32.whl", hash = "sha256:03f2645adbe17f274444953bdea69f8327e9d278d961d85657cb0d06864814c1"}, + {file = "aiohttp-3.10.5-cp39-cp39-win_amd64.whl", hash = "sha256:d17920f18e6ee090bdd3d0bfffd769d9f2cb4c8ffde3eb203777a3895c128862"}, + {file = "aiohttp-3.10.5.tar.gz", hash = "sha256:f071854b47d39591ce9a17981c46790acb30518e2f83dfca8db2dfa091178691"}, +] + +[package.dependencies] +aiohappyeyeballs = ">=2.3.0" +aiosignal = ">=1.1.2" +async-timeout = {version = ">=4.0,<5.0", markers = "python_version < \"3.11\""} +attrs = ">=17.3.0" +frozenlist = ">=1.1.1" +multidict = ">=4.5,<7.0" +yarl = ">=1.0,<2.0" + +[package.extras] +speedups = ["Brotli", "aiodns (>=3.2.0)", "brotlicffi"] + +[[package]] +name = "aiohttp-jinja2" +version = "1.6" +description = "jinja2 template renderer for aiohttp.web (http server for asyncio)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "aiohttp-jinja2-1.6.tar.gz", hash = "sha256:a3a7ff5264e5bca52e8ae547bbfd0761b72495230d438d05b6c0915be619b0e2"}, + {file = "aiohttp_jinja2-1.6-py3-none-any.whl", hash = "sha256:0df405ee6ad1b58e5a068a105407dc7dcc1704544c559f1938babde954f945c7"}, +] + +[package.dependencies] +aiohttp = ">=3.9.0" +jinja2 = ">=3.0.0" + +[[package]] +name = "aiosignal" +version = "1.3.1" +description = "aiosignal: a list of registered asynchronous callbacks" +optional = false +python-versions = ">=3.7" +files = [ + {file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"}, + {file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"}, +] + +[package.dependencies] +frozenlist = ">=1.1.0" + +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.9\""} + +[[package]] +name = "anyio" +version = "4.4.0" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +optional = false +python-versions = ">=3.8" +files = [ + {file = "anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7"}, + {file = "anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +sniffio = ">=1.1" +typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} + +[package.extras] +doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] +trio = ["trio (>=0.23)"] + +[[package]] +name = "async-timeout" +version = "4.0.3" +description = "Timeout context manager for asyncio programs" +optional = false +python-versions = ">=3.7" +files = [ + {file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"}, + {file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"}, +] + +[[package]] +name = "attrs" +version = "24.2.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.7" +files = [ + {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"}, + {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, +] + +[package.extras] +benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] + +[[package]] +name = "certifi" +version = "2024.7.4" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, + {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, +] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, + {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "frozenlist" +version = "1.4.1" +description = "A list-like structure which implements collections.abc.MutableSequence" +optional = false +python-versions = ">=3.8" +files = [ + {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f9aa1878d1083b276b0196f2dfbe00c9b7e752475ed3b682025ff20c1c1f51ac"}, + {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29acab3f66f0f24674b7dc4736477bcd4bc3ad4b896f5f45379a67bce8b96868"}, + {file = "frozenlist-1.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:74fb4bee6880b529a0c6560885fce4dc95936920f9f20f53d99a213f7bf66776"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:590344787a90ae57d62511dd7c736ed56b428f04cd8c161fcc5e7232c130c69a"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:068b63f23b17df8569b7fdca5517edef76171cf3897eb68beb01341131fbd2ad"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c849d495bf5154cd8da18a9eb15db127d4dba2968d88831aff6f0331ea9bd4c"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9750cc7fe1ae3b1611bb8cfc3f9ec11d532244235d75901fb6b8e42ce9229dfe"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9b2de4cf0cdd5bd2dee4c4f63a653c61d2408055ab77b151c1957f221cabf2a"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0633c8d5337cb5c77acbccc6357ac49a1770b8c487e5b3505c57b949b4b82e98"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:27657df69e8801be6c3638054e202a135c7f299267f1a55ed3a598934f6c0d75"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:f9a3ea26252bd92f570600098783d1371354d89d5f6b7dfd87359d669f2109b5"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:4f57dab5fe3407b6c0c1cc907ac98e8a189f9e418f3b6e54d65a718aaafe3950"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e02a0e11cf6597299b9f3bbd3f93d79217cb90cfd1411aec33848b13f5c656cc"}, + {file = "frozenlist-1.4.1-cp310-cp310-win32.whl", hash = "sha256:a828c57f00f729620a442881cc60e57cfcec6842ba38e1b19fd3e47ac0ff8dc1"}, + {file = "frozenlist-1.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:f56e2333dda1fe0f909e7cc59f021eba0d2307bc6f012a1ccf2beca6ba362439"}, + {file = "frozenlist-1.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a0cb6f11204443f27a1628b0e460f37fb30f624be6051d490fa7d7e26d4af3d0"}, + {file = "frozenlist-1.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b46c8ae3a8f1f41a0d2ef350c0b6e65822d80772fe46b653ab6b6274f61d4a49"}, + {file = "frozenlist-1.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fde5bd59ab5357e3853313127f4d3565fc7dad314a74d7b5d43c22c6a5ed2ced"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:722e1124aec435320ae01ee3ac7bec11a5d47f25d0ed6328f2273d287bc3abb0"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2471c201b70d58a0f0c1f91261542a03d9a5e088ed3dc6c160d614c01649c106"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c757a9dd70d72b076d6f68efdbb9bc943665ae954dad2801b874c8c69e185068"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f146e0911cb2f1da549fc58fc7bcd2b836a44b79ef871980d605ec392ff6b0d2"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f9c515e7914626b2a2e1e311794b4c35720a0be87af52b79ff8e1429fc25f19"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c302220494f5c1ebeb0912ea782bcd5e2f8308037b3c7553fad0e48ebad6ad82"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:442acde1e068288a4ba7acfe05f5f343e19fac87bfc96d89eb886b0363e977ec"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:1b280e6507ea8a4fa0c0a7150b4e526a8d113989e28eaaef946cc77ffd7efc0a"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:fe1a06da377e3a1062ae5fe0926e12b84eceb8a50b350ddca72dc85015873f74"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:db9e724bebd621d9beca794f2a4ff1d26eed5965b004a97f1f1685a173b869c2"}, + {file = "frozenlist-1.4.1-cp311-cp311-win32.whl", hash = "sha256:e774d53b1a477a67838a904131c4b0eef6b3d8a651f8b138b04f748fccfefe17"}, + {file = "frozenlist-1.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:fb3c2db03683b5767dedb5769b8a40ebb47d6f7f45b1b3e3b4b51ec8ad9d9825"}, + {file = "frozenlist-1.4.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:1979bc0aeb89b33b588c51c54ab0161791149f2461ea7c7c946d95d5f93b56ae"}, + {file = "frozenlist-1.4.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cc7b01b3754ea68a62bd77ce6020afaffb44a590c2289089289363472d13aedb"}, + {file = "frozenlist-1.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c9c92be9fd329ac801cc420e08452b70e7aeab94ea4233a4804f0915c14eba9b"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c3894db91f5a489fc8fa6a9991820f368f0b3cbdb9cd8849547ccfab3392d86"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba60bb19387e13597fb059f32cd4d59445d7b18b69a745b8f8e5db0346f33480"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8aefbba5f69d42246543407ed2461db31006b0f76c4e32dfd6f42215a2c41d09"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:780d3a35680ced9ce682fbcf4cb9c2bad3136eeff760ab33707b71db84664e3a"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9acbb16f06fe7f52f441bb6f413ebae6c37baa6ef9edd49cdd567216da8600cd"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:23b701e65c7b36e4bf15546a89279bd4d8675faabc287d06bbcfac7d3c33e1e6"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:3e0153a805a98f5ada7e09826255ba99fb4f7524bb81bf6b47fb702666484ae1"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:dd9b1baec094d91bf36ec729445f7769d0d0cf6b64d04d86e45baf89e2b9059b"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:1a4471094e146b6790f61b98616ab8e44f72661879cc63fa1049d13ef711e71e"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5667ed53d68d91920defdf4035d1cdaa3c3121dc0b113255124bcfada1cfa1b8"}, + {file = "frozenlist-1.4.1-cp312-cp312-win32.whl", hash = "sha256:beee944ae828747fd7cb216a70f120767fc9f4f00bacae8543c14a6831673f89"}, + {file = "frozenlist-1.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:64536573d0a2cb6e625cf309984e2d873979709f2cf22839bf2d61790b448ad5"}, + {file = "frozenlist-1.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:20b51fa3f588ff2fe658663db52a41a4f7aa6c04f6201449c6c7c476bd255c0d"}, + {file = "frozenlist-1.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:410478a0c562d1a5bcc2f7ea448359fcb050ed48b3c6f6f4f18c313a9bdb1826"}, + {file = "frozenlist-1.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c6321c9efe29975232da3bd0af0ad216800a47e93d763ce64f291917a381b8eb"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48f6a4533887e189dae092f1cf981f2e3885175f7a0f33c91fb5b7b682b6bab6"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6eb73fa5426ea69ee0e012fb59cdc76a15b1283d6e32e4f8dc4482ec67d1194d"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbeb989b5cc29e8daf7f976b421c220f1b8c731cbf22b9130d8815418ea45887"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32453c1de775c889eb4e22f1197fe3bdfe457d16476ea407472b9442e6295f7a"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:693945278a31f2086d9bf3df0fe8254bbeaef1fe71e1351c3bd730aa7d31c41b"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:1d0ce09d36d53bbbe566fe296965b23b961764c0bcf3ce2fa45f463745c04701"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:3a670dc61eb0d0eb7080890c13de3066790f9049b47b0de04007090807c776b0"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:dca69045298ce5c11fd539682cff879cc1e664c245d1c64da929813e54241d11"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a06339f38e9ed3a64e4c4e43aec7f59084033647f908e4259d279a52d3757d09"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b7f2f9f912dca3934c1baec2e4585a674ef16fe00218d833856408c48d5beee7"}, + {file = "frozenlist-1.4.1-cp38-cp38-win32.whl", hash = "sha256:e7004be74cbb7d9f34553a5ce5fb08be14fb33bc86f332fb71cbe5216362a497"}, + {file = "frozenlist-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:5a7d70357e7cee13f470c7883a063aae5fe209a493c57d86eb7f5a6f910fae09"}, + {file = "frozenlist-1.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bfa4a17e17ce9abf47a74ae02f32d014c5e9404b6d9ac7f729e01562bbee601e"}, + {file = "frozenlist-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b7e3ed87d4138356775346e6845cccbe66cd9e207f3cd11d2f0b9fd13681359d"}, + {file = "frozenlist-1.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c99169d4ff810155ca50b4da3b075cbde79752443117d89429595c2e8e37fed8"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edb678da49d9f72c9f6c609fbe41a5dfb9a9282f9e6a2253d5a91e0fc382d7c0"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6db4667b187a6742b33afbbaf05a7bc551ffcf1ced0000a571aedbb4aa42fc7b"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55fdc093b5a3cb41d420884cdaf37a1e74c3c37a31f46e66286d9145d2063bd0"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82e8211d69a4f4bc360ea22cd6555f8e61a1bd211d1d5d39d3d228b48c83a897"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89aa2c2eeb20957be2d950b85974b30a01a762f3308cd02bb15e1ad632e22dc7"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9d3e0c25a2350080e9319724dede4f31f43a6c9779be48021a7f4ebde8b2d742"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7268252af60904bf52c26173cbadc3a071cece75f873705419c8681f24d3edea"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:0c250a29735d4f15321007fb02865f0e6b6a41a6b88f1f523ca1596ab5f50bd5"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:96ec70beabbd3b10e8bfe52616a13561e58fe84c0101dd031dc78f250d5128b9"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:23b2d7679b73fe0e5a4560b672a39f98dfc6f60df63823b0a9970525325b95f6"}, + {file = "frozenlist-1.4.1-cp39-cp39-win32.whl", hash = "sha256:a7496bfe1da7fb1a4e1cc23bb67c58fab69311cc7d32b5a99c2007b4b2a0e932"}, + {file = "frozenlist-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:e6a20a581f9ce92d389a8c7d7c3dd47c81fd5d6e655c8dddf341e14aa48659d0"}, + {file = "frozenlist-1.4.1-py3-none-any.whl", hash = "sha256:04ced3e6a46b4cfffe20f9ae482818e34eba9b5fb0ce4056e4cc9b6e212d09b7"}, + {file = "frozenlist-1.4.1.tar.gz", hash = "sha256:c037a86e8513059a2613aaba4d817bb90b9d9b6b69aace3ce9c877e8c8ed402b"}, +] + +[[package]] +name = "h11" +version = "0.14.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.7" +files = [ + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] + +[[package]] +name = "httpcore" +version = "1.0.5" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"}, + {file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.13,<0.15" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<0.26.0)"] + +[[package]] +name = "httpx" +version = "0.27.0" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"}, + {file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" +sniffio = "*" + +[package.extras] +brotli = ["brotli", "brotlicffi"] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] + +[[package]] +name = "idna" +version = "3.7" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.5" +files = [ + {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, + {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, +] + +[[package]] +name = "importlib-metadata" +version = "8.4.0" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "importlib_metadata-8.4.0-py3-none-any.whl", hash = "sha256:66f342cc6ac9818fc6ff340576acd24d65ba0b3efabb2b4ac08b598965a4a2f1"}, + {file = "importlib_metadata-8.4.0.tar.gz", hash = "sha256:9a547d3bc3608b025f93d403fdd1aae741c24fbb8314df4b155675742ce303c5"}, +] + +[package.dependencies] +zipp = ">=0.5" + +[package.extras] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +perf = ["ipython"] +test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-perf (>=0.9.2)", "pytest-ruff (>=0.2.1)"] + +[[package]] +name = "jinja2" +version = "3.1.4" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +files = [ + {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, + {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "linkify-it-py" +version = "2.0.3" +description = "Links recognition library with FULL unicode support." +optional = false +python-versions = ">=3.7" +files = [ + {file = "linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048"}, + {file = "linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79"}, +] + +[package.dependencies] +uc-micro-py = "*" + +[package.extras] +benchmark = ["pytest", "pytest-benchmark"] +dev = ["black", "flake8", "isort", "pre-commit", "pyproject-flake8"] +doc = ["myst-parser", "sphinx", "sphinx-book-theme"] +test = ["coverage", "pytest", "pytest-cov"] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.8" +files = [ + {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, + {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, +] + +[package.dependencies] +linkify-it-py = {version = ">=1,<3", optional = true, markers = "extra == \"linkify\""} +mdit-py-plugins = {version = "*", optional = true, markers = "extra == \"plugins\""} +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +code-style = ["pre-commit (>=3.0,<4.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins"] +profiling = ["gprof2dot"] +rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + +[[package]] +name = "markupsafe" +version = "2.1.5" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.7" +files = [ + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"}, + {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.4.1" +description = "Collection of plugins for markdown-it-py" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mdit_py_plugins-0.4.1-py3-none-any.whl", hash = "sha256:1020dfe4e6bfc2c79fb49ae4e3f5b297f5ccd20f010187acc52af2921e27dc6a"}, + {file = "mdit_py_plugins-0.4.1.tar.gz", hash = "sha256:834b8ac23d1cd60cec703646ffd22ae97b7955a6d596eb1d304be1e251ae499c"}, +] + +[package.dependencies] +markdown-it-py = ">=1.0.0,<4.0.0" + +[package.extras] +code-style = ["pre-commit"] +rtd = ["myst-parser", "sphinx-book-theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "msgpack" +version = "1.0.8" +description = "MessagePack serializer" +optional = false +python-versions = ">=3.8" +files = [ + {file = "msgpack-1.0.8-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:505fe3d03856ac7d215dbe005414bc28505d26f0c128906037e66d98c4e95868"}, + {file = "msgpack-1.0.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b7842518a63a9f17107eb176320960ec095a8ee3b4420b5f688e24bf50c53c"}, + {file = "msgpack-1.0.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:376081f471a2ef24828b83a641a02c575d6103a3ad7fd7dade5486cad10ea659"}, + {file = "msgpack-1.0.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e390971d082dba073c05dbd56322427d3280b7cc8b53484c9377adfbae67dc2"}, + {file = "msgpack-1.0.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00e073efcba9ea99db5acef3959efa45b52bc67b61b00823d2a1a6944bf45982"}, + {file = "msgpack-1.0.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82d92c773fbc6942a7a8b520d22c11cfc8fd83bba86116bfcf962c2f5c2ecdaa"}, + {file = "msgpack-1.0.8-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9ee32dcb8e531adae1f1ca568822e9b3a738369b3b686d1477cbc643c4a9c128"}, + {file = "msgpack-1.0.8-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e3aa7e51d738e0ec0afbed661261513b38b3014754c9459508399baf14ae0c9d"}, + {file = "msgpack-1.0.8-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:69284049d07fce531c17404fcba2bb1df472bc2dcdac642ae71a2d079d950653"}, + {file = "msgpack-1.0.8-cp310-cp310-win32.whl", hash = "sha256:13577ec9e247f8741c84d06b9ece5f654920d8365a4b636ce0e44f15e07ec693"}, + {file = "msgpack-1.0.8-cp310-cp310-win_amd64.whl", hash = "sha256:e532dbd6ddfe13946de050d7474e3f5fb6ec774fbb1a188aaf469b08cf04189a"}, + {file = "msgpack-1.0.8-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9517004e21664f2b5a5fd6333b0731b9cf0817403a941b393d89a2f1dc2bd836"}, + {file = "msgpack-1.0.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d16a786905034e7e34098634b184a7d81f91d4c3d246edc6bd7aefb2fd8ea6ad"}, + {file = "msgpack-1.0.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2872993e209f7ed04d963e4b4fbae72d034844ec66bc4ca403329db2074377b"}, + {file = "msgpack-1.0.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c330eace3dd100bdb54b5653b966de7f51c26ec4a7d4e87132d9b4f738220ba"}, + {file = "msgpack-1.0.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b5c044f3eff2a6534768ccfd50425939e7a8b5cf9a7261c385de1e20dcfc85"}, + {file = "msgpack-1.0.8-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1876b0b653a808fcd50123b953af170c535027bf1d053b59790eebb0aeb38950"}, + {file = "msgpack-1.0.8-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:dfe1f0f0ed5785c187144c46a292b8c34c1295c01da12e10ccddfc16def4448a"}, + {file = "msgpack-1.0.8-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:3528807cbbb7f315bb81959d5961855e7ba52aa60a3097151cb21956fbc7502b"}, + {file = "msgpack-1.0.8-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e2f879ab92ce502a1e65fce390eab619774dda6a6ff719718069ac94084098ce"}, + {file = "msgpack-1.0.8-cp311-cp311-win32.whl", hash = "sha256:26ee97a8261e6e35885c2ecd2fd4a6d38252246f94a2aec23665a4e66d066305"}, + {file = "msgpack-1.0.8-cp311-cp311-win_amd64.whl", hash = "sha256:eadb9f826c138e6cf3c49d6f8de88225a3c0ab181a9b4ba792e006e5292d150e"}, + {file = "msgpack-1.0.8-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:114be227f5213ef8b215c22dde19532f5da9652e56e8ce969bf0a26d7c419fee"}, + {file = "msgpack-1.0.8-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d661dc4785affa9d0edfdd1e59ec056a58b3dbb9f196fa43587f3ddac654ac7b"}, + {file = "msgpack-1.0.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d56fd9f1f1cdc8227d7b7918f55091349741904d9520c65f0139a9755952c9e8"}, + {file = "msgpack-1.0.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0726c282d188e204281ebd8de31724b7d749adebc086873a59efb8cf7ae27df3"}, + {file = "msgpack-1.0.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8db8e423192303ed77cff4dce3a4b88dbfaf43979d280181558af5e2c3c71afc"}, + {file = "msgpack-1.0.8-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:99881222f4a8c2f641f25703963a5cefb076adffd959e0558dc9f803a52d6a58"}, + {file = "msgpack-1.0.8-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b5505774ea2a73a86ea176e8a9a4a7c8bf5d521050f0f6f8426afe798689243f"}, + {file = "msgpack-1.0.8-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:ef254a06bcea461e65ff0373d8a0dd1ed3aa004af48839f002a0c994a6f72d04"}, + {file = "msgpack-1.0.8-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e1dd7839443592d00e96db831eddb4111a2a81a46b028f0facd60a09ebbdd543"}, + {file = "msgpack-1.0.8-cp312-cp312-win32.whl", hash = "sha256:64d0fcd436c5683fdd7c907eeae5e2cbb5eb872fafbc03a43609d7941840995c"}, + {file = "msgpack-1.0.8-cp312-cp312-win_amd64.whl", hash = "sha256:74398a4cf19de42e1498368c36eed45d9528f5fd0155241e82c4082b7e16cffd"}, + {file = "msgpack-1.0.8-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0ceea77719d45c839fd73abcb190b8390412a890df2f83fb8cf49b2a4b5c2f40"}, + {file = "msgpack-1.0.8-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1ab0bbcd4d1f7b6991ee7c753655b481c50084294218de69365f8f1970d4c151"}, + {file = "msgpack-1.0.8-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1cce488457370ffd1f953846f82323cb6b2ad2190987cd4d70b2713e17268d24"}, + {file = "msgpack-1.0.8-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3923a1778f7e5ef31865893fdca12a8d7dc03a44b33e2a5f3295416314c09f5d"}, + {file = "msgpack-1.0.8-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a22e47578b30a3e199ab067a4d43d790249b3c0587d9a771921f86250c8435db"}, + {file = "msgpack-1.0.8-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd739c9251d01e0279ce729e37b39d49a08c0420d3fee7f2a4968c0576678f77"}, + {file = "msgpack-1.0.8-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:d3420522057ebab1728b21ad473aa950026d07cb09da41103f8e597dfbfaeb13"}, + {file = "msgpack-1.0.8-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:5845fdf5e5d5b78a49b826fcdc0eb2e2aa7191980e3d2cfd2a30303a74f212e2"}, + {file = "msgpack-1.0.8-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6a0e76621f6e1f908ae52860bdcb58e1ca85231a9b0545e64509c931dd34275a"}, + {file = "msgpack-1.0.8-cp38-cp38-win32.whl", hash = "sha256:374a8e88ddab84b9ada695d255679fb99c53513c0a51778796fcf0944d6c789c"}, + {file = "msgpack-1.0.8-cp38-cp38-win_amd64.whl", hash = "sha256:f3709997b228685fe53e8c433e2df9f0cdb5f4542bd5114ed17ac3c0129b0480"}, + {file = "msgpack-1.0.8-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f51bab98d52739c50c56658cc303f190785f9a2cd97b823357e7aeae54c8f68a"}, + {file = "msgpack-1.0.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:73ee792784d48aa338bba28063e19a27e8d989344f34aad14ea6e1b9bd83f596"}, + {file = "msgpack-1.0.8-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f9904e24646570539a8950400602d66d2b2c492b9010ea7e965025cb71d0c86d"}, + {file = "msgpack-1.0.8-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e75753aeda0ddc4c28dce4c32ba2f6ec30b1b02f6c0b14e547841ba5b24f753f"}, + {file = "msgpack-1.0.8-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5dbf059fb4b7c240c873c1245ee112505be27497e90f7c6591261c7d3c3a8228"}, + {file = "msgpack-1.0.8-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4916727e31c28be8beaf11cf117d6f6f188dcc36daae4e851fee88646f5b6b18"}, + {file = "msgpack-1.0.8-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7938111ed1358f536daf311be244f34df7bf3cdedb3ed883787aca97778b28d8"}, + {file = "msgpack-1.0.8-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:493c5c5e44b06d6c9268ce21b302c9ca055c1fd3484c25ba41d34476c76ee746"}, + {file = "msgpack-1.0.8-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fbb160554e319f7b22ecf530a80a3ff496d38e8e07ae763b9e82fadfe96f273"}, + {file = "msgpack-1.0.8-cp39-cp39-win32.whl", hash = "sha256:f9af38a89b6a5c04b7d18c492c8ccf2aee7048aff1ce8437c4683bb5a1df893d"}, + {file = "msgpack-1.0.8-cp39-cp39-win_amd64.whl", hash = "sha256:ed59dd52075f8fc91da6053b12e8c89e37aa043f8986efd89e61fae69dc1b011"}, + {file = "msgpack-1.0.8.tar.gz", hash = "sha256:95c02b0e27e706e48d0e5426d1710ca78e0f0628d6e89d5b5a5b91a5f12274f3"}, +] + +[[package]] +name = "multidict" +version = "6.0.5" +description = "multidict implementation" +optional = false +python-versions = ">=3.7" +files = [ + {file = "multidict-6.0.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:228b644ae063c10e7f324ab1ab6b548bdf6f8b47f3ec234fef1093bc2735e5f9"}, + {file = "multidict-6.0.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:896ebdcf62683551312c30e20614305f53125750803b614e9e6ce74a96232604"}, + {file = "multidict-6.0.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:411bf8515f3be9813d06004cac41ccf7d1cd46dfe233705933dd163b60e37600"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d147090048129ce3c453f0292e7697d333db95e52616b3793922945804a433c"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:215ed703caf15f578dca76ee6f6b21b7603791ae090fbf1ef9d865571039ade5"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c6390cf87ff6234643428991b7359b5f59cc15155695deb4eda5c777d2b880f"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21fd81c4ebdb4f214161be351eb5bcf385426bf023041da2fd9e60681f3cebae"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3cc2ad10255f903656017363cd59436f2111443a76f996584d1077e43ee51182"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6939c95381e003f54cd4c5516740faba40cf5ad3eeff460c3ad1d3e0ea2549bf"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:220dd781e3f7af2c2c1053da9fa96d9cf3072ca58f057f4c5adaaa1cab8fc442"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:766c8f7511df26d9f11cd3a8be623e59cca73d44643abab3f8c8c07620524e4a"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:fe5d7785250541f7f5019ab9cba2c71169dc7d74d0f45253f8313f436458a4ef"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1c1496e73051918fcd4f58ff2e0f2f3066d1c76a0c6aeffd9b45d53243702cc"}, + {file = "multidict-6.0.5-cp310-cp310-win32.whl", hash = "sha256:7afcdd1fc07befad18ec4523a782cde4e93e0a2bf71239894b8d61ee578c1319"}, + {file = "multidict-6.0.5-cp310-cp310-win_amd64.whl", hash = "sha256:99f60d34c048c5c2fabc766108c103612344c46e35d4ed9ae0673d33c8fb26e8"}, + {file = "multidict-6.0.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f285e862d2f153a70586579c15c44656f888806ed0e5b56b64489afe4a2dbfba"}, + {file = "multidict-6.0.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:53689bb4e102200a4fafa9de9c7c3c212ab40a7ab2c8e474491914d2305f187e"}, + {file = "multidict-6.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:612d1156111ae11d14afaf3a0669ebf6c170dbb735e510a7438ffe2369a847fd"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7be7047bd08accdb7487737631d25735c9a04327911de89ff1b26b81745bd4e3"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de170c7b4fe6859beb8926e84f7d7d6c693dfe8e27372ce3b76f01c46e489fcf"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04bde7a7b3de05732a4eb39c94574db1ec99abb56162d6c520ad26f83267de29"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85f67aed7bb647f93e7520633d8f51d3cbc6ab96957c71272b286b2f30dc70ed"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425bf820055005bfc8aa9a0b99ccb52cc2f4070153e34b701acc98d201693733"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d3eb1ceec286eba8220c26f3b0096cf189aea7057b6e7b7a2e60ed36b373b77f"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:7901c05ead4b3fb75113fb1dd33eb1253c6d3ee37ce93305acd9d38e0b5f21a4"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:e0e79d91e71b9867c73323a3444724d496c037e578a0e1755ae159ba14f4f3d1"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:29bfeb0dff5cb5fdab2023a7a9947b3b4af63e9c47cae2a10ad58394b517fddc"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e030047e85cbcedbfc073f71836d62dd5dadfbe7531cae27789ff66bc551bd5e"}, + {file = "multidict-6.0.5-cp311-cp311-win32.whl", hash = "sha256:2f4848aa3baa109e6ab81fe2006c77ed4d3cd1e0ac2c1fbddb7b1277c168788c"}, + {file = "multidict-6.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:2faa5ae9376faba05f630d7e5e6be05be22913782b927b19d12b8145968a85ea"}, + {file = "multidict-6.0.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:51d035609b86722963404f711db441cf7134f1889107fb171a970c9701f92e1e"}, + {file = "multidict-6.0.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cbebcd5bcaf1eaf302617c114aa67569dd3f090dd0ce8ba9e35e9985b41ac35b"}, + {file = "multidict-6.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2ffc42c922dbfddb4a4c3b438eb056828719f07608af27d163191cb3e3aa6cc5"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ceb3b7e6a0135e092de86110c5a74e46bda4bd4fbfeeb3a3bcec79c0f861e450"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79660376075cfd4b2c80f295528aa6beb2058fd289f4c9252f986751a4cd0496"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4428b29611e989719874670fd152b6625500ad6c686d464e99f5aaeeaca175a"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d84a5c3a5f7ce6db1f999fb9438f686bc2e09d38143f2d93d8406ed2dd6b9226"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76c0de87358b192de7ea9649beb392f107dcad9ad27276324c24c91774ca5271"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:79a6d2ba910adb2cbafc95dad936f8b9386e77c84c35bc0add315b856d7c3abb"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:92d16a3e275e38293623ebf639c471d3e03bb20b8ebb845237e0d3664914caef"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:fb616be3538599e797a2017cccca78e354c767165e8858ab5116813146041a24"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:14c2976aa9038c2629efa2c148022ed5eb4cb939e15ec7aace7ca932f48f9ba6"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:435a0984199d81ca178b9ae2c26ec3d49692d20ee29bc4c11a2a8d4514c67eda"}, + {file = "multidict-6.0.5-cp312-cp312-win32.whl", hash = "sha256:9fe7b0653ba3d9d65cbe7698cca585bf0f8c83dbbcc710db9c90f478e175f2d5"}, + {file = "multidict-6.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:01265f5e40f5a17f8241d52656ed27192be03bfa8764d88e8220141d1e4b3556"}, + {file = "multidict-6.0.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:19fe01cea168585ba0f678cad6f58133db2aa14eccaf22f88e4a6dccadfad8b3"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6bf7a982604375a8d49b6cc1b781c1747f243d91b81035a9b43a2126c04766f5"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:107c0cdefe028703fb5dafe640a409cb146d44a6ae201e55b35a4af8e95457dd"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:403c0911cd5d5791605808b942c88a8155c2592e05332d2bf78f18697a5fa15e"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aeaf541ddbad8311a87dd695ed9642401131ea39ad7bc8cf3ef3967fd093b626"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e4972624066095e52b569e02b5ca97dbd7a7ddd4294bf4e7247d52635630dd83"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d946b0a9eb8aaa590df1fe082cee553ceab173e6cb5b03239716338629c50c7a"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b55358304d7a73d7bdf5de62494aaf70bd33015831ffd98bc498b433dfe5b10c"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:a3145cb08d8625b2d3fee1b2d596a8766352979c9bffe5d7833e0503d0f0b5e5"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:d65f25da8e248202bd47445cec78e0025c0fe7582b23ec69c3b27a640dd7a8e3"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c9bf56195c6bbd293340ea82eafd0071cb3d450c703d2c93afb89f93b8386ccc"}, + {file = "multidict-6.0.5-cp37-cp37m-win32.whl", hash = "sha256:69db76c09796b313331bb7048229e3bee7928eb62bab5e071e9f7fcc4879caee"}, + {file = "multidict-6.0.5-cp37-cp37m-win_amd64.whl", hash = "sha256:fce28b3c8a81b6b36dfac9feb1de115bab619b3c13905b419ec71d03a3fc1423"}, + {file = "multidict-6.0.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:76f067f5121dcecf0d63a67f29080b26c43c71a98b10c701b0677e4a065fbd54"}, + {file = "multidict-6.0.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b82cc8ace10ab5bd93235dfaab2021c70637005e1ac787031f4d1da63d493c1d"}, + {file = "multidict-6.0.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5cb241881eefd96b46f89b1a056187ea8e9ba14ab88ba632e68d7a2ecb7aadf7"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8e94e6912639a02ce173341ff62cc1201232ab86b8a8fcc05572741a5dc7d93"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09a892e4a9fb47331da06948690ae38eaa2426de97b4ccbfafbdcbe5c8f37ff8"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55205d03e8a598cfc688c71ca8ea5f66447164efff8869517f175ea632c7cb7b"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37b15024f864916b4951adb95d3a80c9431299080341ab9544ed148091b53f50"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2a1dee728b52b33eebff5072817176c172050d44d67befd681609b4746e1c2e"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:edd08e6f2f1a390bf137080507e44ccc086353c8e98c657e666c017718561b89"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:60d698e8179a42ec85172d12f50b1668254628425a6bd611aba022257cac1386"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:3d25f19500588cbc47dc19081d78131c32637c25804df8414463ec908631e453"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:4cc0ef8b962ac7a5e62b9e826bd0cd5040e7d401bc45a6835910ed699037a461"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:eca2e9d0cc5a889850e9bbd68e98314ada174ff6ccd1129500103df7a94a7a44"}, + {file = "multidict-6.0.5-cp38-cp38-win32.whl", hash = "sha256:4a6a4f196f08c58c59e0b8ef8ec441d12aee4125a7d4f4fef000ccb22f8d7241"}, + {file = "multidict-6.0.5-cp38-cp38-win_amd64.whl", hash = "sha256:0275e35209c27a3f7951e1ce7aaf93ce0d163b28948444bec61dd7badc6d3f8c"}, + {file = "multidict-6.0.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e7be68734bd8c9a513f2b0cfd508802d6609da068f40dc57d4e3494cefc92929"}, + {file = "multidict-6.0.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1d9ea7a7e779d7a3561aade7d596649fbecfa5c08a7674b11b423783217933f9"}, + {file = "multidict-6.0.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ea1456df2a27c73ce51120fa2f519f1bea2f4a03a917f4a43c8707cf4cbbae1a"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf590b134eb70629e350691ecca88eac3e3b8b3c86992042fb82e3cb1830d5e1"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5c0631926c4f58e9a5ccce555ad7747d9a9f8b10619621f22f9635f069f6233e"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dce1c6912ab9ff5f179eaf6efe7365c1f425ed690b03341911bf4939ef2f3046"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0868d64af83169e4d4152ec612637a543f7a336e4a307b119e98042e852ad9c"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:141b43360bfd3bdd75f15ed811850763555a251e38b2405967f8e25fb43f7d40"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7df704ca8cf4a073334e0427ae2345323613e4df18cc224f647f251e5e75a527"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6214c5a5571802c33f80e6c84713b2c79e024995b9c5897f794b43e714daeec9"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:cd6c8fca38178e12c00418de737aef1261576bd1b6e8c6134d3e729a4e858b38"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:e02021f87a5b6932fa6ce916ca004c4d441509d33bbdbeca70d05dff5e9d2479"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ebd8d160f91a764652d3e51ce0d2956b38efe37c9231cd82cfc0bed2e40b581c"}, + {file = "multidict-6.0.5-cp39-cp39-win32.whl", hash = "sha256:04da1bb8c8dbadf2a18a452639771951c662c5ad03aefe4884775454be322c9b"}, + {file = "multidict-6.0.5-cp39-cp39-win_amd64.whl", hash = "sha256:d6f6d4f185481c9669b9447bf9d9cf3b95a0e9df9d169bbc17e363b7d5487755"}, + {file = "multidict-6.0.5-py3-none-any.whl", hash = "sha256:0d63c74e3d7ab26de115c49bffc92cc77ed23395303d496eae515d4204a625e7"}, + {file = "multidict-6.0.5.tar.gz", hash = "sha256:f7e301075edaf50500f0b341543c41194d8df3ae5caf4702f2095f3ca73dd8da"}, +] + +[[package]] +name = "pydantic" +version = "2.8.2" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic-2.8.2-py3-none-any.whl", hash = "sha256:73ee9fddd406dc318b885c7a2eab8a6472b68b8fb5ba8150949fc3db939f23c8"}, + {file = "pydantic-2.8.2.tar.gz", hash = "sha256:6f62c13d067b0755ad1c21a34bdd06c0c12625a22b0fc09c6b149816604f7c2a"}, +] + +[package.dependencies] +annotated-types = ">=0.4.0" +pydantic-core = "2.20.1" +typing-extensions = [ + {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, + {version = ">=4.6.1", markers = "python_version < \"3.13\""}, +] + +[package.extras] +email = ["email-validator (>=2.0.0)"] + +[[package]] +name = "pydantic-core" +version = "2.20.1" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic_core-2.20.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3acae97ffd19bf091c72df4d726d552c473f3576409b2a7ca36b2f535ffff4a3"}, + {file = "pydantic_core-2.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41f4c96227a67a013e7de5ff8f20fb496ce573893b7f4f2707d065907bffdbd6"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f239eb799a2081495ea659d8d4a43a8f42cd1fe9ff2e7e436295c38a10c286a"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53e431da3fc53360db73eedf6f7124d1076e1b4ee4276b36fb25514544ceb4a3"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1f62b2413c3a0e846c3b838b2ecd6c7a19ec6793b2a522745b0869e37ab5bc1"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d41e6daee2813ecceea8eda38062d69e280b39df793f5a942fa515b8ed67953"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d482efec8b7dc6bfaedc0f166b2ce349df0011f5d2f1f25537ced4cfc34fd98"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e93e1a4b4b33daed65d781a57a522ff153dcf748dee70b40c7258c5861e1768a"}, + {file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e7c4ea22b6739b162c9ecaaa41d718dfad48a244909fe7ef4b54c0b530effc5a"}, + {file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4f2790949cf385d985a31984907fecb3896999329103df4e4983a4a41e13e840"}, + {file = "pydantic_core-2.20.1-cp310-none-win32.whl", hash = "sha256:5e999ba8dd90e93d57410c5e67ebb67ffcaadcea0ad973240fdfd3a135506250"}, + {file = "pydantic_core-2.20.1-cp310-none-win_amd64.whl", hash = "sha256:512ecfbefef6dac7bc5eaaf46177b2de58cdf7acac8793fe033b24ece0b9566c"}, + {file = "pydantic_core-2.20.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d2a8fa9d6d6f891f3deec72f5cc668e6f66b188ab14bb1ab52422fe8e644f312"}, + {file = "pydantic_core-2.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:175873691124f3d0da55aeea1d90660a6ea7a3cfea137c38afa0a5ffabe37b88"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37eee5b638f0e0dcd18d21f59b679686bbd18917b87db0193ae36f9c23c355fc"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25e9185e2d06c16ee438ed39bf62935ec436474a6ac4f9358524220f1b236e43"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:150906b40ff188a3260cbee25380e7494ee85048584998c1e66df0c7a11c17a6"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ad4aeb3e9a97286573c03df758fc7627aecdd02f1da04516a86dc159bf70121"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3f3ed29cd9f978c604708511a1f9c2fdcb6c38b9aae36a51905b8811ee5cbf1"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b0dae11d8f5ded51699c74d9548dcc5938e0804cc8298ec0aa0da95c21fff57b"}, + {file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:faa6b09ee09433b87992fb5a2859efd1c264ddc37280d2dd5db502126d0e7f27"}, + {file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9dc1b507c12eb0481d071f3c1808f0529ad41dc415d0ca11f7ebfc666e66a18b"}, + {file = "pydantic_core-2.20.1-cp311-none-win32.whl", hash = "sha256:fa2fddcb7107e0d1808086ca306dcade7df60a13a6c347a7acf1ec139aa6789a"}, + {file = "pydantic_core-2.20.1-cp311-none-win_amd64.whl", hash = "sha256:40a783fb7ee353c50bd3853e626f15677ea527ae556429453685ae32280c19c2"}, + {file = "pydantic_core-2.20.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:595ba5be69b35777474fa07f80fc260ea71255656191adb22a8c53aba4479231"}, + {file = "pydantic_core-2.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a4f55095ad087474999ee28d3398bae183a66be4823f753cd7d67dd0153427c9"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9aa05d09ecf4c75157197f27cdc9cfaeb7c5f15021c6373932bf3e124af029f"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e97fdf088d4b31ff4ba35db26d9cc472ac7ef4a2ff2badeabf8d727b3377fc52"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc633a9fe1eb87e250b5c57d389cf28998e4292336926b0b6cdaee353f89a237"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d573faf8eb7e6b1cbbcb4f5b247c60ca8be39fe2c674495df0eb4318303137fe"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26dc97754b57d2fd00ac2b24dfa341abffc380b823211994c4efac7f13b9e90e"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:33499e85e739a4b60c9dac710c20a08dc73cb3240c9a0e22325e671b27b70d24"}, + {file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bebb4d6715c814597f85297c332297c6ce81e29436125ca59d1159b07f423eb1"}, + {file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:516d9227919612425c8ef1c9b869bbbee249bc91912c8aaffb66116c0b447ebd"}, + {file = "pydantic_core-2.20.1-cp312-none-win32.whl", hash = "sha256:469f29f9093c9d834432034d33f5fe45699e664f12a13bf38c04967ce233d688"}, + {file = "pydantic_core-2.20.1-cp312-none-win_amd64.whl", hash = "sha256:035ede2e16da7281041f0e626459bcae33ed998cca6a0a007a5ebb73414ac72d"}, + {file = "pydantic_core-2.20.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0827505a5c87e8aa285dc31e9ec7f4a17c81a813d45f70b1d9164e03a813a686"}, + {file = "pydantic_core-2.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19c0fa39fa154e7e0b7f82f88ef85faa2a4c23cc65aae2f5aea625e3c13c735a"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa223cd1e36b642092c326d694d8bf59b71ddddc94cdb752bbbb1c5c91d833b"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c336a6d235522a62fef872c6295a42ecb0c4e1d0f1a3e500fe949415761b8a19"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7eb6a0587eded33aeefea9f916899d42b1799b7b14b8f8ff2753c0ac1741edac"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:70c8daf4faca8da5a6d655f9af86faf6ec2e1768f4b8b9d0226c02f3d6209703"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9fa4c9bf273ca41f940bceb86922a7667cd5bf90e95dbb157cbb8441008482c"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:11b71d67b4725e7e2a9f6e9c0ac1239bbc0c48cce3dc59f98635efc57d6dac83"}, + {file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:270755f15174fb983890c49881e93f8f1b80f0b5e3a3cc1394a255706cabd203"}, + {file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c81131869240e3e568916ef4c307f8b99583efaa60a8112ef27a366eefba8ef0"}, + {file = "pydantic_core-2.20.1-cp313-none-win32.whl", hash = "sha256:b91ced227c41aa29c672814f50dbb05ec93536abf8f43cd14ec9521ea09afe4e"}, + {file = "pydantic_core-2.20.1-cp313-none-win_amd64.whl", hash = "sha256:65db0f2eefcaad1a3950f498aabb4875c8890438bc80b19362cf633b87a8ab20"}, + {file = "pydantic_core-2.20.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:4745f4ac52cc6686390c40eaa01d48b18997cb130833154801a442323cc78f91"}, + {file = "pydantic_core-2.20.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a8ad4c766d3f33ba8fd692f9aa297c9058970530a32c728a2c4bfd2616d3358b"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41e81317dd6a0127cabce83c0c9c3fbecceae981c8391e6f1dec88a77c8a569a"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04024d270cf63f586ad41fff13fde4311c4fc13ea74676962c876d9577bcc78f"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eaad4ff2de1c3823fddf82f41121bdf453d922e9a238642b1dedb33c4e4f98ad"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26ab812fa0c845df815e506be30337e2df27e88399b985d0bb4e3ecfe72df31c"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c5ebac750d9d5f2706654c638c041635c385596caf68f81342011ddfa1e5598"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2aafc5a503855ea5885559eae883978c9b6d8c8993d67766ee73d82e841300dd"}, + {file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:4868f6bd7c9d98904b748a2653031fc9c2f85b6237009d475b1008bfaeb0a5aa"}, + {file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa2f457b4af386254372dfa78a2eda2563680d982422641a85f271c859df1987"}, + {file = "pydantic_core-2.20.1-cp38-none-win32.whl", hash = "sha256:225b67a1f6d602de0ce7f6c1c3ae89a4aa25d3de9be857999e9124f15dab486a"}, + {file = "pydantic_core-2.20.1-cp38-none-win_amd64.whl", hash = "sha256:6b507132dcfc0dea440cce23ee2182c0ce7aba7054576efc65634f080dbe9434"}, + {file = "pydantic_core-2.20.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:b03f7941783b4c4a26051846dea594628b38f6940a2fdc0df00b221aed39314c"}, + {file = "pydantic_core-2.20.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1eedfeb6089ed3fad42e81a67755846ad4dcc14d73698c120a82e4ccf0f1f9f6"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:635fee4e041ab9c479e31edda27fcf966ea9614fff1317e280d99eb3e5ab6fe2"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:77bf3ac639c1ff567ae3b47f8d4cc3dc20f9966a2a6dd2311dcc055d3d04fb8a"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ed1b0132f24beeec5a78b67d9388656d03e6a7c837394f99257e2d55b461611"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6514f963b023aeee506678a1cf821fe31159b925c4b76fe2afa94cc70b3222b"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10d4204d8ca33146e761c79f83cc861df20e7ae9f6487ca290a97702daf56006"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2d036c7187b9422ae5b262badb87a20a49eb6c5238b2004e96d4da1231badef1"}, + {file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9ebfef07dbe1d93efb94b4700f2d278494e9162565a54f124c404a5656d7ff09"}, + {file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:6b9d9bb600328a1ce523ab4f454859e9d439150abb0906c5a1983c146580ebab"}, + {file = "pydantic_core-2.20.1-cp39-none-win32.whl", hash = "sha256:784c1214cb6dd1e3b15dd8b91b9a53852aed16671cc3fbe4786f4f1db07089e2"}, + {file = "pydantic_core-2.20.1-cp39-none-win_amd64.whl", hash = "sha256:d2fe69c5434391727efa54b47a1e7986bb0186e72a41b203df8f5b0a19a4f669"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a45f84b09ac9c3d35dfcf6a27fd0634d30d183205230a0ebe8373a0e8cfa0906"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d02a72df14dfdbaf228424573a07af10637bd490f0901cee872c4f434a735b94"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2b27e6af28f07e2f195552b37d7d66b150adbaa39a6d327766ffd695799780f"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:084659fac3c83fd674596612aeff6041a18402f1e1bc19ca39e417d554468482"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:242b8feb3c493ab78be289c034a1f659e8826e2233786e36f2893a950a719bb6"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:38cf1c40a921d05c5edc61a785c0ddb4bed67827069f535d794ce6bcded919fc"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e0bbdd76ce9aa5d4209d65f2b27fc6e5ef1312ae6c5333c26db3f5ade53a1e99"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:254ec27fdb5b1ee60684f91683be95e5133c994cc54e86a0b0963afa25c8f8a6"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:407653af5617f0757261ae249d3fba09504d7a71ab36ac057c938572d1bc9331"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:c693e916709c2465b02ca0ad7b387c4f8423d1db7b4649c551f27a529181c5ad"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b5ff4911aea936a47d9376fd3ab17e970cc543d1b68921886e7f64bd28308d1"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177f55a886d74f1808763976ac4efd29b7ed15c69f4d838bbd74d9d09cf6fa86"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:964faa8a861d2664f0c7ab0c181af0bea66098b1919439815ca8803ef136fc4e"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4dd484681c15e6b9a977c785a345d3e378d72678fd5f1f3c0509608da24f2ac0"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f6d6cff3538391e8486a431569b77921adfcdef14eb18fbf19b7c0a5294d4e6a"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a6d511cc297ff0883bc3708b465ff82d7560193169a8b93260f74ecb0a5e08a7"}, + {file = "pydantic_core-2.20.1.tar.gz", hash = "sha256:26ca695eeee5f9f1aeeb211ffc12f10bcb6f71e2989988fda61dabd65db878d4"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + +[[package]] +name = "pygments" +version = "2.18.0" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, + {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "rich" +version = "13.7.1" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"}, + {file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" +typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9\""} + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + +[[package]] +name = "textual" +version = "0.43.2" +description = "Modern Text User Interface framework" +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "textual-0.43.2-py3-none-any.whl", hash = "sha256:b6a3340738e3c2223049bb6a4fbce059e4f942a4480b8fd146b816ce5228a8ec"}, + {file = "textual-0.43.2.tar.gz", hash = "sha256:7f4f84f1ae753aa39290659dc0bb0aab06abb7e37aa3041349c86940698c6b54"}, +] + +[package.dependencies] +importlib-metadata = ">=4.11.3" +markdown-it-py = {version = ">=2.1.0", extras = ["linkify", "plugins"]} +rich = ">=13.3.3" +typing-extensions = ">=4.4.0,<5.0.0" + +[package.extras] +syntax = ["tree-sitter (>=0.20.1,<0.21.0)", "tree_sitter_languages (>=1.7.0)"] + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + +[[package]] +name = "uc-micro-py" +version = "1.0.3" +description = "Micro subset of unicode data files for linkify-it-py projects." +optional = false +python-versions = ">=3.7" +files = [ + {file = "uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a"}, + {file = "uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5"}, +] + +[package.extras] +test = ["coverage", "pytest", "pytest-cov"] + +[[package]] +name = "uvloop" +version = "0.19.0" +description = "Fast implementation of asyncio event loop on top of libuv" +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "uvloop-0.19.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:de4313d7f575474c8f5a12e163f6d89c0a878bc49219641d49e6f1444369a90e"}, + {file = "uvloop-0.19.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5588bd21cf1fcf06bded085f37e43ce0e00424197e7c10e77afd4bbefffef428"}, + {file = "uvloop-0.19.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b1fd71c3843327f3bbc3237bedcdb6504fd50368ab3e04d0410e52ec293f5b8"}, + {file = "uvloop-0.19.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a05128d315e2912791de6088c34136bfcdd0c7cbc1cf85fd6fd1bb321b7c849"}, + {file = "uvloop-0.19.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:cd81bdc2b8219cb4b2556eea39d2e36bfa375a2dd021404f90a62e44efaaf957"}, + {file = "uvloop-0.19.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5f17766fb6da94135526273080f3455a112f82570b2ee5daa64d682387fe0dcd"}, + {file = "uvloop-0.19.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4ce6b0af8f2729a02a5d1575feacb2a94fc7b2e983868b009d51c9a9d2149bef"}, + {file = "uvloop-0.19.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:31e672bb38b45abc4f26e273be83b72a0d28d074d5b370fc4dcf4c4eb15417d2"}, + {file = "uvloop-0.19.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:570fc0ed613883d8d30ee40397b79207eedd2624891692471808a95069a007c1"}, + {file = "uvloop-0.19.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5138821e40b0c3e6c9478643b4660bd44372ae1e16a322b8fc07478f92684e24"}, + {file = "uvloop-0.19.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:91ab01c6cd00e39cde50173ba4ec68a1e578fee9279ba64f5221810a9e786533"}, + {file = "uvloop-0.19.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:47bf3e9312f63684efe283f7342afb414eea4d3011542155c7e625cd799c3b12"}, + {file = "uvloop-0.19.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:da8435a3bd498419ee8c13c34b89b5005130a476bda1d6ca8cfdde3de35cd650"}, + {file = "uvloop-0.19.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:02506dc23a5d90e04d4f65c7791e65cf44bd91b37f24cfc3ef6cf2aff05dc7ec"}, + {file = "uvloop-0.19.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2693049be9d36fef81741fddb3f441673ba12a34a704e7b4361efb75cf30befc"}, + {file = "uvloop-0.19.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7010271303961c6f0fe37731004335401eb9075a12680738731e9c92ddd96ad6"}, + {file = "uvloop-0.19.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:5daa304d2161d2918fa9a17d5635099a2f78ae5b5960e742b2fcfbb7aefaa593"}, + {file = "uvloop-0.19.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7207272c9520203fea9b93843bb775d03e1cf88a80a936ce760f60bb5add92f3"}, + {file = "uvloop-0.19.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:78ab247f0b5671cc887c31d33f9b3abfb88d2614b84e4303f1a63b46c046c8bd"}, + {file = "uvloop-0.19.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:472d61143059c84947aa8bb74eabbace30d577a03a1805b77933d6bd13ddebbd"}, + {file = "uvloop-0.19.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45bf4c24c19fb8a50902ae37c5de50da81de4922af65baf760f7c0c42e1088be"}, + {file = "uvloop-0.19.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271718e26b3e17906b28b67314c45d19106112067205119dddbd834c2b7ce797"}, + {file = "uvloop-0.19.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:34175c9fd2a4bc3adc1380e1261f60306344e3407c20a4d684fd5f3be010fa3d"}, + {file = "uvloop-0.19.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e27f100e1ff17f6feeb1f33968bc185bf8ce41ca557deee9d9bbbffeb72030b7"}, + {file = "uvloop-0.19.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:13dfdf492af0aa0a0edf66807d2b465607d11c4fa48f4a1fd41cbea5b18e8e8b"}, + {file = "uvloop-0.19.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6e3d4e85ac060e2342ff85e90d0c04157acb210b9ce508e784a944f852a40e67"}, + {file = "uvloop-0.19.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca4956c9ab567d87d59d49fa3704cf29e37109ad348f2d5223c9bf761a332e7"}, + {file = "uvloop-0.19.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f467a5fd23b4fc43ed86342641f3936a68ded707f4627622fa3f82a120e18256"}, + {file = "uvloop-0.19.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:492e2c32c2af3f971473bc22f086513cedfc66a130756145a931a90c3958cb17"}, + {file = "uvloop-0.19.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2df95fca285a9f5bfe730e51945ffe2fa71ccbfdde3b0da5772b4ee4f2e770d5"}, + {file = "uvloop-0.19.0.tar.gz", hash = "sha256:0246f4fd1bf2bf702e06b0d45ee91677ee5c31242f39aab4ea6fe0c51aedd0fd"}, +] + +[package.extras] +docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] +test = ["Cython (>=0.29.36,<0.30.0)", "aiohttp (==3.9.0b0)", "aiohttp (>=3.8.1)", "flake8 (>=5.0,<6.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=23.0.0,<23.1.0)", "pycodestyle (>=2.9.0,<2.10.0)"] + +[[package]] +name = "xdg" +version = "6.0.0" +description = "Variables defined by the XDG Base Directory Specification" +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "xdg-6.0.0-py3-none-any.whl", hash = "sha256:df3510755b4395157fc04fc3b02467c777f3b3ca383257397f09ab0d4c16f936"}, + {file = "xdg-6.0.0.tar.gz", hash = "sha256:24278094f2d45e846d1eb28a2ebb92d7b67fc0cab5249ee3ce88c95f649a1c92"}, +] + +[[package]] +name = "yarl" +version = "1.9.4" +description = "Yet another URL library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "yarl-1.9.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a8c1df72eb746f4136fe9a2e72b0c9dc1da1cbd23b5372f94b5820ff8ae30e0e"}, + {file = "yarl-1.9.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a3a6ed1d525bfb91b3fc9b690c5a21bb52de28c018530ad85093cc488bee2dd2"}, + {file = "yarl-1.9.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c38c9ddb6103ceae4e4498f9c08fac9b590c5c71b0370f98714768e22ac6fa66"}, + {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d9e09c9d74f4566e905a0b8fa668c58109f7624db96a2171f21747abc7524234"}, + {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8477c1ee4bd47c57d49621a062121c3023609f7a13b8a46953eb6c9716ca392"}, + {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5ff2c858f5f6a42c2a8e751100f237c5e869cbde669a724f2062d4c4ef93551"}, + {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:357495293086c5b6d34ca9616a43d329317feab7917518bc97a08f9e55648455"}, + {file = "yarl-1.9.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54525ae423d7b7a8ee81ba189f131054defdb122cde31ff17477951464c1691c"}, + {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:801e9264d19643548651b9db361ce3287176671fb0117f96b5ac0ee1c3530d53"}, + {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e516dc8baf7b380e6c1c26792610230f37147bb754d6426462ab115a02944385"}, + {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:7d5aaac37d19b2904bb9dfe12cdb08c8443e7ba7d2852894ad448d4b8f442863"}, + {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:54beabb809ffcacbd9d28ac57b0db46e42a6e341a030293fb3185c409e626b8b"}, + {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bac8d525a8dbc2a1507ec731d2867025d11ceadcb4dd421423a5d42c56818541"}, + {file = "yarl-1.9.4-cp310-cp310-win32.whl", hash = "sha256:7855426dfbddac81896b6e533ebefc0af2f132d4a47340cee6d22cac7190022d"}, + {file = "yarl-1.9.4-cp310-cp310-win_amd64.whl", hash = "sha256:848cd2a1df56ddbffeb375535fb62c9d1645dde33ca4d51341378b3f5954429b"}, + {file = "yarl-1.9.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:35a2b9396879ce32754bd457d31a51ff0a9d426fd9e0e3c33394bf4b9036b099"}, + {file = "yarl-1.9.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c7d56b293cc071e82532f70adcbd8b61909eec973ae9d2d1f9b233f3d943f2c"}, + {file = "yarl-1.9.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d8a1c6c0be645c745a081c192e747c5de06e944a0d21245f4cf7c05e457c36e0"}, + {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b3c1ffe10069f655ea2d731808e76e0f452fc6c749bea04781daf18e6039525"}, + {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:549d19c84c55d11687ddbd47eeb348a89df9cb30e1993f1b128f4685cd0ebbf8"}, + {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7409f968456111140c1c95301cadf071bd30a81cbd7ab829169fb9e3d72eae9"}, + {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e23a6d84d9d1738dbc6e38167776107e63307dfc8ad108e580548d1f2c587f42"}, + {file = "yarl-1.9.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d8b889777de69897406c9fb0b76cdf2fd0f31267861ae7501d93003d55f54fbe"}, + {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:03caa9507d3d3c83bca08650678e25364e1843b484f19986a527630ca376ecce"}, + {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4e9035df8d0880b2f1c7f5031f33f69e071dfe72ee9310cfc76f7b605958ceb9"}, + {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:c0ec0ed476f77db9fb29bca17f0a8fcc7bc97ad4c6c1d8959c507decb22e8572"}, + {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:ee04010f26d5102399bd17f8df8bc38dc7ccd7701dc77f4a68c5b8d733406958"}, + {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:49a180c2e0743d5d6e0b4d1a9e5f633c62eca3f8a86ba5dd3c471060e352ca98"}, + {file = "yarl-1.9.4-cp311-cp311-win32.whl", hash = "sha256:81eb57278deb6098a5b62e88ad8281b2ba09f2f1147c4767522353eaa6260b31"}, + {file = "yarl-1.9.4-cp311-cp311-win_amd64.whl", hash = "sha256:d1d2532b340b692880261c15aee4dc94dd22ca5d61b9db9a8a361953d36410b1"}, + {file = "yarl-1.9.4-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0d2454f0aef65ea81037759be5ca9947539667eecebca092733b2eb43c965a81"}, + {file = "yarl-1.9.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:44d8ffbb9c06e5a7f529f38f53eda23e50d1ed33c6c869e01481d3fafa6b8142"}, + {file = "yarl-1.9.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:aaaea1e536f98754a6e5c56091baa1b6ce2f2700cc4a00b0d49eca8dea471074"}, + {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3777ce5536d17989c91696db1d459574e9a9bd37660ea7ee4d3344579bb6f129"}, + {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fc5fc1eeb029757349ad26bbc5880557389a03fa6ada41703db5e068881e5f2"}, + {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea65804b5dc88dacd4a40279af0cdadcfe74b3e5b4c897aa0d81cf86927fee78"}, + {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa102d6d280a5455ad6a0f9e6d769989638718e938a6a0a2ff3f4a7ff8c62cc4"}, + {file = "yarl-1.9.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09efe4615ada057ba2d30df871d2f668af661e971dfeedf0c159927d48bbeff0"}, + {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:008d3e808d03ef28542372d01057fd09168419cdc8f848efe2804f894ae03e51"}, + {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:6f5cb257bc2ec58f437da2b37a8cd48f666db96d47b8a3115c29f316313654ff"}, + {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:992f18e0ea248ee03b5a6e8b3b4738850ae7dbb172cc41c966462801cbf62cf7"}, + {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:0e9d124c191d5b881060a9e5060627694c3bdd1fe24c5eecc8d5d7d0eb6faabc"}, + {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3986b6f41ad22988e53d5778f91855dc0399b043fc8946d4f2e68af22ee9ff10"}, + {file = "yarl-1.9.4-cp312-cp312-win32.whl", hash = "sha256:4b21516d181cd77ebd06ce160ef8cc2a5e9ad35fb1c5930882baff5ac865eee7"}, + {file = "yarl-1.9.4-cp312-cp312-win_amd64.whl", hash = "sha256:a9bd00dc3bc395a662900f33f74feb3e757429e545d831eef5bb280252631984"}, + {file = "yarl-1.9.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:63b20738b5aac74e239622d2fe30df4fca4942a86e31bf47a81a0e94c14df94f"}, + {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7d7f7de27b8944f1fee2c26a88b4dabc2409d2fea7a9ed3df79b67277644e17"}, + {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c74018551e31269d56fab81a728f683667e7c28c04e807ba08f8c9e3bba32f14"}, + {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ca06675212f94e7a610e85ca36948bb8fc023e458dd6c63ef71abfd482481aa5"}, + {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aef935237d60a51a62b86249839b51345f47564208c6ee615ed2a40878dccdd"}, + {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b134fd795e2322b7684155b7855cc99409d10b2e408056db2b93b51a52accc7"}, + {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d25039a474c4c72a5ad4b52495056f843a7ff07b632c1b92ea9043a3d9950f6e"}, + {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f7d6b36dd2e029b6bcb8a13cf19664c7b8e19ab3a58e0fefbb5b8461447ed5ec"}, + {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:957b4774373cf6f709359e5c8c4a0af9f6d7875db657adb0feaf8d6cb3c3964c"}, + {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:d7eeb6d22331e2fd42fce928a81c697c9ee2d51400bd1a28803965883e13cead"}, + {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:6a962e04b8f91f8c4e5917e518d17958e3bdee71fd1d8b88cdce74dd0ebbf434"}, + {file = "yarl-1.9.4-cp37-cp37m-win32.whl", hash = "sha256:f3bc6af6e2b8f92eced34ef6a96ffb248e863af20ef4fde9448cc8c9b858b749"}, + {file = "yarl-1.9.4-cp37-cp37m-win_amd64.whl", hash = "sha256:ad4d7a90a92e528aadf4965d685c17dacff3df282db1121136c382dc0b6014d2"}, + {file = "yarl-1.9.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ec61d826d80fc293ed46c9dd26995921e3a82146feacd952ef0757236fc137be"}, + {file = "yarl-1.9.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8be9e837ea9113676e5754b43b940b50cce76d9ed7d2461df1af39a8ee674d9f"}, + {file = "yarl-1.9.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:bef596fdaa8f26e3d66af846bbe77057237cb6e8efff8cd7cc8dff9a62278bbf"}, + {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d47552b6e52c3319fede1b60b3de120fe83bde9b7bddad11a69fb0af7db32f1"}, + {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fc30f71689d7fc9168b92788abc977dc8cefa806909565fc2951d02f6b7d57"}, + {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4aa9741085f635934f3a2583e16fcf62ba835719a8b2b28fb2917bb0537c1dfa"}, + {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:206a55215e6d05dbc6c98ce598a59e6fbd0c493e2de4ea6cc2f4934d5a18d130"}, + {file = "yarl-1.9.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07574b007ee20e5c375a8fe4a0789fad26db905f9813be0f9fef5a68080de559"}, + {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5a2e2433eb9344a163aced6a5f6c9222c0786e5a9e9cac2c89f0b28433f56e23"}, + {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:6ad6d10ed9b67a382b45f29ea028f92d25bc0bc1daf6c5b801b90b5aa70fb9ec"}, + {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:6fe79f998a4052d79e1c30eeb7d6c1c1056ad33300f682465e1b4e9b5a188b78"}, + {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a825ec844298c791fd28ed14ed1bffc56a98d15b8c58a20e0e08c1f5f2bea1be"}, + {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8619d6915b3b0b34420cf9b2bb6d81ef59d984cb0fde7544e9ece32b4b3043c3"}, + {file = "yarl-1.9.4-cp38-cp38-win32.whl", hash = "sha256:686a0c2f85f83463272ddffd4deb5e591c98aac1897d65e92319f729c320eece"}, + {file = "yarl-1.9.4-cp38-cp38-win_amd64.whl", hash = "sha256:a00862fb23195b6b8322f7d781b0dc1d82cb3bcac346d1e38689370cc1cc398b"}, + {file = "yarl-1.9.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:604f31d97fa493083ea21bd9b92c419012531c4e17ea6da0f65cacdcf5d0bd27"}, + {file = "yarl-1.9.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8a854227cf581330ffa2c4824d96e52ee621dd571078a252c25e3a3b3d94a1b1"}, + {file = "yarl-1.9.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ba6f52cbc7809cd8d74604cce9c14868306ae4aa0282016b641c661f981a6e91"}, + {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6327976c7c2f4ee6816eff196e25385ccc02cb81427952414a64811037bbc8b"}, + {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8397a3817d7dcdd14bb266283cd1d6fc7264a48c186b986f32e86d86d35fbac5"}, + {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e0381b4ce23ff92f8170080c97678040fc5b08da85e9e292292aba67fdac6c34"}, + {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23d32a2594cb5d565d358a92e151315d1b2268bc10f4610d098f96b147370136"}, + {file = "yarl-1.9.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ddb2a5c08a4eaaba605340fdee8fc08e406c56617566d9643ad8bf6852778fc7"}, + {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:26a1dc6285e03f3cc9e839a2da83bcbf31dcb0d004c72d0730e755b33466c30e"}, + {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:18580f672e44ce1238b82f7fb87d727c4a131f3a9d33a5e0e82b793362bf18b4"}, + {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:29e0f83f37610f173eb7e7b5562dd71467993495e568e708d99e9d1944f561ec"}, + {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:1f23e4fe1e8794f74b6027d7cf19dc25f8b63af1483d91d595d4a07eca1fb26c"}, + {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:db8e58b9d79200c76956cefd14d5c90af54416ff5353c5bfd7cbe58818e26ef0"}, + {file = "yarl-1.9.4-cp39-cp39-win32.whl", hash = "sha256:c7224cab95645c7ab53791022ae77a4509472613e839dab722a72abe5a684575"}, + {file = "yarl-1.9.4-cp39-cp39-win_amd64.whl", hash = "sha256:824d6c50492add5da9374875ce72db7a0733b29c2394890aef23d533106e2b15"}, + {file = "yarl-1.9.4-py3-none-any.whl", hash = "sha256:928cecb0ef9d5a7946eb6ff58417ad2fe9375762382f1bf5c55e61645f2c43ad"}, + {file = "yarl-1.9.4.tar.gz", hash = "sha256:566db86717cf8080b99b58b083b773a908ae40f06681e87e589a976faf8246bf"}, +] + +[package.dependencies] +idna = ">=2.0" +multidict = ">=4.0" + +[[package]] +name = "zipp" +version = "3.20.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.8" +files = [ + {file = "zipp-3.20.0-py3-none-any.whl", hash = "sha256:58da6168be89f0be59beb194da1250516fdaa062ccebd30127ac65d30045e10d"}, + {file = "zipp-3.20.0.tar.gz", hash = "sha256:0145e43d89664cfe1a2e533adc75adafed82fe2da404b4bbb6b026c0157bdb31"}, +] + +[package.extras] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"] + +[metadata] +lock-version = "2.0" +python-versions = "^3.8.1" +content-hash = "b9e771cacb531138edabc9b86df2a9cfc8f587efc1fcc1ae4ae1f405fcefe4a7" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d1b7c4a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,119 @@ +[tool.poetry] +name = "textual-webterm" +version = "0.1.9" +description = "Serve terminal sessions over the web" +authors = ["Will McGugan "] +license = "MIT" +readme = "README.md" +packages = [{include = "textual_webterm", from = "src"}] + +[tool.poetry.dependencies] +python = "^3.9" +textual-serve = "^1.1.0" +aiohttp = "^3.13.0" +uvloop = { version = "^0.22.0", markers = "sys_platform != 'win32'" } +click = "^8.1.7" +pydantic = "^2.7.0" +xdg = "^6.0.0" +msgpack = "^1.1.0" +importlib-metadata = ">=6.0.0" +httpx = ">=0.27.0" +tomli = { version = "^2.0.1", python = "<3.11" } +pyyaml = "^6.0.0" + +[tool.poetry.group.dev.dependencies] +pytest = "^8.0.0" +pytest-asyncio = "^0.24.0" +pytest-cov = "^6.0.0" +pytest-timeout = "^2.3.0" +ruff = "^0.9.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry.scripts] +textual-webterm = "textual_webterm.cli:app" + +[tool.ruff] +line-length = 100 +target-version = "py39" +src = ["src"] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # Pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade + "ARG", # flake8-unused-arguments + "SIM", # flake8-simplify + "TC", # flake8-type-checking + "PTH", # flake8-use-pathlib + "ERA", # eradicate (commented out code) + "PL", # Pylint + "RUF", # Ruff-specific rules +] +ignore = [ + "E501", # line too long (handled by formatter) + "PLR0912", # too many branches + "PLR0913", # too many arguments + "PLR0915", # too many statements + "PLR2004", # magic value comparison + "ARG001", # unused function argument + "ARG002", # unused method argument + "PLC0415", # import not at top level (needed for optional deps) +] + +[tool.ruff.lint.isort] +known-first-party = ["textual_webterm"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +timeout = 30 +addopts = [ + "-v", + "--tb=short", + "--strict-markers", +] +markers = [ + "slow: marks tests as slow (deselect with '-m \"not slow\"')", + "integration: marks tests as integration tests", +] + +[tool.coverage.run] +source = ["src/textual_webterm"] +branch = true +omit = [ + "*/tests/*", + "*/__pycache__/*", + # Integration-heavy modules that require running servers/processes + "*/local_server.py", + "*/app_session.py", + "*/terminal_session.py", + "*/exit_poller.py", + "*/poller.py", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise NotImplementedError", + "if TYPE_CHECKING:", + "if __name__ == .__main__.:", + "assert ", +] +# Unit test coverage target - integration tests would add ~20% more +fail_under = 80 diff --git a/src/textual_webterm/__init__.py b/src/textual_webterm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/textual_webterm/_two_way_dict.py b/src/textual_webterm/_two_way_dict.py new file mode 100644 index 0000000..12fa7f0 --- /dev/null +++ b/src/textual_webterm/_two_way_dict.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import threading +from typing import Generic, TypeVar + +Key = TypeVar("Key") +Value = TypeVar("Value") + + +class TwoWayDict(Generic[Key, Value]): + """ + A two-way mapping offering O(1) access in both directions. + + Wraps two dictionaries and uses them to provide efficient access to + both values (given keys) and keys (given values). + """ + + def __init__(self, initial: dict[Key, Value] | None = None) -> None: + initial_data = {} if initial is None else initial + self._forward: dict[Key, Value] = initial_data + self._reverse: dict[Value, Key] = {value: key for key, value in initial_data.items()} + self._lock = threading.RLock() + + def __setitem__(self, key: Key, value: Value) -> None: + with self._lock: + # If reassigning the same key, remove old reverse mapping first + old_value = self._forward.get(key) + if old_value is not None and old_value != value: + del self._reverse[old_value] + # Enforce 1:1 mapping: value must not already map to a different key + existing_key = self._reverse.get(value) + if existing_key is not None and existing_key != key: + raise ValueError(f"Value {value!r} already mapped to key {existing_key!r}") + self._forward[key] = value + self._reverse[value] = key + + def __delitem__(self, key: Key) -> None: + with self._lock: + value = self._forward[key] + self._forward.__delitem__(key) + self._reverse.__delitem__(value) + + def __iter__(self): + with self._lock: + return iter(dict(self._forward)) + + def get(self, key: Key) -> Value | None: + """Given a key, efficiently lookup and return the associated value. + + Args: + key: The key + + Returns: + The value + """ + with self._lock: + return self._forward.get(key) + + def get_key(self, value: Value) -> Key | None: + """Given a value, efficiently lookup and return the associated key. + + Args: + value: The value + + Returns: + The key + """ + with self._lock: + return self._reverse.get(value) + + def contains_value(self, value: Value) -> bool: + """Check if `value` is a value within this TwoWayDict. + + Args: + value: The value to check. + + Returns: + True if the value is within the values of this dict. + """ + with self._lock: + return value in self._reverse + + def __len__(self): + with self._lock: + return len(self._forward) + + def __contains__(self, item: Key) -> bool: + with self._lock: + return item in self._forward diff --git a/src/textual_webterm/app_session.py b/src/textual_webterm/app_session.py new file mode 100644 index 0000000..cf7f263 --- /dev/null +++ b/src/textual_webterm/app_session.py @@ -0,0 +1,330 @@ +from __future__ import annotations + +import asyncio +import io +import json +import logging +import os +from asyncio import IncompleteReadError, StreamReader, StreamWriter +from datetime import timedelta +from enum import Enum, auto +from time import monotonic +from typing import TYPE_CHECKING + +import rich.repr +from importlib_metadata import version + +from . import constants +from .session import Session, SessionConnector + +if TYPE_CHECKING: + from asyncio.subprocess import Process + from pathlib import Path + + from .types import Meta, SessionID + +log = logging.getLogger("textual-web") + +# Maximum payload size to prevent memory exhaustion (16MB) +MAX_PAYLOAD_SIZE = 16 * 1024 * 1024 + + +class ProcessState(Enum): + """The state of a process.""" + + PENDING = auto() + RUNNING = auto() + CLOSING = auto() + CLOSED = auto() + + def __repr__(self) -> str: + return self.name + + +@rich.repr.auto(angular=True) +class AppSession(Session): + """Runs a single app process.""" + + def __init__( + self, + working_directory: Path, + command: str, + session_id: SessionID, + devtools: bool = False, + ) -> None: + self.working_directory = working_directory + self.command = command + self.session_id = session_id + self.devtools = devtools + self.start_time: float | None = None + self.end_time: float | None = None + self._process: Process | None = None + self._task: asyncio.Task | None = None + + super().__init__() + self._state = ProcessState.PENDING + + @property + def process(self) -> Process: + """The asyncio (sub)process""" + assert self._process is not None + return self._process + + @property + def stdin(self) -> StreamWriter: + """The processes stdin.""" + assert self._process is not None + assert self._process.stdin is not None + return self._process.stdin + + @property + def stdout(self) -> StreamReader: + """The process' stdout.""" + assert self._process is not None + assert self._process.stdout is not None + return self._process.stdout + + @property + def stderr(self) -> StreamReader: + """The process' stderr.""" + assert self._process is not None + assert self._process.stderr is not None + return self._process.stderr + + @property + def task(self) -> asyncio.Task: + """Session task.""" + assert self._task is not None + return self._task + + @property + def state(self) -> ProcessState: + """Current running state.""" + return self._state + + @state.setter + def state(self, state: ProcessState) -> None: + self._state = state + run_time = self.run_time + log.debug( + "%r state=%r run_time=%s", + self, + self.state, + "0" if run_time is None else timedelta(seconds=int(run_time)), + ) + + @property + def run_time(self) -> float | None: + """Time process was running, or `None` if it hasn't started.""" + if self.end_time is not None: + assert self.start_time is not None + return self.end_time - self.start_time + elif self.start_time is not None: + return monotonic() - self.start_time + else: + return None + + def is_running(self) -> bool: + """Check if the app session is still running.""" + return self._state == ProcessState.RUNNING + + def __rich_repr__(self) -> rich.repr.Result: + yield self.command + yield "id", self.session_id + if self._process is not None: + yield "returncode", self._process.returncode, None + + async def open(self, width: int = 80, height: int = 24) -> None: + """Open the process.""" + environment = dict(os.environ.copy()) + environment["TEXTUAL_DRIVER"] = "textual.drivers.web_driver:WebDriver" + environment["TEXTUAL_FPS"] = "60" + environment["TEXTUAL_COLOR_SYSTEM"] = "truecolor" + environment["TERM_PROGRAM"] = "textual-web" + environment["TERM_PROGRAM_VERSION"] = version("textual-web") + environment["COLUMNS"] = str(width) + environment["ROWS"] = str(height) + if self.devtools: + environment["TEXTUAL"] = "debug,devtools" + environment["TEXTUAL_LOG"] = "textual.log" + + # Use cwd parameter instead of os.chdir() for thread safety + self._process = await asyncio.create_subprocess_shell( + self.command, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=environment, + cwd=str(self.working_directory), + ) + await self.set_terminal_size(width, height) + log.debug("opened %r; %r", self.command, self._process) + self.start_time = monotonic() + + async def start(self, connector: SessionConnector) -> asyncio.Task: + """Start a task to run the process.""" + if self._task is not None: + raise RuntimeError("AppSession.start() called while already running") + self._connector = connector + self._task = asyncio.create_task(self.run()) + return self._task + + async def close(self) -> None: + """Close the process.""" + self.state = ProcessState.CLOSING + await self.send_meta({"type": "quit"}) + + async def wait(self) -> None: + """Wait for the process to finish (call close first).""" + if self._task: + await self._task + self._task = None + + async def set_terminal_size(self, width: int, height: int) -> None: + """Set the terminal size for the process. + + Args: + width: Width in cells. + height: Height in cells. + """ + await self.send_meta( + { + "type": "resize", + "width": width, + "height": height, + } + ) + + async def run(self) -> None: + """This loop reads stdout from the process and relays it through the websocket.""" + + self.state = ProcessState.RUNNING + + META = b"M" + DATA = b"D" + BINARY_ENCODED = b"P" + + stderr_data = io.BytesIO() + + async def read_stderr() -> None: + """Task to read stderr.""" + try: + while True: + data = await self.stderr.read(1024 * 4) + if not data: + break + stderr_data.write(data) + except asyncio.CancelledError: + pass + + stderr_task = asyncio.create_task(read_stderr()) + readexactly = self.stdout.readexactly + from_bytes = int.from_bytes + + on_data = self._connector.on_data + on_meta = self._connector.on_meta + on_binary_encoded_message = self._connector.on_binary_encoded_message + try: + ready = False + for _ in range(10): + line = await self.stdout.readline() + if not line: + break + if line == b"__GANGLION__\n": + ready = True + break + if ready: + while True: + type_bytes = await readexactly(1) + size_bytes = await readexactly(4) + size = from_bytes(size_bytes, "big") + if size > MAX_PAYLOAD_SIZE: + log.error("Payload size %d exceeds limit %d", size, MAX_PAYLOAD_SIZE) + break + payload = await readexactly(size) + if type_bytes == DATA: + await on_data(payload) + elif type_bytes == META: + meta_data = json.loads(payload) + meta_type = meta_data.get("type") + if meta_type in {"exit", "blur", "focus"}: + await self.send_meta({"type": meta_type}) + else: + await on_meta(meta_data) + elif type_bytes == BINARY_ENCODED: + await on_binary_encoded_message(payload) + + except IncompleteReadError: + # Incomplete read means that the stream was closed + pass + except asyncio.CancelledError: + pass + finally: + stderr_task.cancel() + await stderr_task + + self.end_time = monotonic() + self.state = ProcessState.CLOSED + + stderr_message = stderr_data.getvalue().decode("utf-8", errors="replace") + if ( + self._process is not None + and self._process.returncode != 0 + and constants.DEBUG + and stderr_message + ): + log.warning(stderr_message) + + await self._connector.on_close() + + @classmethod + def encode_packet(cls, packet_type: bytes, payload: bytes) -> bytes: + """Encode a packet. + + Args: + packet_type: The packet type (b"D" for data or b"M" for meta) + payload: The payload. + + Returns: + Data as bytes. + """ + return b"%s%s%s" % (packet_type, len(payload).to_bytes(4, "big"), payload) + + async def send_bytes(self, data: bytes) -> bool: + """Send bytes to process. + + Args: + data: Data to send. + + Returns: + True if the data was sent, otherwise False. + """ + if self._process is None or self._process.stdin is None: + return False + stdin = self._process.stdin + try: + stdin.write(self.encode_packet(b"D", data)) + await stdin.drain() + except (RuntimeError, ConnectionResetError, BrokenPipeError): + return False + return True + + async def send_meta(self, data: Meta) -> bool: + """Send meta information to process. + + Args: + data: Meta dict to send. + + Returns: + True if the data was sent, otherwise False. + """ + if self._process is None or self._process.stdin is None: + return False + stdin = self._process.stdin + data_bytes = json.dumps(data).encode("utf-8") + try: + stdin.write(self.encode_packet(b"M", data_bytes)) + await stdin.drain() + except (RuntimeError, ConnectionResetError, BrokenPipeError): + return False + return True diff --git a/src/textual_webterm/cli.py b/src/textual_webterm/cli.py new file mode 100644 index 0000000..f503da2 --- /dev/null +++ b/src/textual_webterm/cli.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +import asyncio +import importlib +import importlib.util +import logging +import os +import sys +from pathlib import Path + +import click +from importlib_metadata import version +from rich.logging import RichHandler + +from . import constants +from .local_server import LocalServer + +FORMAT = "%(message)s" +logging.basicConfig( + level="DEBUG" if constants.DEBUG else "INFO", + format=FORMAT, + datefmt="[%X]", + handlers=[RichHandler(show_path=False)], +) + +log = logging.getLogger("textual-webterm") + + +def _is_file_path(path: str) -> bool: + """Check if path looks like a file path (vs module path).""" + return path.endswith(".py") or "/" in path or "\\" in path + + +def parse_app_path(app_path: str) -> tuple[str, str]: + """Parse an app path like 'module.path:ClassName' or 'path/to/file.py:ClassName'. + + Returns: + Tuple of (module_or_file, class_name) + """ + if ":" not in app_path: + raise click.BadParameter( + f"Invalid app path '{app_path}'. Expected format: 'module.path:ClassName' or 'path/to/file.py:ClassName'" + ) + + module_part, class_name = app_path.rsplit(":", 1) + return module_part, class_name + + +def load_app_class(app_path: str): + """Load a Textual App class from a module path. + + Args: + app_path: Path like 'module.path:ClassName' or 'path/to/file.py:ClassName' + + Returns: + The App class + """ + module_part, class_name = parse_app_path(app_path) + + # Check if it's a file path or module path + if _is_file_path(module_part): + # File path - load from file + file_path = Path(module_part).resolve() + if not file_path.exists(): + raise click.BadParameter(f"File not found: {file_path}") + + # Add parent directory to sys.path for imports + parent_dir = str(file_path.parent) + if parent_dir not in sys.path: + sys.path.insert(0, parent_dir) + + # Import the module + module_name = file_path.stem + spec = importlib.util.spec_from_file_location(module_name, file_path) + if spec is None or spec.loader is None: + raise click.BadParameter(f"Could not load module from {file_path}") + + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + else: + # Module path - import normally + try: + module = importlib.import_module(module_part) + except ImportError as e: + raise click.BadParameter(f"Could not import module '{module_part}': {e}") from e + + # Get the class + if not hasattr(module, class_name): + raise click.BadParameter(f"Module '{module_part}' has no attribute '{class_name}'") + + app_class = getattr(module, class_name) + return app_class + + +@click.command() +@click.version_option(version("textual-webterm")) +@click.argument("command", required=False) +@click.option("--port", "-p", type=int, help="Port for server.", default=8080) +@click.option("--host", "-H", help="Host for server.", default="0.0.0.0") +@click.option( + "--app", + "-a", + "app_path", + help="Load a Textual app from module:ClassName (e.g., 'myapp:MyApp' or 'path/to/app.py:MyApp')", +) +@click.option( + "--landing-manifest", + "-L", + "landing_manifest", + type=click.Path(exists=True, dir_okay=False, readable=True, path_type=Path), + help="YAML manifest describing landing page tiles (slug/name/command).", +) +@click.option( + "--compose-manifest", + "-C", + "compose_manifest", + type=click.Path(exists=True, dir_okay=False, readable=True, path_type=Path), + help='Docker compose YAML; services with label "webterm-command" become landing tiles.', +) +def app( + command: str | None, + port: int, + host: str, + app_path: str | None, + landing_manifest: Path | None, + compose_manifest: Path | None, +) -> None: + """Serve a terminal or Textual app over HTTP/WebSocket. + + COMMAND: Shell command to run in terminal (default: $SHELL) + + Examples: + + \b + textual-webterm # Serve default shell + textual-webterm htop # Serve htop in terminal + textual-webterm --app mymodule:MyApp # Serve a Textual app from module + textual-webterm -a ./calculator.py:CalculatorApp # Serve from file + """ + VERSION = version("textual-webterm") + log.info(f"textual-webterm v{VERSION}") + + if constants.DEBUG: + log.warning("DEBUG env var is set; logs may be verbose!") + + from .config import default_config, load_compose_manifest, load_landing_yaml + + _config = default_config() + + landing_apps: list = [] + if landing_manifest: + landing_apps = load_landing_yaml(landing_manifest) + elif compose_manifest: + landing_apps = load_compose_manifest(compose_manifest) + + server = LocalServer( + "./", + _config, + host=host, + port=port, + landing_apps=landing_apps, + ) + for app_entry in landing_apps: + server.add_terminal(app_entry.name, app_entry.command, slug=app_entry.slug) + + if app_path: + # Load and run as Textual app from module:class + try: + app_class = load_app_class(app_path) + except click.BadParameter as e: + log.error(str(e)) + sys.exit(1) + + # Create a command that runs the app using python -m runpy for safety + module_part, class_name = parse_app_path(app_path) + if _is_file_path(module_part): + # File path - use absolute path and proper escaping + file_path = Path(module_part).resolve() + # Use runpy to safely run the file + escaped_path = str(file_path).replace("'", "'\"'\"'") + escaped_class = class_name.replace("'", "'\"'\"'") + run_command = f'python3 -c \'import sys; sys.path.insert(0, "{file_path.parent}"); exec(open("{escaped_path}").read()); {escaped_class}().run()\'' + else: + # Module path - validate module and class names + if not module_part.replace(".", "").replace("_", "").isalnum(): + log.error(f"Invalid module path: {module_part}") + sys.exit(1) + if not class_name.isidentifier(): + log.error(f"Invalid class name: {class_name}") + sys.exit(1) + run_command = ( + f'python3 -c "from {module_part} import {class_name}; {class_name}().run()"' + ) + + app_name = getattr(app_class, "TITLE", None) or class_name + server.add_app(app_name, run_command, "") + log.info(f"Serving Textual app: {app_path}") + elif command: + # Run command as terminal + server.add_terminal("Terminal", command, "") + log.info(f"Serving terminal: {command}") + elif not landing_apps: + # Run default shell + terminal_command = os.environ.get("SHELL", "/bin/sh") + server.add_terminal("Terminal", terminal_command, "") + log.info(f"Serving terminal: {terminal_command}") + + def _run_async(): + if constants.WINDOWS: + asyncio.run(server.run()) + else: + try: + import uvloop + except ImportError: + asyncio.run(server.run()) + else: + if sys.version_info >= (3, 11): + with asyncio.Runner(loop_factory=uvloop.new_event_loop) as runner: + runner.run(server.run()) + else: + uvloop.install() + asyncio.run(server.run()) + + _run_async() + + +if __name__ == "__main__": + app() diff --git a/src/textual_webterm/config.py b/src/textual_webterm/config.py new file mode 100644 index 0000000..9ec1320 --- /dev/null +++ b/src/textual_webterm/config.py @@ -0,0 +1,150 @@ +from os.path import expandvars +from pathlib import Path +from typing import Annotated + +try: + import tomllib as tomli # py311+ +except ImportError: # pragma: no cover + import tomli +import yaml +from pydantic import BaseModel, Field +from pydantic.functional_validators import AfterValidator + +from .identity import generate +from .slugify import slugify + +ExpandVarsStr = Annotated[str, AfterValidator(expandvars)] + + +class App(BaseModel): + """Defines an application.""" + + name: str + slug: str = "" + path: ExpandVarsStr = "./" + color: str = "" + command: ExpandVarsStr = "" + terminal: bool = False + + +class Config(BaseModel): + """Root configuration model.""" + + apps: list[App] = Field(default_factory=list) + landing: list[App] = Field(default_factory=list) + + +def default_config() -> Config: + """Get a default empty configuration. + + Returns: + Configuration object. + """ + return Config() + + +def load_config(config_path: Path) -> Config: + """Load config from a path. + + Args: + config_path: Path to TOML configuration. + + Returns: + Config object. + """ + with Path(config_path).open("rb") as config_file: + config_data = tomli.load(config_file) + + def make_app(name, data: dict[str, object], terminal: bool = False) -> App: + data["name"] = name + data["terminal"] = terminal + if terminal: + data["slug"] = generate().lower() + elif not data.get("slug", ""): + data["slug"] = slugify(name) + + return App(**data) + + apps = [make_app(name, app) for name, app in config_data.get("app", {}).items()] + + apps += [ + make_app(name, app, terminal=True) for name, app in config_data.get("terminal", {}).items() + ] + + config = Config(apps=apps) + + return config + + +def load_landing_yaml(manifest_path: Path) -> list[App]: + """Load landing apps from YAML manifest. + + Expected schema: list of {name, slug, command, color?, path?, terminal?} + """ + with manifest_path.open("r", encoding="utf-8") as f: + data = yaml.safe_load(f) or [] + apps: list[App] = [] + for entry in data: + if not isinstance(entry, dict): + continue + name = entry.get("name") + command = entry.get("command") + if not name or not command: + continue + slug = entry.get("slug") or slugify(name) + apps.append( + App( + name=name, + slug=slug, + command=command, + path=entry.get("path", "./"), + color=entry.get("color", ""), + terminal=bool(entry.get("terminal", True)), + ) + ) + return apps + + +def _extract_label(labels: object, key: str) -> str | None: + """Extract a label value from either dict or list[str] forms.""" + if isinstance(labels, dict): + value = labels.get(key) + if isinstance(value, str): + return value + return None + if isinstance(labels, list): + for item in labels: + if not isinstance(item, str): + continue + if "=" in item: + k, v = item.split("=", 1) + if k == key: + return v + return None + + +def load_compose_manifest(manifest_path: Path) -> list[App]: + """Load landing apps from a docker-compose YAML file using label `webterm-command`.""" + with manifest_path.open("r", encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + services = data.get("services", {}) if isinstance(data, dict) else {} + apps: list[App] = [] + for name, service in services.items(): + if not isinstance(service, dict): + continue + labels = service.get("labels", {}) + command = _extract_label(labels, "webterm-command") + if not command: + continue + slug = slugify(name) + apps.append( + App( + name=name, + slug=slug, + command=command, + path=service.get("working_dir", "./"), + color="", + terminal=True, + ) + ) + return apps diff --git a/src/textual_webterm/constants.py b/src/textual_webterm/constants.py new file mode 100644 index 0000000..882a11e --- /dev/null +++ b/src/textual_webterm/constants.py @@ -0,0 +1,51 @@ +""" +Constants that we might want to expose via the public API. +""" + +from __future__ import annotations + +import os +import platform +from typing import Final + +get_environ = os.environ.get + +WINDOWS: Final = platform.system() == "Windows" +"""True if running on Windows.""" + + +def get_environ_bool(name: str) -> bool: + """Check an environment variable switch. + + Args: + name: Name of environment variable. + + Returns: + `True` if the env var is "1", otherwise `False`. + """ + has_environ = get_environ(name) == "1" + return has_environ + + +def get_environ_int(name: str, default: int) -> int: + """Retrieves an integer environment variable. + + Args: + name: Name of environment variable. + default: The value to use if the value is not set, or set to something other + than a valid integer. + + Returns: + The integer associated with the environment variable if it's set to a valid int + or the default value otherwise. + """ + try: + return int(os.environ[name]) + except KeyError: + return default + except ValueError: + return default + + +DEBUG: Final = get_environ_bool("DEBUG") +"""Enable debug mode.""" diff --git a/src/textual_webterm/exit_poller.py b/src/textual_webterm/exit_poller.py new file mode 100644 index 0000000..53d2877 --- /dev/null +++ b/src/textual_webterm/exit_poller.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import asyncio +import logging +from time import monotonic +from typing import TYPE_CHECKING + +EXIT_POLL_RATE = 5 + +log = logging.getLogger("textual-web") + +if TYPE_CHECKING: + from .local_server import LocalServer + + +class ExitPoller: + """Monitors the client for an idle state, and exits.""" + + def __init__(self, client: LocalServer, idle_wait: int) -> None: + self.client = client + self.idle_wait = idle_wait + self._task: asyncio.Task | None = None + self._idle_start_time: float | None = None + + def start(self) -> None: + """Start polling.""" + self._task = asyncio.create_task(self.run()) + + def stop(self) -> None: + """Stop polling""" + if self._task is not None: + self._task.cancel() + + async def run(self) -> None: + """Run the poller.""" + if not self.idle_wait: + return + try: + while True: + await asyncio.sleep(EXIT_POLL_RATE) + is_idle = not self.client.session_manager.sessions + if is_idle: + if self._idle_start_time is not None: + if monotonic() - self._idle_start_time > self.idle_wait: + log.info("Exiting due to --exit-on-idle") + self.client.force_exit() + else: + self._idle_start_time = monotonic() + else: + self._idle_start_time = None + + except asyncio.CancelledError: + pass diff --git a/src/textual_webterm/identity.py b/src/textual_webterm/identity.py new file mode 100644 index 0000000..b536e29 --- /dev/null +++ b/src/textual_webterm/identity.py @@ -0,0 +1,11 @@ +import os + +SEPARATOR = "-" +IDENTITY_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTUVWYZ" +IDENTITY_SIZE = 12 + + +def generate(size: int = IDENTITY_SIZE) -> str: + """Generate a random identifier.""" + alphabet = IDENTITY_ALPHABET + return "".join(alphabet[byte % 31] for byte in os.urandom(size)) diff --git a/src/textual_webterm/local_server.py b/src/textual_webterm/local_server.py new file mode 100644 index 0000000..085f47c --- /dev/null +++ b/src/textual_webterm/local_server.py @@ -0,0 +1,621 @@ +"""Local server implementation for serving terminals over HTTP/WebSocket.""" + +from __future__ import annotations + +import asyncio +import contextlib +import io +import json +import logging +import signal +from pathlib import Path +from typing import TYPE_CHECKING + +import aiohttp +from aiohttp import WSMsgType, web +from rich.ansi import AnsiDecoder +from rich.console import Console + +from . import constants +from .exit_poller import ExitPoller +from .identity import generate +from .poller import Poller +from .session import SessionConnector +from .session_manager import SessionManager +from .types import Meta, RouteKey, SessionID + +if TYPE_CHECKING: + from .config import Config + +log = logging.getLogger("textual-web") + +DISCONNECT_RESIZE = (132, 45) + +WEBTERM_STATIC_PATH = Path(__file__).parent / "static" + + +def _get_static_path() -> Path | None: + """Get the path to static assets from textual-serve.""" + try: + import textual_serve + + static_path = Path(textual_serve.__file__).parent / "static" + if static_path.exists(): + return static_path + except ImportError: + log.warning("textual-serve not installed - static assets unavailable") + return None + + +STATIC_PATH = _get_static_path() + + +class LocalClientConnector(SessionConnector): + """Local connector that handles communication between sessions and local server.""" + + def __init__(self, server: LocalServer, session_id: SessionID, route_key: RouteKey) -> None: + self.server = server + self.session_id = session_id + self.route_key = route_key + + async def on_data(self, data: bytes) -> None: + await self.server.handle_session_data(self.route_key, data) + + async def on_meta(self, meta: Meta) -> None: + meta_type = meta.get("type") + if meta_type == "open_url": + log.info("App requested to open URL: %s", meta.get("url")) + elif meta_type == "deliver_file_start": + log.info("App requested file delivery: %s", meta.get("path")) + else: + log.debug("Unknown meta type: %r. Full meta: %r", meta_type, meta) + + async def on_binary_encoded_message(self, payload: bytes) -> None: + await self.server.handle_binary_message(self.route_key, payload) + + async def on_close(self) -> None: + await self.server.handle_session_close(self.session_id, self.route_key) + + +class LocalServer: + """Manages local Textual apps and terminals without Ganglion server.""" + + def __init__( + self, + config_path: str, + config: Config, + host: str = "0.0.0.0", + port: int = 8080, + exit_on_idle: int = 0, + landing_apps: list | None = None, + ) -> None: + self.host = host + self.port = port + + abs_path = Path(config_path).absolute() + path = abs_path if abs_path.is_dir() else abs_path.parent + self.config = config + self._websocket_server: aiohttp.web.WebSocketResponse | None = None + self._poller = Poller() + self.session_manager = SessionManager(self._poller, path, config.apps) + self.exit_event = asyncio.Event() + self._task: asyncio.Task | None = None + self._shutdown_task: asyncio.Task | None = None + self._shutdown_started = False + self._loop: asyncio.AbstractEventLoop | None = None + self._exit_poller = ExitPoller(self, idle_wait=exit_on_idle) + + self._websocket_connections: dict[RouteKey, web.WebSocketResponse] = {} + self._landing_apps = landing_apps or [] + + @property + def app_count(self) -> int: + return len(self.session_manager.apps) + + def add_app(self, name: str, command: str, slug: str = "") -> None: + slug = slug or generate().lower() + self.session_manager.add_app(name, command, slug=slug) + + def add_terminal(self, name: str, command: str, slug: str = "") -> None: + if constants.WINDOWS: + log.warning("Sorry, textual-web does not currently support terminals on Windows") + return + slug = slug or generate().lower() + self.session_manager.add_app(name, command, slug=slug, terminal=True) + + async def run(self) -> None: + try: + await self._run() + finally: + self._exit_poller.stop() + if not constants.WINDOWS: + with contextlib.suppress(Exception): + self._poller.exit() + + def on_keyboard_interrupt(self) -> None: + print("\r\033[F") + log.info("Exit requested") + + if self._shutdown_started: + self.exit_event.set() + return + self._shutdown_started = True + + # Ensure we shut down sessions and websockets before stopping the server. + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + + if loop is not None: + if self._shutdown_task is None or self._shutdown_task.done(): + self._shutdown_task = asyncio.create_task(self._shutdown()) + return + + if self._loop is not None and self._loop.is_running(): + if self._shutdown_task is None or self._shutdown_task.done(): + + def _schedule() -> None: + self._shutdown_task = asyncio.create_task(self._shutdown()) + + self._loop.call_soon_threadsafe(_schedule) + return + + self.exit_event.set() + + async def _run(self) -> None: + loop = asyncio.get_event_loop() + self._loop = loop + + if constants.WINDOWS: + + def exit_handler(_sig, _frame) -> None: + self.on_keyboard_interrupt() + + signal.signal(signal.SIGINT, exit_handler) + else: + loop.add_signal_handler(signal.SIGINT, self.on_keyboard_interrupt) + self._poller.set_loop(loop) + self._poller.start() + + self._task = asyncio.create_task(self._run_local_server()) + self._exit_poller.start() + with contextlib.suppress(asyncio.CancelledError): + await self._task + + def _build_routes(self) -> list[web.AbstractRouteDef]: + routes: list[web.AbstractRouteDef] = [ + web.get("/ws/{route_key}", self._handle_websocket), + web.get("/screenshot.svg", self._handle_screenshot), + web.get("/health", self._handle_health_check), + web.get("/", self._handle_root), + ] + + if STATIC_PATH is not None and STATIC_PATH.exists(): + routes.append(web.static("/static", STATIC_PATH)) + log.info("Static assets served from: %s", STATIC_PATH) + else: + log.error("Static assets not found at %s - terminal UI will not work", STATIC_PATH) + + if WEBTERM_STATIC_PATH.exists(): + routes.append(web.static("/static-webterm", WEBTERM_STATIC_PATH)) + + return routes + + async def _shutdown(self) -> None: + try: + for ws in list(self._websocket_connections.values()): + with contextlib.suppress(Exception): + await ws.close() + await self.session_manager.close_all() + finally: + self.exit_event.set() + + async def _run_local_server(self) -> None: + app = web.Application() + app.add_routes(self._build_routes()) + + runner = web.AppRunner(app) + try: + await runner.setup() + site = web.TCPSite(runner, self.host, self.port) + await site.start() + + log.info("Local server started on %s:%s", self.host, self.port) + log.info("Available apps: %s", ", ".join(app.name for app in self.session_manager.apps)) + + await self.exit_event.wait() + finally: + await runner.cleanup() + + async def _dispatch_ws_message( + self, + envelope: list, + route_key: str, + ws: web.WebSocketResponse, + session_created: bool, + ) -> bool: + msg_type = envelope[0] + + if msg_type == "stdin": + data = envelope[1] if len(envelope) > 1 else "" + session_process = self.session_manager.get_session_by_route_key(RouteKey(route_key)) + if session_process: + await session_process.send_bytes(data.encode("utf-8")) + + elif msg_type == "resize": + size_data = envelope[1] if len(envelope) > 1 else {} + width = max(1, min(500, int(size_data.get("width", 80)))) + height = max(1, min(500, int(size_data.get("height", 24)))) + + if not session_created: + await self._create_terminal_session(route_key, width, height) + session_created = True + else: + session_process = self.session_manager.get_session_by_route_key(RouteKey(route_key)) + if session_process: + await session_process.set_terminal_size(width, height) + + elif msg_type == "ping": + data = envelope[1] if len(envelope) > 1 else "" + await ws.send_json(["pong", data]) + + return session_created + + async def _resize_on_disconnect(self, route_key: str) -> None: + session_process = self.session_manager.get_session_by_route_key(RouteKey(route_key)) + if session_process is None or not hasattr(session_process, "set_terminal_size"): + return + width, height = DISCONNECT_RESIZE + with contextlib.suppress(OSError): + await session_process.set_terminal_size(width, height) + + async def _handle_websocket(self, request: web.Request) -> web.WebSocketResponse: + route_key = request.match_info["route_key"] + ws = web.WebSocketResponse(heartbeat=30.0, max_msg_size=64 * 1024) + await ws.prepare(request) + + log.info("WebSocket connection established for route %s", route_key) + self._websocket_connections[route_key] = ws + + session_id = self.session_manager.routes.get(RouteKey(route_key)) + if session_id is not None: + session = self.session_manager.get_session(session_id) + if session is None or not session.is_running(): + self.session_manager.on_session_end(session_id) + session_id = None + + session_created = session_id is not None + + try: + async for msg in ws: + if msg.type == WSMsgType.TEXT: + try: + envelope = json.loads(msg.data) + if not isinstance(envelope, list) or len(envelope) < 1: + continue + session_created = await self._dispatch_ws_message( + envelope, route_key, ws, session_created + ) + except Exception as e: + log.error("Error processing WebSocket message: %s", e) + elif msg.type == WSMsgType.ERROR: + log.error("WebSocket connection error for route %s", route_key) + break + finally: + log.info("WebSocket connection closed for route %s", route_key) + self._websocket_connections.pop(route_key, None) + await self._resize_on_disconnect(route_key) + + return ws + + def _select_app_for_route(self, route_key: str): + """Pick the app matching the route key, or fall back to default.""" + app = self.session_manager.apps_by_slug.get(route_key) + return app or self.session_manager.get_default_app() + + async def _create_terminal_session(self, route_key: str, width: int, height: int) -> None: + available_app = self._select_app_for_route(route_key) + if available_app is None: + log.error("No app available for route %s", route_key) + ws = self._websocket_connections.get(route_key) + if ws: + await ws.send_json(["error", "No app configured"]) + return + + session_id = SessionID(generate()) + log.info( + "Creating %s session %s for route %s (%sx%s)", + "terminal" if available_app.terminal else "app", + session_id, + route_key, + width, + height, + ) + + session_process = await self.session_manager.new_session( + available_app.slug, + session_id, + RouteKey(route_key), + size=(width, height), + ) + + if session_process is None: + log.error("Failed to create session for route %s", route_key) + ws = self._websocket_connections.get(route_key) + if ws: + await ws.send_json(["error", "Failed to create session"]) + return + + connector = LocalClientConnector(self, session_id, RouteKey(route_key)) + await session_process.start(connector) + + async def _handle_screenshot(self, request: web.Request) -> web.Response: + route_key = request.query.get("route_key") + if route_key is None: + running = self.session_manager.get_first_running_session() + if running: + route_key = str(running[0]) + + if route_key is None: + raise web.HTTPNotFound(text="No running session") + + session_process = self.session_manager.get_session_by_route_key(RouteKey(route_key)) + if session_process is None and route_key in self.session_manager.apps_by_slug: + await self._create_terminal_session( + route_key, + width=DISCONNECT_RESIZE[0], + height=DISCONNECT_RESIZE[1], + ) + session_process = self.session_manager.get_session_by_route_key(RouteKey(route_key)) + + if session_process is None or not hasattr(session_process, "get_replay_buffer"): + raise web.HTTPNotFound(text="Session not found") + + replay_data = await session_process.get_replay_buffer() # type: ignore[func-returns-value] + ansi_text = replay_data.decode("utf-8", errors="replace") + + try: + width = int(request.query.get("width", "120")) + except ValueError: + width = 120 + width = max(10, min(400, width)) + + try: + height = int(request.query.get("height", str(DISCONNECT_RESIZE[1]))) + except ValueError: + height = DISCONNECT_RESIZE[1] + height = max(5, min(200, height)) + + lines = ansi_text.splitlines() + if len(lines) > height: + ansi_text = "\n".join(lines[-height:]) + "\n" + + console = Console(record=True, width=width, height=height, file=io.StringIO()) + decoder = AnsiDecoder() + for renderable in decoder.decode(ansi_text): + console.print(renderable) + + svg = console.export_svg( + title="textual-webterm", + code_format=( + '' + '' + '' + '' + '' + '' + '{lines}' + '' + '' + '' + '{backgrounds}' + '{matrix}' + '' + '' + ), + ) + return web.Response(text=svg, content_type="image/svg+xml") + + async def _handle_health_check(self, _request: web.Request) -> web.Response: + return web.Response(text="Local server is running") + + def _get_ws_url_from_request(self, request: web.Request, route_key: str) -> str: + """Build WebSocket URL honoring reverse proxies and port mapping.""" + + forwarded_proto = request.headers.get("X-Forwarded-Proto", "").split(",")[0].strip().lower() + forwarded_host = request.headers.get("X-Forwarded-Host", "").split(",")[0].strip() + forwarded_port = request.headers.get("X-Forwarded-Port", "").split(",")[0].strip() + + def _pick_proto() -> str: + if forwarded_proto in ("https", "wss"): + return "wss" + if forwarded_proto in ("http", "ws"): + return "ws" + return "wss" if request.secure else "ws" + + def _split_host_port(host: str) -> tuple[str, str]: + if not host: + return "", "" + if ":" in host: + return host.rsplit(":", 1) + return host, "" + + ws_proto = _pick_proto() + ws_host, ws_port = _split_host_port(forwarded_host) + + if not ws_host: + host_header = request.headers.get("Host", "") + ws_host, ws_port = _split_host_port(host_header) + + if not ws_host: + ws_host = "localhost" if self.host == "0.0.0.0" else self.host + ws_port = str(self.port) + + if not ws_port and forwarded_port: + ws_port = forwarded_port + + if ws_port and ws_port not in ("80", "443"): + return f"{ws_proto}://{ws_host}:{ws_port}/ws/{route_key}" + if not ws_port and self.port not in (80, 443): + return f"{ws_proto}://{ws_host}:{self.port}/ws/{route_key}" + return f"{ws_proto}://{ws_host}/ws/{route_key}" + + async def _handle_root(self, request: web.Request) -> web.Response: + route_key_param = request.query.get("route_key") + + if self._landing_apps and not route_key_param: + tiles = [ + {"slug": app.slug, "name": app.name, "command": app.command} + for app in self._landing_apps + ] + tiles_json = json.dumps(tiles) + html_content = f""" + + + Textual WebTerm Dashboard + + + +

Sessions

+
+ + +""" + return web.Response(text=html_content, content_type="text/html") + + available_app = None + if route_key_param: + available_app = self.session_manager.apps_by_slug.get(route_key_param) + if available_app is None: + available_app = self.session_manager.get_default_app() + if available_app is None: + html_content = """ + + + Textual Web Terminal Server + + +

No Apps Available

+

No terminal or Textual applications are configured.

+ +""" + return web.Response(text=html_content, content_type="text/html") + + route_key: RouteKey | None = None + if route_key_param: + route_key = RouteKey(route_key_param) + else: + running = self.session_manager.get_first_running_session() + if running: + route_key = running[0] + + if route_key is None: + route_key = RouteKey(generate().lower()) + + ws_url = self._get_ws_url_from_request(request, route_key) + + html_content = f""" + + + Textual Web Terminal + + + + + + +
+ +""" + return web.Response(text=html_content, content_type="text/html") + + async def handle_session_data(self, route_key: RouteKey, data: bytes) -> None: + ws = self._websocket_connections.get(route_key) + if ws is None: + return + await ws.send_bytes(data) + + async def handle_binary_message(self, route_key: RouteKey, payload: bytes) -> None: + ws = self._websocket_connections.get(route_key) + if ws is None: + return + await ws.send_bytes(payload) + + async def handle_session_close(self, session_id: SessionID, route_key: RouteKey) -> None: + self.session_manager.on_session_end(session_id) + ws = self._websocket_connections.get(route_key) + if ws is not None: + with contextlib.suppress(Exception): + await ws.close() + + def force_exit(self) -> None: + self.exit_event.set() diff --git a/src/textual_webterm/poller.py b/src/textual_webterm/poller.py new file mode 100644 index 0000000..8462c95 --- /dev/null +++ b/src/textual_webterm/poller.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import asyncio +import os +import selectors +from collections import deque +from dataclasses import dataclass, field +from threading import Event, Thread + + +@dataclass +class Write: + """Data in a write queue.""" + + data: bytes + position: int = 0 + done_event: asyncio.Event = field(default_factory=asyncio.Event) + + +class Poller(Thread): + """A thread which reads from file descriptors and posts read data to a queue.""" + + def __init__(self) -> None: + super().__init__() + self._loop: asyncio.AbstractEventLoop | None = None + self._selector = selectors.DefaultSelector() + self._read_queues: dict[int, asyncio.Queue[bytes | None]] = {} + self._write_queues: dict[int, deque[Write]] = {} + self._exit_event = Event() + + def add_file(self, file_descriptor: int) -> asyncio.Queue: + """Add a file descriptor to the poller. + + Args: + file_descriptor: File descriptor. + + Returns: + Async queue. + """ + self._selector.register(file_descriptor, selectors.EVENT_READ | selectors.EVENT_WRITE) + queue = self._read_queues[file_descriptor] = asyncio.Queue() + return queue + + def remove_file(self, file_descriptor: int) -> None: + """Remove a file descriptor from the poller. + + Args: + file_descriptor: File descriptor. + """ + self._selector.unregister(file_descriptor) + self._read_queues.pop(file_descriptor, None) + self._write_queues.pop(file_descriptor, None) + + async def write(self, file_descriptor: int, data: bytes) -> None: + """Write data to a file descriptor. + + Args: + file_descriptor: File descriptor. + data: Data to write. + """ + if file_descriptor not in self._write_queues: + self._write_queues[file_descriptor] = deque() + new_write = Write(data) + self._write_queues[file_descriptor].append(new_write) + self._selector.modify(file_descriptor, selectors.EVENT_READ | selectors.EVENT_WRITE) + await new_write.done_event.wait() + + def set_loop(self, loop: asyncio.AbstractEventLoop) -> None: + """Set the asyncio loop. + + Args: + loop: Async loop. + """ + self._loop = loop + + def run(self) -> None: + """Run the Poller thread.""" + + readable_events = selectors.EVENT_READ + writeable_events = selectors.EVENT_WRITE + + loop = self._loop + selector = self._selector + assert loop is not None + while not self._exit_event.is_set(): + events = selector.select(1) + + for selector_key, event_mask in events: + file_descriptor = selector_key.fileobj + assert isinstance(file_descriptor, int) + + queue = self._read_queues.get(file_descriptor, None) + if queue is not None: + if event_mask & readable_events: + try: + data = os.read(file_descriptor, 1024 * 32) or None + except OSError: + loop.call_soon_threadsafe(queue.put_nowait, None) + else: + loop.call_soon_threadsafe(queue.put_nowait, data) + + if event_mask & writeable_events: + write_queue = self._write_queues.get(file_descriptor, None) + if write_queue: + write = write_queue[0] + remaining_data = write.data[write.position :] + try: + bytes_written = os.write(file_descriptor, remaining_data) + except OSError: + # Write failed; signal completion anyway to unblock waiters + write_queue.popleft() + loop.call_soon_threadsafe(write.done_event.set) + continue + write.position += bytes_written + # Check if all data has been written + if write.position >= len(write.data): + write_queue.popleft() + loop.call_soon_threadsafe(write.done_event.set) + else: + selector.modify(file_descriptor, readable_events) + + def exit(self) -> None: + """Exit and block until finished.""" + for queue in self._read_queues.values(): + queue.put_nowait(None) + self._exit_event.set() + self.join() + self._read_queues.clear() + self._write_queues.clear() diff --git a/src/textual_webterm/session.py b/src/textual_webterm/session.py new file mode 100644 index 0000000..b4d4834 --- /dev/null +++ b/src/textual_webterm/session.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from abc import abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import asyncio + + from .types import Meta + + +class SessionConnector: + """Connect a session with a client.""" + + async def on_data(self, data: bytes) -> None: + """Handle data from session. + + Args: + data: Bytes to handle. + """ + + async def on_meta(self, meta: Meta) -> None: + """Handle meta from session. + + Args: + meta: Mapping of meta information. + """ + + async def on_binary_encoded_message(self, payload: bytes) -> None: + """Handle binary encoded data from the process. + + Args: + payload: Binary encoded data to handle. + """ + + async def on_close(self) -> None: + """Handle session close.""" + + +class Session: + """Virtual base class for a session.""" + + def __init__(self) -> None: + self._connector = SessionConnector() + + @abstractmethod + async def open(self, width: int = 80, height: int = 24) -> None: + """Open the session.""" + ... + + @abstractmethod + async def start(self, connector: SessionConnector) -> asyncio.Task: + """Start the session. + + Returns: + Running task. + """ + ... + + @abstractmethod + async def close(self) -> None: + """Close the session.""" + + @abstractmethod + async def wait(self) -> None: + """Wait for session to end.""" + + @abstractmethod + async def set_terminal_size(self, width: int, height: int) -> None: + """Set the terminal size. + + Args: + width: New width. + height: New height. + """ + ... + + @abstractmethod + async def send_bytes(self, data: bytes) -> bool: + """Send bytes to the process. + + Args: + data: Bytes to send. + + Returns: + True on success, or False if the data was not sent. + """ + ... + + @abstractmethod + async def send_meta(self, data: Meta) -> bool: + """Send meta to the process. + + Args: + meta: Meta information. + + Returns: + True on success, or False if the data was not sent. + """ + ... + + def is_running(self) -> bool: + """Check if the session is still running. + + Returns: + True if session is active, False otherwise. + """ + return False + diff --git a/src/textual_webterm/session_manager.py b/src/textual_webterm/session_manager.py new file mode 100644 index 0000000..5e5e584 --- /dev/null +++ b/src/textual_webterm/session_manager.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +import asyncio +import logging +import sys +from typing import TYPE_CHECKING + +from . import config, constants +from ._two_way_dict import TwoWayDict +from .app_session import AppSession +from .identity import generate + +if TYPE_CHECKING: + from pathlib import Path + + from .poller import Poller + from .session import Session + from .types import RouteKey, SessionID + + +log = logging.getLogger("textual-web") + + +if not constants.WINDOWS: + from .terminal_session import TerminalSession + + +class SessionManager: + """Manage sessions (Textual apps or terminals).""" + + def __init__(self, poller: Poller, path: Path, apps: list[config.App]) -> None: + self.poller = poller + self.path = path + self.apps = apps + self.apps_by_slug = {app.slug: app for app in apps} + self.sessions: dict[SessionID, Session] = {} + self.routes: TwoWayDict[RouteKey, SessionID] = TwoWayDict() + + def add_app(self, name: str, command: str, slug: str, terminal: bool = False) -> None: + """Add a new app + + Args: + name: Name of the app. + command: Command to run the app. + slug: Slug used in URL, or blank to auto-generate on server. + """ + slug = slug or generate().lower() + new_app = config.App(name=name, slug=slug, path="./", command=command, terminal=terminal) + self.apps.append(new_app) + self.apps_by_slug[slug] = new_app + + def get_default_app(self) -> config.App | None: + """Get the default app (first configured app), or ``None``.""" + return self.apps[0] if self.apps else None + + def on_session_end(self, session_id: SessionID) -> None: + """Called when a session ends.""" + self.sessions.pop(session_id, None) + route_key = self.routes.get_key(session_id) + if route_key is not None: + del self.routes[route_key] + log.debug(f"Session {session_id} ended") + + async def close_all(self, timeout: float = 3.0) -> None: + """Close app sessions. + + Args: + timeout: Time (in seconds) to wait before giving up. + + """ + sessions = list(self.sessions.values()) + + if not sessions: + return + log.info("Closing %s session(s)", len(sessions)) + + async def do_close() -> int: + """Close all sessions, return number unclosed after timeout + + Returns: + Number of sessions not yet closed. + """ + + async def close_wait(session: Session) -> None: + await asyncio.gather(session.close(), session.wait()) + + if sys.version_info >= (3, 11): + async with asyncio.TaskGroup() as tg: # type: ignore[attr-defined] + for session in sessions: + tg.create_task(close_wait(session)) + return 0 + _done, remaining = await asyncio.wait( + [asyncio.create_task(close_wait(session)) for session in sessions], + timeout=timeout, + ) + return len(remaining) + + remaining = await do_close() + if remaining: + log.warning("%s session(s) didn't close after %s seconds", remaining, timeout) + + async def new_session( + self, + slug: str, + session_id: SessionID, + route_key: RouteKey, + size: tuple[int, int] = (80, 24), + ) -> Session | None: + """Create a new session. + + Args: + slug: Slug for app. + session_id: Session identity. + route_key: Route key. + size: Terminal size (width, height). + + Returns: + New session, or `None` if no app / terminal configured. + """ + app = self.apps_by_slug.get(slug) + if app is None: + return None + + session_process: Session + if app.terminal: + if constants.WINDOWS: + log.warning("Sorry, textual-web does not currently support terminals on Windows") + return None + else: + session_process = TerminalSession( + self.poller, + session_id, + app.command, + ) + log.info(f"Created terminal session {session_id}") + else: + session_process = AppSession( + self.path, + app.command, + session_id, + ) + log.info(f"Created app session {session_id}") + + self.sessions[session_id] = session_process + self.routes[route_key] = session_id + + await session_process.open(*size) + log.debug(f"Session {session_id} opened and ready") + + return session_process + + async def close_session(self, session_id: SessionID) -> None: + """Close a session. + + Args: + session_id: Session identity. + """ + session_process = self.sessions.get(session_id, None) + if session_process is None: + return + await session_process.close() + + def get_session(self, session_id: SessionID) -> Session | None: + """Get a session from a session ID. + + Args: + session_id: Session identity. + + Returns: + A session or `None` if it doesn't exist. + """ + return self.sessions.get(session_id) + + def get_session_by_route_key(self, route_key: RouteKey) -> Session | None: + """Get a session from a route key. + + Args: + route_key: A route key. + + Returns: + A session or `None` if it doesn't exist. + + """ + session_id = self.routes.get(route_key) + if session_id is not None: + return self.sessions.get(session_id) + return None + + def get_first_running_session(self) -> tuple[RouteKey, Session] | None: + """Get the first running session. + + Returns: + Tuple of (route_key, session) or None if no running sessions. + """ + for route_key in self.routes: + session_id = self.routes.get(route_key) + if session_id: + session = self.sessions.get(session_id) + if session and session.is_running(): + return (route_key, session) + return None diff --git a/src/textual_webterm/slugify.py b/src/textual_webterm/slugify.py new file mode 100644 index 0000000..0f388f9 --- /dev/null +++ b/src/textual_webterm/slugify.py @@ -0,0 +1,18 @@ +import re +import unicodedata + + +def slugify(value: str, allow_unicode=False) -> str: + """ + Convert to ASCII if 'allow_unicode' is False. Convert spaces or repeated + dashes to single dashes. Remove characters that aren't alphanumerics, + underscores, or hyphens. Convert to lowercase. Also strip leading and + trailing whitespace, dashes, and underscores. + """ + value = str(value) + if allow_unicode: + value = unicodedata.normalize("NFKC", value) + else: + value = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii") + value = re.sub(r"[^\w\s-]", "", value.lower()) + return re.sub(r"[-\s]+", "-", value).strip("-_") diff --git a/src/textual_webterm/static/__init__.py b/src/textual_webterm/static/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/textual_webterm/static/monospace.css b/src/textual_webterm/static/monospace.css new file mode 100644 index 0000000..a2728b5 --- /dev/null +++ b/src/textual_webterm/static/monospace.css @@ -0,0 +1,19 @@ +/* Generic monospace font stack for terminal rendering. + +Prefers system monospace fonts, with optional Fira Code / Roboto Mono if available. +We avoid external font fetching (e.g. Google Fonts) to keep local server self-contained. +*/ + +:root { + --textual-webterm-mono: ui-monospace, "SFMono-Regular", "FiraCode Nerd Font", + "FiraMono Nerd Font", "Fira Code", "Roboto Mono", Menlo, Monaco, Consolas, + "Liberation Mono", "DejaVu Sans Mono", "Courier New", monospace; +} + +body { + font-family: var(--textual-webterm-mono); +} + +.xterm { + font-family: var(--textual-webterm-mono); +} diff --git a/src/textual_webterm/terminal_session.py b/src/textual_webterm/terminal_session.py new file mode 100644 index 0000000..69f4b4e --- /dev/null +++ b/src/textual_webterm/terminal_session.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +import array +import asyncio +import contextlib +import fcntl +import logging +import os +import pty +import shlex +import signal +import termios +from collections import deque +from typing import TYPE_CHECKING + +import rich.repr +from importlib_metadata import version + +from .session import Session, SessionConnector + +if TYPE_CHECKING: + from .poller import Poller + from .types import Meta, SessionID + +log = logging.getLogger("textual-web") + +# Maximum bytes to keep in replay buffer for reconnection +REPLAY_BUFFER_SIZE = 64 * 1024 # 64KB + + +@rich.repr.auto +class TerminalSession(Session): + """A session that manages a terminal.""" + + def __init__( + self, + poller: Poller, + session_id: SessionID, + command: str, + ) -> None: + self.poller = poller + self.session_id = session_id + self.command = command or os.environ.get("SHELL", "sh") + self.master_fd: int | None = None + self.pid: int | None = None + self._task: asyncio.Task | None = None + self._replay_buffer: deque[bytes] = deque() + self._replay_buffer_size = 0 + self._replay_lock = asyncio.Lock() + super().__init__() + + def __rich_repr__(self) -> rich.repr.Result: + yield "session_id", self.session_id + yield "command", self.command + + async def open(self, width: int = 80, height: int = 24) -> None: + log.info(f"Opening terminal session {self.session_id} with command: {self.command}") + pid, master_fd = pty.fork() + self.pid = pid + self.master_fd = master_fd + if pid == pty.CHILD: + os.environ["TERM_PROGRAM"] = "textual-webterm" + os.environ["TERM_PROGRAM_VERSION"] = version("textual-webterm") + try: + argv = shlex.split(self.command) + except ValueError: + os._exit(1) + if not argv: + os._exit(1) + try: + os.execvp(argv[0], argv) ## Exits the app + except OSError: + os._exit(1) + try: + self._set_terminal_size(width, height) + except OSError: + # Clean up on failure + os.close(master_fd) + self.master_fd = None + raise + log.debug(f"Terminal session {self.session_id} opened successfully") + + def _set_terminal_size(self, width: int, height: int) -> None: + buf = array.array("h", [height, width, 0, 0]) + assert self.master_fd is not None + fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, buf) + + async def set_terminal_size(self, width: int, height: int) -> None: + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, self._set_terminal_size, width, height) + + async def _add_to_replay_buffer(self, data: bytes) -> None: + """Add data to replay buffer, maintaining size limit.""" + async with self._replay_lock: + self._replay_buffer.append(data) + self._replay_buffer_size += len(data) + while self._replay_buffer_size > REPLAY_BUFFER_SIZE and self._replay_buffer: + old_data = self._replay_buffer.popleft() + self._replay_buffer_size -= len(old_data) + + async def get_replay_buffer(self) -> bytes: + """Get the contents of the replay buffer.""" + async with self._replay_lock: + return b"".join(self._replay_buffer) + + def update_connector(self, connector: SessionConnector) -> None: + """Update the connector for reconnection without restarting the session.""" + self._connector = connector + log.debug(f"Updated connector for session {self.session_id}") + + async def start(self, connector: SessionConnector) -> asyncio.Task: + self._connector = connector + assert self.master_fd is not None + if self._task is not None: + # Already running, just update connector (handled by update_connector) + return self._task + self._task = asyncio.create_task(self.run()) + return self._task + + async def run(self) -> None: + assert self.master_fd is not None + queue = self.poller.add_file(self.master_fd) + try: + while True: + data = await queue.get() + if not data: + break + # Store in replay buffer for reconnection + await self._add_to_replay_buffer(data) + # Send to current connector + if self._connector: + await self._connector.on_data(data) + except OSError: + log.exception("error in terminal.run") + finally: + if self._connector: + await self._connector.on_close() + if self.master_fd is not None: + fd = self.master_fd + self.master_fd = None + # Remove from poller first (while fd is still valid), then close + self.poller.remove_file(fd) + os.close(fd) + + async def send_bytes(self, data: bytes) -> bool: + if self.master_fd is None: + return False + await self.poller.write(self.master_fd, data) + return True + + async def send_meta(self, data: Meta) -> bool: + return True + + async def close(self) -> None: + if self.pid is not None: + try: + os.kill(self.pid, signal.SIGHUP) + except ProcessLookupError: + pass # Process already gone + except Exception as e: + log.warning(f"Error closing terminal session {self.session_id}: {e}") + + async def wait(self) -> None: + if self._task is not None: + with contextlib.suppress(asyncio.CancelledError): + await self._task + + def is_running(self) -> bool: + """Check if the terminal session is still running.""" + if self.master_fd is None or self._task is None: + return False + # Check if process is actually alive + if self.pid is not None: + try: + os.kill(self.pid, 0) # Signal 0 checks existence + return True + except OSError: + return False + # pid is None means process not started or already exited + return False diff --git a/src/textual_webterm/types.py b/src/textual_webterm/types.py new file mode 100644 index 0000000..feb7c6c --- /dev/null +++ b/src/textual_webterm/types.py @@ -0,0 +1,6 @@ +from typing import NewType, Union + +AppID = NewType("AppID", str) +Meta = dict[str, Union[str, None, int, bool]] +RouteKey = NewType("RouteKey", str) +SessionID = NewType("SessionID", str) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..befb807 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for textual-webterm.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8797657 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,76 @@ +"""Pytest configuration and fixtures for textual-webterm tests.""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING + +import pytest + +from textual_webterm.config import App, Config +from textual_webterm.local_server import LocalServer +from textual_webterm.poller import Poller +from textual_webterm.session_manager import SessionManager + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator, Generator + from pathlib import Path + + +@pytest.fixture +def event_loop() -> Generator[asyncio.AbstractEventLoop, None, None]: + """Create an event loop for async tests.""" + loop = asyncio.new_event_loop() + yield loop + loop.close() + + +@pytest.fixture +def sample_terminal_app() -> App: + """Create a sample terminal app configuration.""" + return App( + name="Test Terminal", + slug="test-terminal", + terminal=True, + command="echo hello", + ) + + +@pytest.fixture +def sample_config(sample_terminal_app: App) -> Config: + """Create a sample configuration with a terminal app.""" + return Config(apps=[sample_terminal_app]) + + +@pytest.fixture +def tmp_config_path(tmp_path: Path) -> Path: + """Create a temporary config path.""" + return tmp_path / "config" + + +@pytest.fixture +def poller() -> Poller: + """Create a Poller instance.""" + return Poller() + + +@pytest.fixture +def session_manager(poller: Poller, tmp_path: Path, sample_terminal_app: App) -> SessionManager: + """Create a SessionManager instance.""" + return SessionManager(poller, tmp_path, [sample_terminal_app]) + + +@pytest.fixture +async def local_server( + tmp_config_path: Path, sample_config: Config +) -> AsyncGenerator[LocalServer, None]: + """Create a LocalServer instance for testing.""" + server = LocalServer( + str(tmp_config_path), + sample_config, + host="127.0.0.1", + port=0, # Use random available port + ) + yield server + # Cleanup + server.force_exit() diff --git a/tests/test_app_session.py b/tests/test_app_session.py new file mode 100644 index 0000000..71ced15 --- /dev/null +++ b/tests/test_app_session.py @@ -0,0 +1,133 @@ +"""Tests for app_session module.""" + +import asyncio +import contextlib +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from textual_webterm.app_session import AppSession, ProcessState + + +class TestProcessState: + """Tests for ProcessState enum.""" + + def test_process_states_exist(self): + """Test that all process states exist.""" + assert ProcessState.PENDING is not None + assert ProcessState.RUNNING is not None + assert ProcessState.CLOSED is not None + + +class TestAppSession: + """Tests for AppSession class.""" + + @pytest.fixture + def mock_path(self, tmp_path): + """Create a mock path.""" + return tmp_path + + def test_init(self, mock_path): + """Test AppSession initialization.""" + session = AppSession(mock_path, "python app.py", "test-session") + + assert session.working_directory == mock_path + assert session.command == "python app.py" + assert session.session_id == "test-session" + assert session.state == ProcessState.PENDING + + def test_init_with_devtools(self, mock_path): + """Test AppSession with devtools enabled.""" + session = AppSession(mock_path, "python app.py", "test-session", devtools=True) + assert session.devtools is True + + @pytest.mark.asyncio + async def test_send_bytes_not_running(self, mock_path): + """Test send_bytes when not running returns False.""" + session = AppSession(mock_path, "python app.py", "test-session") + + # Session not started, will return False gracefully + result = await session.send_bytes(b"test") + assert result is False + + @pytest.mark.asyncio + async def test_send_meta(self, mock_path): + """Test send_meta.""" + session = AppSession(mock_path, "python app.py", "test-session") + session._process = MagicMock() + session._process.stdin = MagicMock() + session._process.stdin.write = MagicMock() + session._process.stdin.drain = AsyncMock() + + await session.send_meta({"key": "value"}) + # Should handle meta data + + @pytest.mark.asyncio + async def test_set_terminal_size(self, mock_path): + """Test set_terminal_size.""" + session = AppSession(mock_path, "python app.py", "test-session") + session._process = MagicMock() + session._process.stdin = MagicMock() + session._process.stdin.write = MagicMock() + session._process.stdin.drain = AsyncMock() + + # Should not raise + await session.set_terminal_size(100, 50) + + @pytest.mark.asyncio + async def test_close_not_running(self, mock_path): + """Test close when not running handles gracefully.""" + session = AppSession(mock_path, "python app.py", "test-session") + + # No process running, close should handle gracefully (not crash) + await session.close() + assert session.state == ProcessState.CLOSING + + @pytest.mark.asyncio + async def test_wait_no_task(self, mock_path): + """Test wait when no task.""" + session = AppSession(mock_path, "python app.py", "test-session") + + # Should not raise + await session.wait() + + def test_state_transitions(self, mock_path): + """Test state transition tracking.""" + session = AppSession(mock_path, "python app.py", "test-session") + + assert session.state == ProcessState.PENDING + + # Manually set state for testing + session.state = ProcessState.RUNNING + assert session.state == ProcessState.RUNNING + + session.state = ProcessState.CLOSED + assert session.state == ProcessState.CLOSED + + +class TestAppSessionConnector: + """Tests for AppSession with connector.""" + + @pytest.fixture + def mock_connector(self): + """Create a mock connector.""" + connector = MagicMock() + connector.on_data = AsyncMock() + connector.on_close = AsyncMock() + return connector + + @pytest.mark.asyncio + async def test_start_creates_task(self, tmp_path, mock_connector): + """Test that start creates a task.""" + session = AppSession(tmp_path, "echo test", "test-session") + + with ( + patch.object(session, "open", new_callable=AsyncMock), + patch.object(session, "run", new_callable=AsyncMock), + ): + task = await session.start(mock_connector) + assert task is not None + # Cancel to clean up + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..8782d73 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,227 @@ +"""Tests for CLI module.""" + +from pathlib import Path + +import click +import pytest +from click.testing import CliRunner + + +class TestParseAppPath: + """Tests for parse_app_path function.""" + + def test_parse_module_class(self): + """Test parsing module:class format.""" + from textual_webterm.cli import parse_app_path + + module, cls = parse_app_path("mymodule:MyClass") + assert module == "mymodule" + assert cls == "MyClass" + + def test_parse_nested_module_class(self): + """Test parsing nested.module:class format.""" + from textual_webterm.cli import parse_app_path + + module, cls = parse_app_path("my.nested.module:MyClass") + assert module == "my.nested.module" + assert cls == "MyClass" + + def test_parse_file_path_class(self): + """Test parsing file/path.py:class format.""" + from textual_webterm.cli import parse_app_path + + module, cls = parse_app_path("path/to/file.py:MyClass") + assert module == "path/to/file.py" + assert cls == "MyClass" + + def test_parse_no_colon_raises(self): + """Test that missing colon raises BadParameter.""" + from textual_webterm.cli import parse_app_path + + with pytest.raises(click.BadParameter) as exc_info: + parse_app_path("invalid_format") + assert "Expected format" in str(exc_info.value) + + +class TestLoadAppClass: + """Tests for load_app_class function.""" + + def test_load_nonexistent_module(self): + """Test loading from non-existent module raises.""" + from textual_webterm.cli import load_app_class + + with pytest.raises(click.BadParameter) as exc_info: + load_app_class("nonexistent_module_xyz:MyClass") + assert "Could not import" in str(exc_info.value) + + def test_load_nonexistent_class(self): + """Test loading non-existent class from existing module raises.""" + from textual_webterm.cli import load_app_class + + with pytest.raises(click.BadParameter) as exc_info: + load_app_class("os:NonExistentClass") + assert "has no attribute" in str(exc_info.value) + + def test_load_existing_class(self): + """Test loading an existing class from a module.""" + from textual_webterm.cli import load_app_class + + # Load Path from pathlib + cls = load_app_class("pathlib:Path") + assert cls is Path + + def test_load_from_file_nonexistent(self): + """Test loading from non-existent file raises.""" + from textual_webterm.cli import load_app_class + + with pytest.raises(click.BadParameter) as exc_info: + load_app_class("/nonexistent/path.py:MyClass") + assert ( + "not found" in str(exc_info.value).lower() + or "does not exist" in str(exc_info.value).lower() + ) + + +class TestCLI: + """Tests for CLI command.""" + + def test_cli_help(self): + """Test CLI help output.""" + from textual_webterm.cli import app as cli_app + + runner = CliRunner() + result = runner.invoke(cli_app, ["--help"]) + assert result.exit_code == 0 + assert "terminal" in result.output.lower() or "command" in result.output.lower() + + def test_cli_runs_terminal_command(self, monkeypatch): + from textual_webterm import cli + + calls: dict[str, object] = {} + + class FakeServer: + def __init__(self, *_args, **_kwargs): + calls["init"] = True + + def add_terminal(self, name, command, slug): + calls["terminal"] = (name, command, slug) + + async def run(self): + calls["run"] = True + + monkeypatch.setattr(cli, "LocalServer", FakeServer) + monkeypatch.setattr(cli.asyncio, "run", lambda _coro: None) + + runner = CliRunner() + result = runner.invoke(cli.app, ["htop"]) + assert result.exit_code == 0 + assert calls["terminal"][1] == "htop" + + def test_cli_runs_default_shell(self, monkeypatch): + import os + + from textual_webterm import cli + + calls: dict[str, object] = {} + + class FakeServer: + def __init__(self, *_args, **_kwargs): + calls["init"] = True + + def add_terminal(self, name, command, slug): + calls["terminal"] = (name, command, slug) + + async def run(self): + calls["run"] = True + + monkeypatch.setenv("SHELL", "/bin/zsh") + monkeypatch.setattr(cli, "LocalServer", FakeServer) + monkeypatch.setattr(cli.asyncio, "run", lambda _coro: None) + + runner = CliRunner() + result = runner.invoke(cli.app, []) + assert result.exit_code == 0 + assert calls["terminal"][1] == os.environ["SHELL"] + + def test_cli_app_module_validation_rejects(self): + from textual_webterm.cli import app as cli_app + + runner = CliRunner() + result = runner.invoke(cli_app, ["--app", "os;rm -rf /:Fake"]) + assert result.exit_code != 0 + + def test_cli_version(self): + """Test CLI version output.""" + from textual_webterm.cli import app as cli_app + + runner = CliRunner() + result = runner.invoke(cli_app, ["--version"]) + assert result.exit_code == 0 + assert "0.1.0" in result.output + + def test_cli_invalid_app_path(self): + """Test CLI with invalid app path.""" + from textual_webterm.cli import app as cli_app + + runner = CliRunner() + result = runner.invoke(cli_app, ["--app", "invalid"]) + assert result.exit_code != 0 + + def test_cli_port_option(self): + """Test CLI port option parsing.""" + from textual_webterm.cli import app as cli_app + + runner = CliRunner() + result = runner.invoke(cli_app, ["--help"]) + assert "--port" in result.output or "-p" in result.output + + def test_cli_host_option(self): + """Test CLI host option parsing.""" + from textual_webterm.cli import app as cli_app + + runner = CliRunner() + result = runner.invoke(cli_app, ["--help"]) + assert "--host" in result.output or "-H" in result.output + + +class TestModuleValidation: + """Tests for module/class name validation in CLI.""" + + def test_invalid_module_characters(self): + """Test that invalid module names are rejected.""" + from textual_webterm.cli import app as cli_app + + runner = CliRunner() + # Module with shell characters should be rejected or fail gracefully + result = runner.invoke(cli_app, ["--app", "os; rm -rf /:Fake"]) + # Should not succeed + assert result.exit_code != 0 + + def test_invalid_class_name(self): + """Test that invalid class names are rejected.""" + from textual_webterm.cli import app as cli_app + + runner = CliRunner() + result = runner.invoke(cli_app, ["--app", "os:123invalid"]) + assert result.exit_code != 0 + + +class TestCLIOptions: + """Tests for CLI option handling.""" + + def test_debug_option(self): + """Test --debug option exists.""" + from textual_webterm.cli import app as cli_app + + runner = CliRunner() + result = runner.invoke(cli_app, ["--help"]) + assert "--app" in result.output + + def test_no_run_option(self): + """Test --no-run option exists.""" + from textual_webterm.cli import app as cli_app + + runner = CliRunner() + result = runner.invoke(cli_app, ["--help"]) + # Check that basic options are documented + assert "port" in result.output.lower() diff --git a/tests/test_cli_landing.py b/tests/test_cli_landing.py new file mode 100644 index 0000000..4c2482c --- /dev/null +++ b/tests/test_cli_landing.py @@ -0,0 +1,73 @@ +import asyncio +from pathlib import Path + +from click.testing import CliRunner + + +def test_cli_landing_manifest_runs(monkeypatch, tmp_path: Path): + from textual_webterm import cli + + manifest = tmp_path / "landing.yaml" + manifest.write_text( + """ + - name: One + slug: one + command: echo one + """ + ) + + called = {} + + class FakeServer: + def __init__(self, *_args, **_kwargs): + called["init"] = True + + def add_terminal(self, name, command, slug): + called["terminal"] = (name, command, slug) + + async def run(self): + called["run"] = True + + monkeypatch.setattr(cli, "LocalServer", FakeServer) + monkeypatch.setattr(cli, "asyncio", asyncio) + + runner = CliRunner() + result = runner.invoke(cli.app, ["-L", str(manifest)]) + assert result.exit_code == 0 + assert called.get("terminal") == ("One", "echo one", "one") + assert called.get("run") is True + + +def test_cli_compose_manifest_runs(monkeypatch, tmp_path: Path): + from textual_webterm import cli + + manifest = tmp_path / "compose.yaml" + manifest.write_text( + """ + services: + svc1: + labels: + webterm-command: echo svc1 + """ + ) + + called = {} + + class FakeServer: + def __init__(self, *_args, **_kwargs): + called["init"] = True + + def add_terminal(self, name, command, slug): + called["terminal"] = (name, command, slug) + + async def run(self): + called["run"] = True + + monkeypatch.setattr(cli, "LocalServer", FakeServer) + monkeypatch.setattr(cli, "asyncio", asyncio) + + runner = CliRunner() + result = runner.invoke(cli.app, ["-C", str(manifest)]) + assert result.exit_code == 0 + assert called.get("terminal") == ("svc1", "echo svc1", "svc1") + assert called.get("run") is True diff --git a/tests/test_cli_run_app.py b/tests/test_cli_run_app.py new file mode 100644 index 0000000..9db4862 --- /dev/null +++ b/tests/test_cli_run_app.py @@ -0,0 +1,55 @@ +"""Extra CLI coverage tests for app execution paths.""" + +from __future__ import annotations + +from click.testing import CliRunner + + +def test_cli_runs_app_from_file(monkeypatch, tmp_path): + from textual_webterm import cli + + app_file = tmp_path / "myapp.py" + app_file.write_text( + """ +class MyApp: + TITLE = "MyApp" + def run(self): + return 0 +""".lstrip() + ) + + calls: dict[str, object] = {} + + class FakeServer: + def __init__(self, *_args, **_kwargs): + calls["init"] = True + + def add_app(self, name, command, slug): + calls["app"] = (name, command, slug) + + async def run(self): + calls["run"] = True + + monkeypatch.setattr(cli, "LocalServer", FakeServer) + monkeypatch.setattr(cli.asyncio, "run", lambda _coro: None) + + runner = CliRunner() + result = runner.invoke(cli.app, ["--app", f"{app_file}:MyApp"]) + assert result.exit_code == 0 + assert calls["app"][0] == "MyApp" + assert "python3" in calls["app"][1] + + +def test_load_app_class_from_file(tmp_path): + from textual_webterm.cli import load_app_class + + app_file = tmp_path / "myapp2.py" + app_file.write_text( + """ +class MyApp2: + pass +""".lstrip() + ) + + cls = load_app_class(f"{app_file}:MyApp2") + assert cls.__name__ == "MyApp2" diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..9e0e206 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,110 @@ +"""Tests for configuration handling.""" + +from __future__ import annotations + +from textual_webterm.config import App, Config + + +class TestApp: + """Tests for App configuration.""" + + def test_create_terminal_app(self) -> None: + """Test creating a terminal app configuration.""" + app = App( + name="My Terminal", + slug="my-terminal", + terminal=True, + command="bash", + ) + assert app.name == "My Terminal" + assert app.slug == "my-terminal" + assert app.terminal is True + assert app.command == "bash" + + def test_create_textual_app(self) -> None: + """Test creating a Textual app configuration.""" + app = App( + name="My App", + slug="my-app", + terminal=False, + command="python -m myapp", + ) + assert app.terminal is False + + +class TestConfig: + """Tests for Config.""" + + def test_create_config_with_apps(self) -> None: + """Test creating a config with apps.""" + app = App(name="Test", slug="test", terminal=True, command="bash") + config = Config(apps=[app]) + assert len(config.apps) == 1 + assert config.apps[0].name == "Test" + + def test_create_empty_config(self) -> None: + """Test creating a config with no apps.""" + config = Config(apps=[]) + assert len(config.apps) == 0 + + +class TestDefaultConfig: + """Tests for default_config function.""" + + def test_default_config_returns_config(self): + """Test that default_config returns a Config object.""" + from textual_webterm.config import default_config + + config = default_config() + assert config is not None + assert hasattr(config, "apps") + + +class TestLoadConfig: + """Tests for load_config function.""" + + def test_load_config_parses_app_and_terminal(self, tmp_path): + from textual_webterm.config import load_config + + config_path = tmp_path / "config.toml" + config_path.write_text( + """ +[app.demo] +command = "echo demo" + +[terminal.shell] +command = "bash" +""".lstrip() + ) + + config = load_config(config_path) + assert len(config.apps) == 2 + assert {a.name for a in config.apps} == {"demo", "shell"} + assert any(a.terminal for a in config.apps) + + def test_load_config_slugify_for_app(self, tmp_path): + from textual_webterm.config import load_config + + config_path = tmp_path / "config.toml" + config_path.write_text( + """ +[app."My App"] +command = "echo hi" +""".lstrip() + ) + config = load_config(config_path) + assert config.apps[0].slug + + def test_load_config_expands_vars(self, tmp_path, monkeypatch): + from textual_webterm.config import load_config + + monkeypatch.setenv("MY_CMD", "echo expanded") + config_path = tmp_path / "config.toml" + config_path.write_text( + """ +[terminal.t] +command = "$MY_CMD" +""".lstrip() + ) + config = load_config(config_path) + assert config.apps[0].command == "echo expanded" diff --git a/tests/test_config_manifest.py b/tests/test_config_manifest.py new file mode 100644 index 0000000..cde8818 --- /dev/null +++ b/tests/test_config_manifest.py @@ -0,0 +1,44 @@ +import tempfile +from pathlib import Path + +from textual_webterm.config import load_compose_manifest, load_landing_yaml + + +def test_load_landing_yaml_simple(): + data = """ + - name: One + slug: one + command: echo one + - name: Two + command: echo two + """ + with tempfile.NamedTemporaryFile("w+", delete=False) as f: + f.write(data) + f.flush() + apps = load_landing_yaml(Path(f.name)) + assert len(apps) == 2 + assert apps[0].slug == "one" + assert apps[1].command == "echo two" + + +def test_load_compose_manifest_reads_label(): + data = """ + services: + svc1: + labels: + webterm-command: echo svc1 + svc2: + labels: + - webterm-command=echo svc2 + svc3: + labels: + other: value + """ + with tempfile.NamedTemporaryFile("w+", delete=False) as f: + f.write(data) + f.flush() + apps = load_compose_manifest(Path(f.name)) + slugs = {a.slug for a in apps} + commands = {a.command for a in apps} + assert slugs == {"svc1", "svc2"} + assert "echo svc1" in commands and "echo svc2" in commands diff --git a/tests/test_constants.py b/tests/test_constants.py new file mode 100644 index 0000000..159b9d9 --- /dev/null +++ b/tests/test_constants.py @@ -0,0 +1,34 @@ +"""Tests for constants helpers.""" + +from __future__ import annotations + + +def test_get_environ_bool(monkeypatch): + from textual_webterm.constants import get_environ_bool + + monkeypatch.setenv("FLAG", "1") + assert get_environ_bool("FLAG") is True + + monkeypatch.setenv("FLAG", "0") + assert get_environ_bool("FLAG") is False + + +def test_get_environ_int_keyerror(monkeypatch): + from textual_webterm.constants import get_environ_int + + monkeypatch.delenv("INT", raising=False) + assert get_environ_int("INT", 7) == 7 + + +def test_get_environ_int_valueerror(monkeypatch): + from textual_webterm.constants import get_environ_int + + monkeypatch.setenv("INT", "not-an-int") + assert get_environ_int("INT", 7) == 7 + + +def test_get_environ_int_valid(monkeypatch): + from textual_webterm.constants import get_environ_int + + monkeypatch.setenv("INT", "42") + assert get_environ_int("INT", 7) == 42 diff --git a/tests/test_local_server.py b/tests/test_local_server.py new file mode 100644 index 0000000..88b454d --- /dev/null +++ b/tests/test_local_server.py @@ -0,0 +1,81 @@ +"""Tests for LocalServer.""" + +from __future__ import annotations + +from textual_webterm.config import App, Config +from textual_webterm.local_server import STATIC_PATH, LocalServer + + +class TestLocalServer: + """Tests for LocalServer.""" + + def test_static_path_exists(self) -> None: + """Test that static path is set from textual-serve.""" + assert STATIC_PATH is not None + assert STATIC_PATH.exists() + + def test_static_path_has_required_files(self) -> None: + """Test that static path contains required assets.""" + assert STATIC_PATH is not None + assert (STATIC_PATH / "js" / "textual.js").exists() + assert (STATIC_PATH / "css" / "xterm.css").exists() + + def test_create_server(self, tmp_path) -> None: + """Test creating a LocalServer instance.""" + app = App(name="Test", slug="test", terminal=True, command="echo test") + config = Config(apps=[app]) + + server = LocalServer( + str(tmp_path), + config, + host="127.0.0.1", + port=8080, + ) + + assert server.host == "127.0.0.1" + assert server.port == 8080 + assert server.app_count == 1 + + def test_add_app(self, tmp_path) -> None: + """Test adding an app to the server.""" + config = Config(apps=[]) + server = LocalServer(str(tmp_path), config, host="127.0.0.1", port=8080) + + assert server.app_count == 0 + server.add_app("New App", "echo hello", slug="new-app") + assert server.app_count == 1 + + +class TestWebSocketProtocol: + """Tests for WebSocket protocol handling.""" + + def test_stdin_message_format(self) -> None: + """Test that stdin messages use correct format.""" + import json + + msg = json.dumps(["stdin", "hello"]) + parsed = json.loads(msg) + assert parsed[0] == "stdin" + assert parsed[1] == "hello" + + def test_resize_message_format(self) -> None: + """Test that resize messages use correct format.""" + import json + + msg = json.dumps(["resize", {"width": 80, "height": 24}]) + parsed = json.loads(msg) + assert parsed[0] == "resize" + assert parsed[1]["width"] == 80 + assert parsed[1]["height"] == 24 + + def test_ping_pong_format(self) -> None: + """Test ping/pong message format.""" + import json + + ping = json.dumps(["ping", "12345"]) + parsed = json.loads(ping) + assert parsed[0] == "ping" + + pong = json.dumps(["pong", "12345"]) + parsed = json.loads(pong) + assert parsed[0] == "pong" diff --git a/tests/test_local_server_unit.py b/tests/test_local_server_unit.py new file mode 100644 index 0000000..6d24b57 --- /dev/null +++ b/tests/test_local_server_unit.py @@ -0,0 +1,365 @@ +"""Tests for local_server module - unit tests for helper functions.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from aiohttp import web + +from textual_webterm.config import App, Config +from textual_webterm.local_server import LocalServer + + +class TestGetStaticPath: + """Tests for static path function.""" + + def test_static_path_exists(self): + """Test that static path exists.""" + from textual_webterm.local_server import _get_static_path + + path = _get_static_path() + assert path is not None and path.exists() + + def test_static_path_has_js(self): + """Test that static path has JS directory.""" + from textual_webterm.local_server import _get_static_path + + path = _get_static_path() + assert path is not None + assert (path / "js").exists() + + def test_static_path_has_css(self): + """Test that static path has CSS directory.""" + from textual_webterm.local_server import _get_static_path + + path = _get_static_path() + assert path is not None + assert (path / "css").exists() + + +class TestLocalServer: + """Tests for LocalServer class.""" + + @pytest.fixture + def config(self): + """Create a test config.""" + return Config( + apps=[ + App(name="Test", slug="test", path="./", command="echo test", terminal=True), + ], + ) + + @pytest.fixture + def server(self, config, tmp_path): + """Create a test server.""" + config_file = tmp_path / "config.toml" + config_file.write_text("") + return LocalServer( + config_path=str(config_file), + config=config, + host="localhost", + port=8080, + ) + + def test_init(self, server): + """Test LocalServer initialization.""" + assert server.host == "localhost" + assert server.port == 8080 + assert server.session_manager is not None + + def test_add_app(self, server): + """Test adding an app.""" + server.add_app("New App", "python app.py", "newapp") + assert "newapp" in server.session_manager.apps_by_slug + + def test_add_terminal(self, server): + """Test adding a terminal.""" + server.add_terminal("Terminal", "bash", "term") + assert "term" in server.session_manager.apps_by_slug + app = server.session_manager.apps_by_slug["term"] + assert app.terminal is True + + @pytest.mark.asyncio + async def test_create_terminal_session_uses_slug_and_starts_session(self, server, monkeypatch): + from textual_webterm import local_server + + monkeypatch.setattr(local_server, "generate", lambda: "fixed-session") + + session = MagicMock() + session.start = AsyncMock() + monkeypatch.setattr(server.session_manager, "new_session", AsyncMock(return_value=session)) + + await server._create_terminal_session("test", 80, 24) + + server.session_manager.new_session.assert_awaited_once_with( + "test", + "fixed-session", + "test", + size=(80, 24), + ) + session.start.assert_awaited_once() + connector = session.start.call_args.args[0] + assert connector.session_id == "fixed-session" + assert connector.route_key == "test" + + +class TestLocalServerHelpers: + """Tests for LocalServer helper methods.""" + + @pytest.mark.asyncio + async def test_keyboard_interrupt_closes_sessions_and_websockets(self, server, monkeypatch): + ws1 = MagicMock() + ws1.close = AsyncMock() + ws2 = MagicMock() + ws2.close = AsyncMock() + server._websocket_connections["a"] = ws1 + server._websocket_connections["b"] = ws2 + + monkeypatch.setattr(server.session_manager, "close_all", AsyncMock()) + + server.on_keyboard_interrupt() + assert server._shutdown_task is not None + await server._shutdown_task + + ws1.close.assert_awaited_once() + ws2.close.assert_awaited_once() + server.session_manager.close_all.assert_awaited_once() + assert server.exit_event.is_set() + + @pytest.mark.asyncio + async def test_ws_resize_creates_session_when_slug_exists(self, server, monkeypatch): + server.session_manager.apps_by_slug["slug"] = App( + name="Known", + slug="slug", + path="./", + command="echo ok", + terminal=True, + ) + monkeypatch.setattr(server, "_create_terminal_session", AsyncMock()) + + ws = MagicMock() + session_created = await server._dispatch_ws_message( + ["resize", {"width": 100, "height": 40}], + "slug", + ws, + session_created=False, + ) + + assert session_created is True + server._create_terminal_session.assert_awaited_once_with("slug", 100, 40) + + @pytest.mark.asyncio + async def test_ws_resize_sends_error_if_no_apps(self, server): + ws = MagicMock() + ws.send_json = AsyncMock() + server._websocket_connections["rk"] = ws + + session_created = await server._dispatch_ws_message( + ["resize", {"width": 80, "height": 24}], + "rk", + ws, + session_created=False, + ) + + assert session_created is True + ws.send_json.assert_awaited_once_with(["error", "No app configured"]) + + @pytest.mark.asyncio + async def test_resize_on_disconnect_calls_set_terminal_size(self, server, monkeypatch): + session = MagicMock() + session.set_terminal_size = AsyncMock() + + monkeypatch.setattr(server.session_manager, "get_session_by_route_key", lambda _rk: session) + + await server._resize_on_disconnect("rk") + + session.set_terminal_size.assert_called_once_with(132, 45) + + @pytest.mark.asyncio + async def test_create_terminal_session_sends_error_if_no_apps(self, server): + ws = MagicMock() + ws.send_json = AsyncMock() + server._websocket_connections["rk"] = ws + + await server._create_terminal_session("rk", 80, 24) + + ws.send_json.assert_awaited_once_with(["error", "No app configured"]) + + @pytest.mark.asyncio + async def test_screenshot_svg_handler_returns_svg(self, server, monkeypatch, capsys): + request = MagicMock() + request.query = {"route_key": "rk", "width": "80"} + + session = MagicMock() + session.get_replay_buffer = AsyncMock(return_value=b"hello\r\n") + + monkeypatch.setattr(server.session_manager, "get_session_by_route_key", lambda _rk: session) + + response = await server._handle_screenshot(request) + assert response.content_type == "image/svg+xml" + assert " None: + """Test that SessionID is a string type.""" + session_id = SessionID("test-session-123") + assert isinstance(session_id, str) + assert session_id == "test-session-123" + + def test_route_key_is_string(self) -> None: + """Test that RouteKey is a string type.""" + route_key = RouteKey("abc123") + assert isinstance(route_key, str) + assert route_key == "abc123" + + +class TestIdentity: + """Tests for identity generation.""" + + def test_generate_unique_ids(self) -> None: + """Test that generated IDs are unique.""" + from textual_webterm.identity import generate + + ids = [generate() for _ in range(100)] + assert len(set(ids)) == 100 # All unique + + def test_generate_id_format(self) -> None: + """Test that generated IDs have expected format.""" + from textual_webterm.identity import generate + + id_ = generate() + assert isinstance(id_, str) + assert len(id_) > 0 diff --git a/tests/test_session_manager.py b/tests/test_session_manager.py new file mode 100644 index 0000000..996d224 --- /dev/null +++ b/tests/test_session_manager.py @@ -0,0 +1,258 @@ +"""Tests for session_manager module.""" + +import platform +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from textual_webterm.config import App +from textual_webterm.session_manager import SessionManager +from textual_webterm.types import RouteKey, SessionID + + +class TestSessionManager: + """Tests for SessionManager class.""" + + @pytest.fixture + def mock_poller(self): + """Create a mock poller.""" + return MagicMock() + + @pytest.fixture + def mock_path(self, tmp_path): + """Create a mock path.""" + return tmp_path + + @pytest.fixture + def sample_apps(self): + """Create sample apps.""" + return [ + App(name="Test Terminal", slug="terminal", path="./", command="bash", terminal=True), + App(name="Test App", slug="app", path="./", command="python app.py", terminal=False), + ] + + def test_init(self, mock_poller, mock_path, sample_apps): + """Test SessionManager initialization.""" + manager = SessionManager(mock_poller, mock_path, sample_apps) + + assert manager.poller == mock_poller + assert manager.path == mock_path + assert len(manager.apps) == 2 + assert "terminal" in manager.apps_by_slug + assert "app" in manager.apps_by_slug + assert len(manager.sessions) == 0 + assert len(manager.routes) == 0 + + def test_get_default_app(self, mock_poller, mock_path, sample_apps): + """Test getting the default app.""" + manager = SessionManager(mock_poller, mock_path, sample_apps) + assert manager.get_default_app() == sample_apps[0] + + def test_get_default_app_empty(self, mock_poller, mock_path): + """Test getting the default app when no apps are configured.""" + manager = SessionManager(mock_poller, mock_path, []) + assert manager.get_default_app() is None + + def test_add_app(self, mock_poller, mock_path): + """Test adding an app.""" + manager = SessionManager(mock_poller, mock_path, []) + + manager.add_app("New App", "python new.py", "newapp", terminal=False) + + assert len(manager.apps) == 1 + assert "newapp" in manager.apps_by_slug + assert manager.apps_by_slug["newapp"].name == "New App" + + def test_add_app_auto_slug(self, mock_poller, mock_path): + """Test adding an app with auto-generated slug.""" + manager = SessionManager(mock_poller, mock_path, []) + + manager.add_app("Auto App", "python auto.py", "", terminal=False) + + assert len(manager.apps) == 1 + # Slug should be auto-generated + assert len(manager.apps[0].slug) > 0 + + def test_get_session_not_found(self, mock_poller, mock_path, sample_apps): + """Test getting a non-existent session.""" + manager = SessionManager(mock_poller, mock_path, sample_apps) + + result = manager.get_session(SessionID("nonexistent")) + assert result is None + + def test_get_session_by_route_key_not_found(self, mock_poller, mock_path, sample_apps): + """Test getting session by non-existent route key.""" + manager = SessionManager(mock_poller, mock_path, sample_apps) + + result = manager.get_session_by_route_key(RouteKey("nonexistent")) + assert result is None + + def test_on_session_end(self, mock_poller, mock_path, sample_apps): + """Test session end cleanup.""" + manager = SessionManager(mock_poller, mock_path, sample_apps) + + # Manually add a session + session_id = SessionID("test-session") + route_key = RouteKey("test-route") + mock_session = MagicMock() + manager.sessions[session_id] = mock_session + manager.routes[route_key] = session_id + + # End session + manager.on_session_end(session_id) + + assert session_id not in manager.sessions + assert route_key not in manager.routes + + def test_on_session_end_nonexistent(self, mock_poller, mock_path, sample_apps): + """Test session end for non-existent session.""" + manager = SessionManager(mock_poller, mock_path, sample_apps) + + # Should not raise + manager.on_session_end(SessionID("nonexistent")) + + @pytest.mark.asyncio + async def test_close_all_empty(self, mock_poller, mock_path, sample_apps): + """Test closing all sessions when empty.""" + manager = SessionManager(mock_poller, mock_path, sample_apps) + + # Should not raise + await manager.close_all() + + @pytest.mark.asyncio + async def test_close_all_with_sessions(self, mock_poller, mock_path, sample_apps): + """Test closing all sessions.""" + manager = SessionManager(mock_poller, mock_path, sample_apps) + + # Add mock sessions + mock_session = MagicMock() + mock_session.close = AsyncMock() + mock_session.wait = AsyncMock() + manager.sessions[SessionID("s1")] = mock_session + + await manager.close_all(timeout=1.0) + + mock_session.close.assert_called_once() + + @pytest.mark.asyncio + async def test_close_session(self, mock_poller, mock_path, sample_apps): + """Test closing a specific session.""" + manager = SessionManager(mock_poller, mock_path, sample_apps) + + mock_session = MagicMock() + mock_session.close = AsyncMock() + session_id = SessionID("test-session") + manager.sessions[session_id] = mock_session + + await manager.close_session(session_id) + + mock_session.close.assert_called_once() + + @pytest.mark.asyncio + async def test_close_session_nonexistent(self, mock_poller, mock_path, sample_apps): + """Test closing a non-existent session.""" + manager = SessionManager(mock_poller, mock_path, sample_apps) + + # Should not raise + await manager.close_session(SessionID("nonexistent")) + + @pytest.mark.asyncio + async def test_new_session_no_app(self, mock_poller, mock_path): + """Test creating session with no matching app.""" + manager = SessionManager(mock_poller, mock_path, []) + + result = await manager.new_session( + "nonexistent", + SessionID("test"), + RouteKey("route"), + ) + + assert result is None + + @pytest.mark.asyncio + @pytest.mark.skipif(platform.system() == "Windows", reason="Terminal not supported on Windows") + async def test_new_terminal_session(self, mock_poller, mock_path): + """Test creating a new terminal session.""" + from textual_webterm.terminal_session import TerminalSession + + app = App(name="Terminal", slug="term", path="./", command="echo test", terminal=True) + manager = SessionManager(mock_poller, mock_path, [app]) + + with patch.object(TerminalSession, "open", new_callable=AsyncMock): + result = await manager.new_session( + "term", + SessionID("test-session"), + RouteKey("test-route"), + ) + + assert result is not None + assert isinstance(result, TerminalSession) + assert SessionID("test-session") in manager.sessions + assert RouteKey("test-route") in manager.routes + + @pytest.mark.asyncio + async def test_new_app_session(self, mock_poller, mock_path): + """Test creating a new app session.""" + from textual_webterm.app_session import AppSession + + app = App(name="App", slug="app", path="./", command="python app.py", terminal=False) + manager = SessionManager(mock_poller, mock_path, [app]) + + with patch.object(AppSession, "open", new_callable=AsyncMock): + result = await manager.new_session( + "app", + SessionID("test-session"), + RouteKey("test-route"), + ) + + assert result is not None + assert isinstance(result, AppSession) + + +class TestSessionManagerRoutes: + """Tests for SessionManager route handling.""" + + @pytest.fixture + def manager(self, tmp_path): + """Create a session manager with mock poller.""" + mock_poller = MagicMock() + return SessionManager(mock_poller, tmp_path, []) + + def test_route_mapping(self, manager): + """Test route to session mapping.""" + session_id = SessionID("session1") + route_key = RouteKey("route1") + + manager.routes[route_key] = session_id + + assert manager.routes.get(route_key) == session_id + assert manager.routes.get_key(session_id) == route_key + + def test_get_session_by_route(self, manager): + """Test getting session by route key.""" + session_id = SessionID("session1") + route_key = RouteKey("route1") + mock_session = MagicMock() + + manager.sessions[session_id] = mock_session + manager.routes[route_key] = session_id + + result = manager.get_session_by_route_key(route_key) + assert result == mock_session + + def test_get_first_running_session_none(self, manager): + """Test getting first running session when empty.""" + assert manager.get_first_running_session() is None + + def test_get_first_running_session_found(self, manager): + """Test getting first running session.""" + session_id = SessionID("s1") + route_key = RouteKey("r1") + mock_session = MagicMock() + mock_session.is_running.return_value = True + + manager.sessions[session_id] = mock_session + manager.routes[route_key] = session_id + + result = manager.get_first_running_session() + assert result == (route_key, mock_session) diff --git a/tests/test_slugify.py b/tests/test_slugify.py new file mode 100644 index 0000000..628dd37 --- /dev/null +++ b/tests/test_slugify.py @@ -0,0 +1,40 @@ +"""Tests for slugify module.""" + +from textual_webterm.slugify import slugify + + +class TestSlugify: + """Tests for the slugify function.""" + + def test_lowercase(self): + """Test that slugify converts to lowercase.""" + assert slugify("HelloWorld") == "helloworld" + + def test_spaces_to_dashes(self): + """Test that spaces are converted to dashes.""" + assert slugify("hello world") == "hello-world" + + def test_multiple_spaces(self): + """Test that multiple spaces become single dash.""" + assert slugify("hello world") == "hello-world" + + def test_special_characters_removed(self): + """Test that special characters are removed.""" + assert slugify("hello@world!") == "helloworld" + + def test_combined(self): + """Test combination of transformations.""" + assert slugify("Hello World!") == "hello-world" + + def test_empty_string(self): + """Test empty string.""" + assert slugify("") == "" + + def test_numbers_preserved(self): + """Test that numbers are preserved.""" + assert slugify("test123") == "test123" + + def test_leading_trailing_spaces(self): + """Test that leading/trailing spaces are handled.""" + result = slugify(" hello ") + assert "hello" in result diff --git a/tests/test_terminal_session.py b/tests/test_terminal_session.py new file mode 100644 index 0000000..a1aea92 --- /dev/null +++ b/tests/test_terminal_session.py @@ -0,0 +1,194 @@ +"""Tests for terminal_session module.""" + +import os +import platform +import pty +import shlex +from unittest.mock import MagicMock, patch + +import pytest + +# Skip tests on Windows +pytestmark = pytest.mark.skipif( + platform.system() == "Windows", + reason="Terminal sessions not supported on Windows", +) + + +class TestTerminalSession: + """Tests for TerminalSession class.""" + + def test_import(self): + """Test that module can be imported.""" + from textual_webterm.terminal_session import TerminalSession + + assert TerminalSession is not None + + def test_replay_buffer_size(self): + """Test replay buffer size constant.""" + from textual_webterm.terminal_session import REPLAY_BUFFER_SIZE + + assert REPLAY_BUFFER_SIZE == 64 * 1024 # 64KB + + def test_init(self): + """Test TerminalSession initialization.""" + from textual_webterm.terminal_session import TerminalSession + + mock_poller = MagicMock() + session = TerminalSession(mock_poller, "test-session", "bash") + + assert session.session_id == "test-session" + assert session.command == "bash" + assert session.master_fd is None + assert session.pid is None + assert session._task is None + + def test_init_default_shell(self): + """Test that default shell is used when command is empty.""" + from textual_webterm.terminal_session import TerminalSession + + mock_poller = MagicMock() + with patch.dict(os.environ, {"SHELL": "/bin/zsh"}): + session = TerminalSession(mock_poller, "test-session", "") + assert session.command == "/bin/zsh" + + @pytest.mark.asyncio + async def test_replay_buffer_add(self): + """Test adding data to replay buffer.""" + from textual_webterm.terminal_session import TerminalSession + + mock_poller = MagicMock() + session = TerminalSession(mock_poller, "test-session", "bash") + + await session._add_to_replay_buffer(b"test data") + assert session._replay_buffer_size == 9 + assert await session.get_replay_buffer() == b"test data" + + @pytest.mark.asyncio + async def test_replay_buffer_multiple_adds(self): + """Test adding multiple chunks to replay buffer.""" + from textual_webterm.terminal_session import TerminalSession + + mock_poller = MagicMock() + session = TerminalSession(mock_poller, "test-session", "bash") + + await session._add_to_replay_buffer(b"chunk1") + await session._add_to_replay_buffer(b"chunk2") + assert await session.get_replay_buffer() == b"chunk1chunk2" + + @pytest.mark.asyncio + async def test_replay_buffer_overflow(self): + """Test that replay buffer trims old data when exceeding limit.""" + from textual_webterm.terminal_session import ( + REPLAY_BUFFER_SIZE, + TerminalSession, + ) + + mock_poller = MagicMock() + session = TerminalSession(mock_poller, "test-session", "bash") + + # Add more data than buffer size + chunk_size = 1024 + for _i in range(100): # 100KB total + await session._add_to_replay_buffer(b"x" * chunk_size) + + # Buffer should be trimmed + assert session._replay_buffer_size <= REPLAY_BUFFER_SIZE + chunk_size + + def test_update_connector(self): + """Test updating connector.""" + from textual_webterm.terminal_session import TerminalSession + + mock_poller = MagicMock() + session = TerminalSession(mock_poller, "test-session", "bash") + + mock_connector = MagicMock() + session.update_connector(mock_connector) + assert session._connector == mock_connector + + def test_is_running_not_started(self): + """Test is_running when session not started.""" + from textual_webterm.terminal_session import TerminalSession + + mock_poller = MagicMock() + session = TerminalSession(mock_poller, "test-session", "bash") + + assert session.is_running() is False + + @pytest.mark.asyncio + async def test_send_bytes_no_fd(self): + """Test send_bytes returns False when no master_fd.""" + from textual_webterm.terminal_session import TerminalSession + + mock_poller = MagicMock() + session = TerminalSession(mock_poller, "test-session", "bash") + + result = await session.send_bytes(b"test") + assert result is False + + @pytest.mark.asyncio + async def test_send_meta(self): + """Test send_meta returns True.""" + from textual_webterm.terminal_session import TerminalSession + + mock_poller = MagicMock() + session = TerminalSession(mock_poller, "test-session", "bash") + + result = await session.send_meta({}) + assert result is True + + @pytest.mark.asyncio + async def test_close_no_pid(self): + """Test close when no pid.""" + from textual_webterm.terminal_session import TerminalSession + + mock_poller = MagicMock() + session = TerminalSession(mock_poller, "test-session", "bash") + + # Should not raise + await session.close() + + @pytest.mark.asyncio + async def test_wait_no_task(self): + """Test wait when no task.""" + from textual_webterm.terminal_session import TerminalSession + + mock_poller = MagicMock() + session = TerminalSession(mock_poller, "test-session", "bash") + + # Should not raise + await session.wait() + + def test_rich_repr(self): + """Test rich repr output.""" + from textual_webterm.terminal_session import TerminalSession + + mock_poller = MagicMock() + session = TerminalSession(mock_poller, "test-session", "bash") + + repr_items = list(session.__rich_repr__()) + assert ("session_id", "test-session") in repr_items + assert ("command", "bash") in repr_items + + @pytest.mark.asyncio + async def test_open_uses_shlex_split_and_execvp_with_args(self): + from textual_webterm.terminal_session import TerminalSession + + mock_poller = MagicMock() + command = 'echo "hello world"' + session = TerminalSession(mock_poller, "test-session", command) + + with ( + patch("textual_webterm.terminal_session.pty.fork", return_value=(pty.CHILD, 123)) as mock_fork, + patch("textual_webterm.terminal_session.version", return_value="0.0.0"), + patch("textual_webterm.terminal_session.shlex.split", wraps=shlex.split) as mock_split, + patch("textual_webterm.terminal_session.os.execvp", side_effect=OSError()) as mock_execvp, + patch("textual_webterm.terminal_session.os._exit", side_effect=SystemExit(1)) as mock_exit, + pytest.raises(SystemExit), + ): + await session.open() + + mock_fork.assert_called_once() + mock_split.assert_called_once_with(command) + mock_execvp.assert_called_once_with("echo", ["echo", "hello world"]) + mock_exit.assert_called_once_with(1) diff --git a/tests/test_two_way_dict.py b/tests/test_two_way_dict.py new file mode 100644 index 0000000..0f95b68 --- /dev/null +++ b/tests/test_two_way_dict.py @@ -0,0 +1,71 @@ +"""Tests for TwoWayDict.""" + +from __future__ import annotations + +from textual_webterm._two_way_dict import TwoWayDict + + +class TestTwoWayDict: + """Tests for TwoWayDict bidirectional mapping.""" + + def test_set_and_get(self) -> None: + """Test basic set and get operations.""" + d: TwoWayDict[str, int] = TwoWayDict() + d["a"] = 1 + d["b"] = 2 + assert d.get("a") == 1 + assert d.get("b") == 2 + + def test_get_key(self) -> None: + """Test reverse lookup by value.""" + d: TwoWayDict[str, int] = TwoWayDict() + d["a"] = 1 + d["b"] = 2 + assert d.get_key(1) == "a" + assert d.get_key(2) == "b" + + def test_delete(self) -> None: + """Test deletion removes both mappings.""" + d: TwoWayDict[str, int] = TwoWayDict() + d["a"] = 1 + del d["a"] + assert d.get("a") is None + assert d.get_key(1) is None + + def test_contains(self) -> None: + """Test key containment check.""" + d: TwoWayDict[str, int] = TwoWayDict() + d["a"] = 1 + assert "a" in d + assert "b" not in d + + def test_contains_value(self) -> None: + """Test value containment check.""" + d: TwoWayDict[str, int] = TwoWayDict() + d["a"] = 1 + assert d.contains_value(1) is True + assert d.contains_value(2) is False + + def test_len(self) -> None: + """Test length of dictionary.""" + d: TwoWayDict[str, int] = TwoWayDict() + assert len(d) == 0 + d["a"] = 1 + assert len(d) == 1 + d["b"] = 2 + assert len(d) == 2 + + def test_iter(self) -> None: + """Test iteration over keys.""" + d: TwoWayDict[str, int] = TwoWayDict() + d["a"] = 1 + d["b"] = 2 + keys = list(d) + assert "a" in keys + assert "b" in keys + + def test_initial_data(self) -> None: + """Test initialization with data.""" + d: TwoWayDict[str, int] = TwoWayDict({"a": 1, "b": 2}) + assert d.get("a") == 1 + assert d.get_key(2) == "b"