diff --git a/.dockerignore b/.dockerignore index 27829b9..757b526 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,29 +1,5 @@ -# Keep the build context minimal for pip installs -.git -.github -.idea -.vscode -__pycache__ -*.py[cod] -*.pyo -*.pyd -.pytest_cache -.ruff_cache -.mypy_cache -.coverage -htmlcov -build -dist -*.egg-info -node_modules -bun.lock -bun.lockb -package-lock.json -package.json -tsconfig.json -bunfig.toml -tests -docs -examples -Dockerfile -LICENSE +* +!go/ +!go/** +!Dockerfile +!.dockerignore diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 10905b2..dc03b63 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,50 +2,27 @@ ## Makefile Usage (MANDATORY) -Always use the Makefile for development tasks. Never run raw `pytest`, `ruff`, or `bun` commands directly. +Always use the Makefile for development tasks. Never run raw `go test`, `go vet`, or `bun` commands directly. ### Quick Reference | Task | Command | Description | |------|---------|-------------| -| Run tests | `make test` | Run pytest | -| Run linter | `make lint` | Run ruff linter | -| Format code | `make format` | Auto-format with ruff | +| Run tests | `make test` | Run Go tests | +| Run linter | `make lint` | Run `go vet` | +| Format code | `make format` | Run `gofmt` | +| Coverage report | `make coverage` | Run Go coverage with `-coverpkg` | | Full check | `make check` | Run lint + coverage | -| Coverage report | `make coverage` | Run pytest with coverage | +| Race tests | `make race` | Run `go test -race` | +| Fuzz smoke run | `make fuzz` | Run all fuzz targets briefly | | Build frontend | `make build` | TypeScript typecheck + bundle | -| Quick frontend build | `make build-fast` | Bundle without typecheck | -| Watch mode | `make bundle-watch` | Frontend dev with auto-rebuild | -| Install dev deps | `make install-dev` | Install package + dev dependencies | -| Clean build | `make build-all` | Full reproducible build from scratch | +| Build Go binary | `make build-go` | Build CLI binary | +| Full rebuild | `make build-all` | Clean + deps + build + checks | | See all targets | `make help` | Show all available commands | ### Development Workflow -1. **Before making changes**: Run `make check` to establish baseline -2. **After making changes**: Run `make check` to verify no regressions -3. **Frontend changes**: Use `make build` or `make build-fast` -4. **Full rebuild**: Use `make build-all` (cleans everything first) - -### Clean Targets - -- `make clean` - Remove Python cache files only -- `make bundle-clean` - Remove frontend build artifacts (node_modules, etc.) -- `make clean-all` - Remove everything - -### Version Management - -- `make bump-patch` - Bump patch version and create git tag - -## Testing Guidelines - -- Aim for good test coverage -- Review tests periodically to consolidate/parameterize and remove redundancy -- Use fixtures from `tests/conftest.py` instead of duplicating setup code -- Prefer parameterized tests for similar test cases - -## Code Style - -- Do not use heredocs or random shell commands -- Prefer `make` and ecosystem tools (pip, bun) over manual operations -- Debug issues systematically - search for and review documentation as needed +1. **Before changes**: Run `make check` to establish baseline. +2. **After changes**: Run `make check` and `make race`. +3. **Frontend edits**: Run `make build` after changing `go/webterm/static/js/terminal.ts`. +4. **Major validation**: Run `make build-all` for a reproducible full run. diff --git a/.github/instructions/00-project-detection.instructions.md b/.github/instructions/00-project-detection.instructions.md index 0e4cc03..84d573e 100644 --- a/.github/instructions/00-project-detection.instructions.md +++ b/.github/instructions/00-project-detection.instructions.md @@ -2,7 +2,6 @@ Use these heuristics to decide which other instruction files apply: -- If `pyproject.toml` or `requirements*.txt` exists -> apply `python.instructions.md`. - If `go.mod` exists -> apply `go.instructions.md`. - If `Dockerfile` exists and CI is publishing images -> apply `docker-image.instructions.md`. - If `package.json` exists (or Bun is referenced) -> apply `frontend-bun.instructions.md`. diff --git a/.github/instructions/go.instructions.md b/.github/instructions/go.instructions.md index 26ae1dd..4745b3f 100644 --- a/.github/instructions/go.instructions.md +++ b/.github/instructions/go.instructions.md @@ -3,9 +3,9 @@ Applies when: this repo has `go.mod`. ## Makefile-first workflow -- CI should run `make check` and Go tests (`cd go && go test ./...` in this repository). +- CI should run `make check`, and `make race` for concurrency-sensitive paths. - Put `golangci-lint` and `gosec` wiring behind Make targets when introduced. ## Conventions to implement -- `make test` should run `go test ./...` (prefer `-race` and coverage in CI). +- `make test` should run `cd go && go test ./...`. - Avoid bespoke CI steps when a Make target can encode the same behavior. diff --git a/.github/instructions/python.instructions.md b/.github/instructions/python.instructions.md deleted file mode 100644 index ce1138c..0000000 --- a/.github/instructions/python.instructions.md +++ /dev/null @@ -1,12 +0,0 @@ -# Python project instructions - -Applies when: this repo has `pyproject.toml` or `requirements*.txt`. - -## Makefile-first workflow -- Prefer `make check` as the default validation gate. -- If you add/change tooling, wire it through Make targets (don't ask users to run raw `pytest`/`ruff`). - -## Conventions to implement -- Prefer `ruff` for lint+format. -- Prefer `python -m pytest` (and `--cov` for coverage) via Make targets. -- Keep CI calling Make targets, not raw commands. diff --git a/.github/skills/screenshot-debugging/SKILL.md b/.github/skills/screenshot-debugging/SKILL.md index 6058043..b724440 100644 --- a/.github/skills/screenshot-debugging/SKILL.md +++ b/.github/skills/screenshot-debugging/SKILL.md @@ -1,65 +1,28 @@ # Screenshot Debugging Skill ## Purpose -Diagnose terminal screenshot corruption caused by incomplete escape-sequence handling. +Diagnose terminal screenshot corruption caused by incomplete terminal-state updates. ## When to Use -- SVG screenshot shows stale or overlaid content after clear/redraw. -- Behavior differs between live terminal output and screenshot snapshots. -- Issues appear inside tmux/vim/less or other full-screen TUIs. +- SVG screenshots show stale or overlaid text. +- Live WebSocket output looks correct but `/screenshot.svg` does not. +- Issues appear with full-screen TUIs (tmux/vim/less). ## Procedure -1. **Reproduce and capture raw output** - - Capture PTY output around the failing action (e.g., `clear` inside tmux). - - Ensure capture includes the full sequence before and after the command. - -2. **Replay into the emulator** - - Feed captured bytes into the same emulator used for screenshots (pyte + AltScreen). - - Inspect the rendered buffer for stale cells or overlay. - -3. **Scan for unhandled escape modes** - - Look for private modes: `?47`, `?1047`, `?1048`, `?1049`. - - Check erase semantics: `ED` (`J`), `EL` (`K`), `ECH` (`X`). - - Verify C1 controls are normalized to 7-bit ESC equivalents. - -4. **Fix emulator handling** - - Update AltScreen to recognize any missing alternate buffer modes (e.g., `?47`). - - Ensure mode toggles save/restore the main buffer and mark dirty lines. - +1. **Capture terminal bytes** + - Capture PTY output around the failing action. +2. **Replay into the Go tracker** + - Feed bytes through `internal/terminalstate` (go-te based). + - Confirm whether buffer state diverges from expected terminal behavior. +3. **Check preprocessing/filtering** + - Validate DA filtering and any partial-sequence buffering logic. +4. **Check dirty/refresh logic** + - Verify tracker updates happen before screenshot cache decisions. 5. **Add regression coverage** - - Add a focused test that replays the sequence and asserts the buffer is cleared. - - Include any new mode variants in existing parameterized tests. - + - Add a focused Go test and (when useful) fuzz corpus seed. 6. **Verify** - - Run `make check`. - - Re-test the real scenario and confirm screenshots match the live terminal. - -## Minimal Capture Snippet (PTY -> pyte) -```python -import os, pty, select, time, pyte -from webterm.alt_screen import AltScreen - -def read_all(fd, timeout=0.5): - out = b"" - end = time.time() + timeout - while time.time() < end: - r, _, _ = select.select([fd], [], [], 0.05) - if not r: - continue - try: - data = os.read(fd, 4096) - except OSError: - break - if not data: - break - out += data - return out - -screen = AltScreen(80, 24) -stream = pyte.ByteStream(screen) -stream.feed(raw_bytes) -``` + - Run `make check` and re-test the real scenario. ## Notes -- tmux often uses `DECSET ?47` (legacy alt buffer) instead of `?1049`. -- Always validate with real output captures, not just synthetic sequences. +- Prefer reproductions from real PTY captures over synthetic minimal sequences. +- If rendering differs only in dashboard thumbnails, inspect SSE activity gating and screenshot cache TTL/invalidations. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f109e7..d72e9ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,3 +35,12 @@ jobs: else echo "No Go module found; skipping." fi + + - name: Build runtime image (if Dockerfile present) + run: | + set -e + if [ -f Dockerfile ]; then + docker build --target runtime --tag webterm:ci . + else + echo "No Dockerfile found; skipping." + fi diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index e1316ee..816750c 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -51,6 +51,8 @@ jobs: uses: docker/build-push-action@v5 with: context: . + file: ./Dockerfile + target: runtime platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true @@ -345,4 +347,3 @@ jobs: core.setFailed(`Error managing package versions: ${error.message}`); } } - diff --git a/.gitignore b/.gitignore index 018a10b..2add649 100644 --- a/.gitignore +++ b/.gitignore @@ -1,142 +1,24 @@ -# macOS .DS_Store -.AppleDouble -.LSOverride - -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class -*.pyi - -# 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/ +.vscode/ *.swp *.swo *~ -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pyright -.pyright/ - -# cache / build artifacts -.cache/ -.coverage -htmlcov/ - -# pdm -.pdm.toml -.pdm-python -.pdm-build/ - -# webterm specific +# Runtime artifacts +*.log webterm.log -# Node.js / Bun (for development only) +# Coverage/build artifacts +go/coverage.out +coverage.out +dist/ +build/ +target/ + +# Go +go/bin/ + +# Frontend node_modules/ bun.lockb -package-lock.json diff --git a/Dockerfile b/Dockerfile index 604fad8..1bdc0a8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,38 +1,24 @@ -# Minimal image for serving a web terminal with Docker watch mode -# # Build: docker build -t webterm . # Run: docker run -v /var/run/docker.sock:/var/run/docker.sock -p 8080:8080 webterm --docker-watch -# -FROM python:3.12-slim AS builder -# Install build dependencies -RUN apt-get update && apt-get install -y --no-install-recommends \ - make \ - && rm -rf /var/lib/apt/lists/* +FROM golang:1.26-alpine AS builder -# Copy only what's needed for installation -WORKDIR /build -COPY pyproject.toml poetry.lock* ./ -COPY README.md ./ -COPY src/ ./src/ -# Install the package -RUN pip install --no-cache-dir . +WORKDIR /src +COPY go/go.mod go/go.sum ./go/ +RUN cd go && go mod download +COPY go ./go +RUN cd go && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/webterm ./cmd/webterm -# Final minimal image -FROM python:3.12-slim +FROM alpine:3.21 AS runtime -# Install only runtime dependencies (docker CLI for exec commands) -RUN apt-get update && apt-get install -y --no-install-recommends \ - docker.io \ - && rm -rf /var/lib/apt/lists/* +# Keep docker-cli for user-provided webterm-command values like `docker logs` / `docker exec`. +RUN apk add --no-cache ca-certificates docker-cli -# Copy installed packages from builder -COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages -COPY --from=builder /usr/local/bin/webterm /usr/local/bin/webterm +WORKDIR /app +COPY --from=builder /out/webterm /usr/local/bin/webterm +COPY go/webterm/static /app/static -# Create non-root user (optional, but may need root for Docker socket access) -# RUN useradd -m webterm -# USER webterm +ENV WEBTERM_STATIC_PATH=/app/static EXPOSE 8080 diff --git a/Makefile b/Makefile index 98427a8..d5515f9 100644 --- a/Makefile +++ b/Makefile @@ -1,53 +1,38 @@ -.PHONY: help install install-dev lint format test coverage check clean clean-all build build-all build-fast bundle bundle-watch bundle-clean typecheck bump-patch push +.PHONY: help install install-dev lint format test race coverage check fuzz build-go build build-fast bundle bundle-watch bundle-clean clean clean-all build-all typecheck -PYTHON ?= python3 -PIP ?= $(PYTHON) -m pip - -# Static assets -STATIC_JS_DIR = src/webterm/static/js +GO_DIR = go +STATIC_JS_DIR = go/webterm/static/js +TERMINAL_TS = $(STATIC_JS_DIR)/terminal.ts TERMINAL_JS = $(STATIC_JS_DIR)/terminal.js GHOSTTY_WASM = $(STATIC_JS_DIR)/ghostty-vt.wasm help: ## Show this help @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}' -# ============================================================================= -# Full reproducible build -# ============================================================================= +install: ## Download Go dependencies + cd $(GO_DIR) && go mod download -build-all: clean-all node_modules build install-dev check ## Full reproducible build (clean + deps + bundle + install) - @echo "Build complete!" +install-dev: install node_modules ## Install Go and frontend dependencies -# ============================================================================= -# Python targets -# ============================================================================= -# NOTE: Install dev tools (ruff/pytest/etc.) using `pip install --user --break-system-packages` -# from the dev dependency list (pyproject.toml or requirements-dev.txt). +lint: ## Run Go vet + cd $(GO_DIR) && go vet ./... -install: ## Install package in editable mode - $(PIP) install -e . +format: ## Format Go code + cd $(GO_DIR) && gofmt -w ./cmd ./internal ./webterm -install-dev: install ## Install with dev dependencies - $(PIP) install aiohttp uvloop click pydantic importlib-metadata tomli pyyaml pyte - $(PIP) install pytest pytest-asyncio pytest-cov pytest-timeout ruff +test: ## Run Go tests + cd $(GO_DIR) && go test ./... -lint: ## Run ruff linter - ruff check src tests +race: ## Run Go race tests + cd $(GO_DIR) && go test -race ./... -format: ## Format code with ruff - ruff format src tests +coverage: ## Run Go coverage for runtime package + cd $(GO_DIR) && go test ./webterm -coverprofile=coverage.out && go tool cover -func=coverage.out -test: ## Run pytest - pytest +check: lint test coverage ## Run lint + tests + coverage -coverage: ## Run pytest with coverage - pytest --cov=src/webterm --cov-report=term-missing - -check: lint coverage ## Run lint + coverage - -# ============================================================================= -# Frontend build targets (requires Bun: https://bun.sh) -# ============================================================================= +fuzz: ## Run all fuzz tests briefly + cd $(GO_DIR) && go test ./... -run=^$$ -fuzz=Fuzz -fuzztime=1s node_modules: package.json bun install @@ -69,40 +54,16 @@ bundle-watch: node_modules ## Watch mode for frontend development @test -f $(GHOSTTY_WASM) || bun run copy-wasm bun run watch -# ============================================================================= -# Clean targets -# ============================================================================= +build-go: ## Build Go CLI binary + cd $(GO_DIR) && mkdir -p bin && go build -o ./bin/webterm ./cmd/webterm -clean: ## Remove Python cache files - rm -rf .pytest_cache .coverage htmlcov .ruff_cache __pycache__ src/**/__pycache__ +clean: ## Remove coverage artifacts + rm -f $(GO_DIR)/coverage.out -bundle-clean: ## Remove frontend build artifacts - rm -rf node_modules bun.lock $(TERMINAL_JS) $(GHOSTTY_WASM) +bundle-clean: ## Remove frontend dependencies + rm -rf node_modules bun.lock -clean-all: clean bundle-clean ## Remove everything (clean + bundle-clean) +clean-all: clean bundle-clean ## Remove all generated artifacts -# ============================================================================= -# Version management -# ============================================================================= - -bump-patch: ## Bump patch version and create git tag - @OLD=$$(grep -Po '(?<=^version = ")[^"]+' pyproject.toml); \ - MAJOR=$$(echo $$OLD | cut -d. -f1); \ - MINOR=$$(echo $$OLD | cut -d. -f2); \ - PATCH=$$(echo $$OLD | cut -d. -f3); \ - NEW="$$MAJOR.$$MINOR.$$((PATCH + 1))"; \ - sed -i "s/^version = \"$$OLD\"/version = \"$$NEW\"/" pyproject.toml; \ - git add pyproject.toml; \ - git commit -m "Bump version to $$NEW"; \ - git tag "v$$NEW"; \ - echo "Bumped version: $$OLD -> $$NEW (tagged v$$NEW)" - -push: ## Push commits and current tag to origin - @TAG=$$(git describe --tags --exact-match 2>/dev/null); \ - git push origin main; \ - if [ -n "$$TAG" ]; then \ - echo "Pushing tag $$TAG..."; \ - git push origin "$$TAG"; \ - else \ - echo "No tag on current commit"; \ - fi +build-all: clean-all install-dev build check build-go ## Full reproducible build from scratch + @echo "Build complete!" diff --git a/README.md b/README.md index 9ded018..3d4a725 100644 --- a/README.md +++ b/README.md @@ -1,289 +1,118 @@ -# webterm +# webterm (Go) ![Icon](docs/icon-256.png) -Serve terminal sessions over the web with a simple CLI command. [Blog post](https://taoofmac.com/space/notes/2026/01/25/2030#seizing-the-means-of-production) - -> **Credit and Inspiration:** This project was originally based on the genius [textual-web](https://github.com/Textualize/textual-web) package, which uses `xterm.js`. It has been rewritten to use a [ghostty-web](https://github.com/coder/ghostty-web)'s WebAssembly-based terminal emulator, which provides better performance and native theme support. - -It is, for the moment, temporarily based on a [patched version of ghostty-web](https://github.com/rcarmo/ghostty-web), because the current version has bugs and feature gaps that I needed to fill. - -Coupled with [`agentbox`](https://github.com/rcarmo/agentbox), you can use it to keep track of several containerized AI coding agents, since it provides an easy way to expose terminal sessions via HTTP/WebSocket with automatic reconnection support: +`webterm` serves terminal sessions over HTTP/WebSocket, with a dashboard mode for multiple sessions and Docker-aware tiles. ![Screenshot](docs/screenshot.png) ## Features -- **Web-based terminal** - Access your terminal from any browser -- **Mobile support** - Works on iOS Safari and Android with on-screen keyboard modifier (experimental) and touch selection -- **Session reconnection** - Refresh the page and reconnect to the same session -- **Full terminal emulation** - Colors, cursor, and ANSI codes work correctly -- **Customizable themes** - 9 built-in themes (monokai, dracula, nord, etc.) -- **Custom fonts** - Configure terminal font family and size -- **Scrollback history** - Scroll back through terminal output (configurable) -- **Auto-sizing** - Terminal automatically resizes to fit the browser window -- **Live screenshots** - Dashboard shows real-time SVG screenshots of terminals -- **CPU sparklines** - Dashboard displays 30-minute CPU history for Docker containers -- **SSE updates** - Real-time screenshot updates via Server-Sent Events -- **Simple CLI** - One command to start serving +- Web terminal with reconnect support +- Session dashboard with live SVG screenshots +- Docker watch mode (`webterm-command` / `webterm-theme` labels) +- Docker compose manifest ingestion +- CPU sparkline tiles for compose services +- SSE activity updates for fast dashboard refresh +- Theme/font controls for terminal rendering -## Non-Features +## Install -- **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` - -## Known Issues - -- `pyte` (the library used to capture the underlying terminal state for screenshots) does not implement some standard escape sequences, resulting in occasionally mis-rendered screenshots. We monkeypatch pyte at runtime to add missing support (CSI S/T scroll, alternate screen buffers, etc.) — see [docs/pyte-patches.md](docs/pyte-patches.md) for details. - -## Installation - -Install directly from GitHub: - -```bash -pip install git+https://github.com/rcarmo/webterm.git -``` - -## Quick Start - -### Serve a Terminal - -Serve your default shell: - -```bash -webterm -``` - -Serve a specific command: - -```bash -webterm htop -``` - -### Options - -Specify host and port: - -```bash -webterm --host 0.0.0.0 --port 8080 bash -``` - -Customize theme and font: - -```bash -webterm --theme dracula --font-size 18 -webterm --theme nord --font-family "JetBrains Mono, monospace" -``` - -Available themes: `xterm` (default), `monokai`, `dark`, `light`, `dracula`, `catppuccin`, `nord`, `gruvbox`, `solarized`, `tokyo`. - -Then open http://localhost:8080 in your browser. - -## Session Dashboard - -You can serve a dashboard 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 -webterm --landing-manifest landing.yaml -``` - -### Docker Watch Mode - -Watch for Docker containers with `webterm-command` **or** `webterm-theme` labels and dynamically add/remove terminal sessions: - -```bash -webterm --docker-watch -``` - -When a container starts with either label, it automatically appears in the dashboard. When it stops, it's removed. Label values: - -- `webterm-command: auto` (or empty) - Opens a PTY via Docker exec API (override with `WEBTERM_DOCKER_AUTO_COMMAND`) -- `webterm-command: ` - Runs the specified command -- `webterm-theme: ` - Sets the terminal theme for that container (xterm, monokai, dark, light, dracula, catppuccin, nord, gruvbox, solarized, tokyo). Invalid themes fall back to `tango` and the page background defaults to black. - -Containers that only specify `webterm-theme` are still included and use the default auto command. - -**Environment Variables:** -- `WEBTERM_DOCKER_USERNAME` - Set to run Docker exec sessions as a specific user (default: root) -- `WEBTERM_DOCKER_AUTO_COMMAND` - Override the default `auto` command (default: `/bin/bash`). Supports `{container}` placeholder for the container name. -- `WEBTERM_SCREENSHOT_FORCE_REDRAW` - When set to `true`, send a SIGWINCH-style redraw before generating screenshots (default: false). - -Example: Start containers and exec into them as `developer` user: -```bash -WEBTERM_DOCKER_USERNAME=developer webterm --docker-watch -``` - -Example: Use tmux with per-container session names: -```bash -WEBTERM_DOCKER_AUTO_COMMAND="tmux new-session -ADs {container}" webterm --docker-watch -``` -This creates a tmux session named after each container (e.g., `my-webapp`, `redis`, etc.) instead of a shared session name. - -Example docker-compose.yaml: - -```yaml -services: - myapp: - image: myapp:latest - labels: - webterm-command: auto # Opens bash in container - webterm-theme: monokai - - logs: - image: myapp:latest - labels: - webterm-command: docker logs -f myapp # Shows logs - webterm-theme: nord -``` - -**Requires**: Docker socket access (`-v /var/run/docker.sock:/var/run/docker.sock`) - -### Docker Compose Integration - -Point to a docker-compose file; services with the label `webterm-command` become tiles (and `webterm-theme` applies there too): - -```yaml -services: - db: - image: postgres - labels: - webterm-command: docker exec -it db psql - webterm-theme: gruvbox -``` - -Start with: - -```bash -webterm --compose-manifest compose.yaml -``` - -In compose mode, the dashboard displays **CPU sparklines** showing 30 minutes of container CPU usage history (requires access to Docker socket at `/var/run/docker.sock`). - -### Dashboard Features - -- **Live screenshots** - Terminal thumbnails update in real-time via SSE when activity occurs -- **Dynamic updates** - In docker-watch mode, tiles appear/disappear as containers start/stop -- **CPU sparklines** - Mini charts showing container CPU usage (compose mode only) -- **Tab reuse** - Clicking the same tile reopens the existing browser tab -- **Auto-focus** - Terminals automatically receive keyboard focus on load - -## CLI Reference - -``` -Usage: webterm [OPTIONS] [COMMAND] - - Serve a terminal 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] - -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. - -D, --docker-watch Watch Docker for containers with - "webterm-command" label (dynamic mode). - -t, --theme TEXT Terminal color theme [default: xterm] - Options: xterm, monokai, dark, light, dracula, - catppuccin, nord, gruvbox, solarized, tokyo - -f, --font-family TEXT Terminal font family (CSS font stack) - -s, --font-size INTEGER Terminal font size in pixels [default: 16] - --version Show the version and exit. - --help Show this message and exit. -``` - -## API Endpoints - -| Endpoint | Description | -|----------|-------------| -| `/` | Dashboard (with manifest/docker-watch) or terminal view | -| `/ws/{route_key}` | WebSocket for terminal I/O | -| `/screenshot.svg?route_key=...` | SVG screenshot of terminal | -| `/cpu-sparkline.svg?container=...` | CPU sparkline SVG (compose mode) | -| `/tiles` | JSON list of current tiles (for dynamic dashboards) | -| `/events` | SSE stream for activity notifications | -| `/health` | Health check endpoint | - -## Development - -### Setup (Makefile-first) +### Build from source ```bash git clone https://github.com/rcarmo/webterm.git -cd webterm +cd webterm/go +mkdir -p bin +go build -o ./bin/webterm ./cmd/webterm +``` -# Install with dev dependencies via Makefile +The command above produces `go/bin/webterm`; you can also build it from repo root with `make build-go`. + +## Quick start + +Run a default shell session: + +```bash +cd go +go run ./cmd/webterm +``` + +Run a specific command: + +```bash +cd go +go run ./cmd/webterm -- htop +``` + +Then open . + +## Dashboard modes + +### Landing manifest + +```yaml +- name: Logs + slug: logs + command: docker logs -f my-service + theme: nord +``` + +```bash +cd go +go run ./cmd/webterm -- --landing-manifest ../landing.yaml +``` + +### Docker watch + +```bash +cd go +go run ./cmd/webterm -- --docker-watch +``` + +Containers with these labels become tiles: + +- `webterm-command`: command string, or `auto` for Docker exec +- `webterm-theme`: theme name (fallback is `xterm` palette) + +### Compose manifest + +```bash +cd go +go run ./cmd/webterm -- --compose-manifest ../docker-compose.yaml +``` + +## Environment variables + +- `WEBTERM_STATIC_PATH`: override static asset directory +- `WEBTERM_DOCKER_USERNAME`: user for Docker exec sessions +- `WEBTERM_DOCKER_AUTO_COMMAND`: override auto command (`/bin/bash` default) +- `WEBTERM_SCREENSHOT_FORCE_REDRAW`: force redraw before screenshots (`true/1/yes/on`) +- `DOCKER_HOST`: Docker daemon endpoint override + +## Development (Makefile-first) + +```bash make install-dev +make check +make race +make test ``` -### Common tasks (use Makefile) - -- Lint: `make lint` -- Format: `make format` -- Tests: `make test` -- Coverage (fail_under=78): `make coverage` -- Full check (lint + coverage): `make check` -- Bump patch version: `make bump-patch` - -### Frontend Development - -The terminal UI is built with a [patched version of ghostty-web](https://github.com/rcarmo/ghostty-web), which provides Ghostty's VT100 parser via WebAssembly with native theme/palette support. This replaces the original xterm.js dependency used in earlier versions. - -Key improvements over xterm.js: -- Native theme colors passed directly to WASM (no runtime color remapping) -- Smaller bundle size (~0.67 MB vs ~1.16 MB) -- IME input support for CJK languages -- Better Unicode and complex script rendering - -The pre-built bundle is committed to the repo, so users can `pip install` without needing Node.js. - -To rebuild the frontend after modifying `terminal.ts`: - -```bash -# Requires Bun (https://bun.sh) -bun install -bun run build -# Or simply: -make bundle -``` - -For development with auto-rebuild: +Frontend bundle tasks: ```bash +make build +make build-fast make bundle-watch ``` -### Notes +## Docker -- WebSocket protocol (browser ↔ server) is JSON: `["stdin", data]`, `["resize", {"width": w, "height": h}]`, `["ping", data]`. -- Frontend source is in `src/webterm/static/js/terminal.ts`. -- Screenshots use [pyte](https://github.com/selectel/pyte) for ANSI interpretation and custom SVG rendering. `AltScreen` adds alternate screen buffer support, [CSI S/T scroll handling, and Ink partial clear expansion](docs/pyte-patches.md). -- Go runtime port is in `go/webterm` (entrypoint: `go/cmd/webterm`), using [go-te](https://github.com/rcarmo/go-te) for terminal state and screenshots. -- CPU stats are read directly from Docker socket using asyncio (no additional dependencies). +```bash +docker build -t webterm . +docker run -v /var/run/docker.sock:/var/run/docker.sock -p 8080:8080 webterm --docker-watch +``` -## Requirements - -- Python 3.9+ -- Bun -- Linux or macOS - -## License - -MIT License - see [LICENSE](LICENSE) for details. - -## Related Projects - -- [ghostty-web](https://github.com/rcarmo/ghostty-web) - Patched Ghostty terminal for the web (vendored fork with theme support) -- [ghostty-web upstream](https://github.com/coder/ghostty-web) - Original Ghostty terminal for the web -- [pyte](https://github.com/selectel/pyte) - PYTE terminal emulator (used for SVG screenshots) -- [go-te](https://github.com/rcarmo/go-te) - Go VT terminal emulator used for the in-progress port +The image sets `WEBTERM_STATIC_PATH=/app/static` and serves assets from `go/webterm/static`. +The Dockerfile uses a minimal Alpine runtime stage and only installs `ca-certificates` plus `docker-cli`. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 72b7dce..5651b7c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,257 +1,62 @@ # Architecture -This document describes the internal architecture of webterm. - ## Overview -webterm is a web-based terminal server that exposes terminal sessions over HTTP and WebSocket. It's designed to run behind a reverse proxy with authentication. - -> **Note:** As of v1.0.0, this project uses [ghostty-web](https://github.com/rcarmo/ghostty-web) (a patched fork with native theme support) instead of xterm.js. +`webterm` is a Go HTTP/WebSocket server that hosts one or more terminal sessions and renders screenshot/telemetry surfaces for a dashboard UI. ``` -┌─────────────┐ ┌──────────────────────────────────────────────────┐ -│ Browser │─────▶│ local_server.py │ -│ (ghostty- │◀─────│ (aiohttp web server) │ -│ web) │ │ │ -└─────────────┘ │ ┌──────────────┐ ┌──────────────────────────┐ │ - │ │ │ session_ │ │ terminal_session.py │ │ - │ WebSocket │ │ manager.py │──│ (PTY + pyte emulator) │ │ - └────────────▶│ └──────────────┘ └──────────────────────────┘ │ - │ │ - │ ┌──────────────┐ ┌──────────────────────────┐ │ - │ │ poller.py │ │ docker_stats.py │ │ - │ │ (I/O thread) │ │ (CPU metrics via socket) │ │ - │ └──────────────┘ └──────────────────────────┘ │ - └──────────────────────────────────────────────────┘ +Browser (terminal.js + ghostty-vt.wasm) + │ + │ WS / HTTP / SSE + ▼ +go/webterm/server.go (LocalServer) + │ + ├── session_manager.go (route/app/session registry) + ├── terminal_session.go (PTY-backed local sessions) + ├── docker_exec_session.go (Docker exec-backed sessions) + ├── docker_watcher.go (container add/remove discovery) + ├── docker_stats.go (CPU sampling + sparkline data) + └── svg_exporter.go (terminal snapshot -> SVG) ``` -## Core Components +## Packages -### local_server.py +- `go/cmd/webterm`: CLI entrypoint +- `go/webterm`: server/runtime/domain logic +- `go/internal/terminalstate`: Go terminal emulator wrapper (`go-te`) used for screenshots -The main HTTP/WebSocket server built on aiohttp. Handles: +## Runtime data flow -- **HTTP routes**: Dashboard, screenshots, sparklines, SSE events, health checks -- **WebSocket connections**: Terminal I/O multiplexing with JSON protocol -- **Screenshot caching**: Time-based and change-based cache invalidation -- **SSE broadcasting**: Real-time activity notifications to dashboard +1. Browser connects to `/ws/{route_key}`. +2. `SessionManager` resolves or creates a session. +3. Session reads PTY output and updates: + - live WS stream (`stdout`) + - replay buffer (reconnect support) + - terminal-state tracker (`go-te`) for screenshots +4. Dashboard pulls `/screenshot.svg` and listens on `/events` for activity. -Key classes: -- `Server`: Main server class managing routes and session lifecycle +## Static assets -### session_manager.py +Assets live in `go/webterm/static`: -Manages the mapping between route keys and sessions: +- `js/terminal.ts` source +- `js/terminal.js` bundled client +- `js/ghostty-vt.wasm` +- `monospace.css`, icons, `manifest.json` -- **TwoWayDict**: Bidirectional mapping of RouteKey ↔ SessionID -- **Session creation**: Creates TerminalSession on demand -- **App registry**: Stores terminal configurations from manifest files +The server resolves static files from: -### terminal_session.py +1. `WEBTERM_STATIC_PATH` (if set) +2. local repository-relative fallbacks rooted at `go/webterm/static` -Manages a single terminal session: +## Docker integration -- **PTY management**: Fork/exec with pseudo-terminal -- **pyte emulator**: Uses `AltScreen` (patched pyte) for ANSI interpretation -- **Data pipeline**: C1 normalization → `expand_clear_sequences()` → `stream.feed()` -- **Replay buffer**: 64KB ring buffer for reconnection support -- **Resize handling**: Propagates window size changes to PTY +- **Compose mode** loads services from a compose manifest and creates tiles for services carrying `webterm-command`. +- **Watch mode** subscribes to Docker events and adds/removes tiles at runtime. +- `webterm-theme` controls tile theme; default theme applies if unset. -The pyte screen buffer provides character-level access for screenshots. +## Reliability notes -### poller.py - -Background thread for non-blocking PTY I/O: - -- **selector-based**: Uses `selectors.DefaultSelector` for efficient I/O -- **Async queues**: Bridges sync I/O thread to async main loop -- **Write queuing**: Handles backpressure for terminal input - -### svg_exporter.py - -Custom SVG renderer for terminal screenshots: - -- **Per-character positioning**: Each character has explicit x coordinate -- **Box-drawing scaling**: Vertical 1.2x scale for line-height alignment -- **Color handling**: ANSI 16-color palette + 256-color + truecolor -- **Wide character support**: Proper column tracking for CJK characters - -### docker_stats.py - -Collects CPU metrics from Docker containers: - -- **Unix socket client**: Direct HTTP-over-Unix-socket to Docker API -- **Compose awareness**: Filters containers by compose project label -- **History buffer**: 180 samples (30 min at 10s intervals) -- **Sparkline SVG**: Renders mini CPU graphs - -## Data Flow - -### Terminal I/O - -``` -Browser Server PTY - │ │ │ - │──["stdin", "ls\n"]──────▶│ │ - │ │────write(b"ls\n")───────▶│ - │ │ │ - │ │◀───read(output)──────────│ - │◀─["stdout", "..."]───────│ │ -``` - -### Screenshot Generation - -``` -Dashboard ──GET /screenshot.svg──▶ Server - │ - ▼ - TerminalSession - │ - get_screen_state() - │ - ▼ - pyte.Screen - .buffer - │ - ▼ - svg_exporter.py - render_terminal_svg() - │ - ▼ - ... -``` - -### SSE Activity Updates - -``` -Dashboard ──GET /events──▶ Server (SSE connection held open) - │ -Terminal activity ───────────▶│ - │ - ▼ - Broadcast to all SSE clients: - data: {"route_key": "...", "type": "activity"} -``` - -## Session Lifecycle - -1. **Browser connects** to `/ws/{route_key}` -2. **SessionManager** looks up or creates session for route_key -3. **TerminalSession** forks PTY process, initializes pyte emulator -4. **Poller** registers PTY fd for I/O events -5. **WebSocket handler** bridges browser ↔ PTY via JSON messages -6. **On disconnect**: Session stays alive; browser can reconnect -7. **On reconnect**: Replay buffer restores recent output - -## Configuration - -### Manifest-based (--landing-manifest) - -```yaml -- name: Display Name - slug: route-key - command: /path/to/command -``` - -### Compose-based (--compose-manifest) - -Reads docker-compose.yaml, creates tiles for services with `webterm-command` label: - -```yaml -services: - myservice: - labels: - webterm-command: docker exec -it myservice bash -``` - -## WebSocket Protocol - -JSON-encoded messages over WebSocket: - -| Direction | Message | Description | -|-----------|---------|-------------| -| Client→Server | `["stdin", "data"]` | Terminal input | -| Client→Server | `["resize", {"width": N, "height": M}]` | Window resize | -| Client→Server | `["ping", data]` | Keep-alive | -| Server→Client | `["stdout", "data"]` | Terminal output | -| Server→Client | `["pong", data]` | Keep-alive response | - -## API Endpoints - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/` | GET | Dashboard HTML or terminal redirect | -| `/ws/{route_key}` | WS | WebSocket terminal connection | -| `/screenshot.svg` | GET | SVG screenshot (query: `route_key`) | -| `/cpu-sparkline.svg` | GET | CPU sparkline (query: `container`) | -| `/events` | GET | SSE stream for activity updates | -| `/health` | GET | Health check | - -## Key Design Decisions - -### Why bundle ghostty-web directly? - -We bundle a [patched version of ghostty-web](https://github.com/rcarmo/ghostty-web) for: - -- **Native theme support** - Theme colors are passed directly to WASM, no runtime remapping needed -- **Production-tested VT100 parser** - Uses Ghostty's battle-tested parser via WebAssembly -- **Full configuration control** - fontFamily, scrollback, theme are configurable via CLI -- **IME support** - Proper input method support for CJK languages -- **Mobile support** - Custom keyboard handling for iOS Safari and Android -- **Smaller bundle** - ~0.67 MB (down from 1.16 MB with color patching) -- **11 built-in themes** - xterm, monokai, ristretto, dark, light, dracula, catppuccin, nord, gruvbox, solarized, tokyo - -The pre-built `terminal.js` bundle is committed to the repo so users can `pip install` without needing Node.js/Bun. - -### Why custom SVG exporter? - -Rich's `export_svg()` had alignment issues with box-drawing characters and varied font rendering across browsers. The custom exporter: - -- Positions each character individually for pixel-perfect alignment -- Scales box-drawing characters vertically to fill line height -- Uses explicit x coordinates instead of relying on font metrics - -### Why pyte? - -pyte provides a pure-Python terminal emulator that tracks screen state character-by-character, enabling: - -- Screenshot generation without screen scraping -- Dirty tracking for efficient cache invalidation -- Full ANSI/VT100 escape sequence support - -pyte 0.8.2 has gaps (no alternate screen buffer, no CSI S/T scroll), so `AltScreen` in `alt_screen.py` monkeypatches pyte at import time to fill them — see [pyte-patches.md](pyte-patches.md) for details. - -### Why session persistence? - -Unlike traditional web terminals, sessions survive page refreshes: - -- Replay buffer allows catching up on missed output -- SessionManager keeps sessions alive until explicit close -- Enables dashboard with multiple live terminal thumbnails - -## File Structure - -``` -src/webterm/ -├── alt_screen.py # pyte Screen subclass with alt buffer + SU/SD patches -├── cli.py # Click CLI entry point -├── config.py # Configuration parsing (YAML manifests) -├── local_server.py # Main HTTP/WebSocket server -├── session_manager.py # Session registry and routing -├── session.py # Abstract session interface -├── terminal_session.py # PTY-based terminal session -├── poller.py # Async I/O polling thread -├── svg_exporter.py # Terminal→SVG renderer -├── docker_stats.py # Docker CPU metrics collector -├── exit_poller.py # Graceful shutdown handling -├── identity.py # Session ID generation -├── slugify.py # URL-safe slug generation -├── types.py # Type aliases -├── constants.py # Platform constants -└── static/ - ├── monospace.css # Font stack CSS variables - └── js/ - ├── terminal.ts # ghostty-web client source (TypeScript) - ├── terminal.js # Pre-built bundle (committed) - └── ghostty-vt.wasm # Ghostty VT100 parser (WebAssembly) -``` +- WebSocket writes are serialized through a sender queue. +- Session-manager maps are lock-protected and race-tested. +- Replay buffers are bounded to avoid unbounded memory growth. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md deleted file mode 100644 index b000120..0000000 --- a/docs/ROADMAP.md +++ /dev/null @@ -1,890 +0,0 @@ -# Roadmap: Migration to ghostty-web - -This document outlines the completed migration from xterm.js/textual-serve to ghostty-web. - -## Status: ✅ Complete (v1.0.0) - -The migration has been completed and merged to `main`. - -### What Was Done - -- [x] **Phase 1: Tooling Setup** - Added `package.json`, `bunfig.toml`, Makefile targets -- [x] **Phase 2: Terminal Client** - Created `terminal.ts` with full WebSocket protocol -- [x] **Phase 3: Server Integration** - Updated HTML template, removed monkey-patch -- [x] **Phase 4: Configuration** - Added CLI options for theme, font-family, font-size -- [x] **Phase 5: Remove Dependency** - Dropped `textual-serve` from pyproject.toml -- [x] **Phase 6: Documentation** - Updated README.md and ARCHITECTURE.md -- [x] **Phase 7: Mobile Support** - Added hidden textarea for iOS Safari keyboard -- [x] **Phase 8: Native Theme Support** - Upgraded to patched ghostty-web with WASM palette support - -### Key Outcomes - -| Metric | Before (xterm.js) | After (ghostty-web 0.4.0) | -|--------|-------------------|---------------------------| -| textual-serve dependency | Required | ❌ Removed | -| Terminal engine | xterm.js 6.0 | ghostty-web (Ghostty VT parser via WASM) | -| Theme handling | Runtime color remapping | Native WASM palette support | -| Scrollback history | 0 (none) | 1000 (configurable) | -| Theme configuration | None | 11 built-in themes via `--theme` | -| Font configuration | Monkey-patch workaround | `--font-family` and `--font-size` CLI options | -| Mobile Safari | No keyboard | ✅ Hidden textarea for keyboard input | -| IME support | Limited | ✅ Full CJK input method support | -| Bundle size | 1.16 MB | 0.67 MB | - -### ghostty-web Fork - -We use a [patched version of ghostty-web](https://github.com/rcarmo/ghostty-web) that adds: - -- Native theme/palette support at the WASM level via `buildWasmConfig()` -- Theme colors passed directly to `createTerminal()` config -- No runtime color remapping needed -- IME input fixes for CJK languages - -### Files Changed - -``` -Added: - package.json # ghostty-web + TypeScript - bunfig.toml # Bun configuration - tsconfig.json # TypeScript configuration - src/.../static/js/terminal.ts # TypeScript source - src/.../static/js/terminal.js # Pre-built bundle (committed) - src/.../static/js/ghostty-vt.wasm # Ghostty VT100 parser - -Modified: - pyproject.toml # Removed textual-serve dependency, version 1.0.0 - Makefile # Added bundle/bundle-watch/bump-patch targets - .gitignore # Added node_modules/ - src/.../cli.py # Added --theme, --font-family, --font-size - src/.../local_server.py # Pass theme/font config to HTML template - docs/ARCHITECTURE.md # Updated for ghostty-web - docs/ROADMAP.md # Migration complete - README.md # Updated documentation -``` - -### For Users - -No action required. The pre-built `terminal.js` bundle is committed to the repo: - -```bash -pip install webterm -# or -pip install git+https://github.com/rcarmo/webterm.git -``` - -Works without needing Node.js or Bun. - -### For Developers - -To modify the frontend: - -```bash -# Install Bun (https://bun.sh) -curl -fsSL https://bun.sh/install | bash - -# Install dependencies and build -make bundle - -# Or watch for changes during development -make bundle-watch - -# Bump patch version -make bump-patch -``` - ---- - -## Background Analysis - -The sections below document the original analysis that led to this migration. - -### What textual-serve Provided - -| Asset | Size | What We Used | Required? | -|-------|------|--------------|-----------| -| `static/js/textual.js` | 502 KB | xterm.js + WebSocket client | **Yes** | -| `static/css/xterm.css` | 4.6 KB | Terminal styling | **Yes** | -| `static/fonts/RobotoMono*.ttf` | 381 KB | Roboto Mono font | No (we override font) | -| `static/images/background.png` | 58 KB | Background image | No | -| **Total** | **948 KB** | | | - -### Why We Switched to ghostty-web - -| Benefit | Impact | -|---------|--------| -| **Production-tested VT100 parser** | Ghostty's parser handles edge cases correctly | -| **xterm.js API compatibility** | Easy migration, familiar API | -| **Full configuration control** | Theme, font, scrollback via CLI | -| **Mobile Safari support** | Hidden textarea triggers keyboard | -| **Modern features** | RGB colors, better cursor handling | - -### WebSocket Protocol (Fully Compatible) - -The protocol is simple JSON arrays. Our server already implements this: - -| Direction | Message | Description | -|-----------|---------|-------------| -| Client → Server | `["stdin", "data"]` | Terminal input | -| Client → Server | `["resize", {width: N, height: M}]` | Window resize | -| Client → Server | `["ping", data]` | Keep-alive | -| Server → Client | `["stdout", "data"]` | Terminal output (text) | -| Server → Client | Binary frame | Terminal output (binary) | -| Server → Client | `["pong", data]` | Keep-alive response | - -### Current Workarounds - -1. **Font override**: Canvas monkey-patch in HTML to replace hardcoded font family -2. **No scrollback**: Users cannot scroll back through terminal history - ---- - -## Tradeoffs Analysis - -### Option A: Keep textual-serve Dependency - -| Pros | Cons | -|------|------| -| Zero build tooling | Hardcoded font requires workaround | -| Automatic updates via pip | No scrollback (scrollback: 0) | -| Maintained by Textualize | No theme customization | -| | Carries unused fonts/images (381 KB) | -| | Tied to textual-serve release cycle | -| | Unknown xterm.js version (likely 5.x) | - -### Option B: Bundle xterm.js 6.0 Directly - -| Pros | Cons | -|------|------| -| Full configuration control | Requires Bun toolchain | -| Scrollback history support | ~150-200 KB bundle to maintain | -| Custom themes/colors | Must track xterm.js updates | -| Latest xterm.js 6.0 features | Initial setup effort (2-3 days) | -| Smaller bundle (no unused fonts) | | -| Can drop textual-serve dependency | | - -### xterm.js 6.0 Features We'd Gain - -| Feature | Benefit | -|---------|---------| -| Synchronized output (DEC 2026) | Smoother rapid output rendering | -| Ligature support | Better programming font rendering | -| Progress addon | Visual progress indicators | -| Shadow DOM support | Better CSS encapsulation | -| ESM support | Modern module loading | -| Performance improvements | Faster search, less memory | -| OSC 52 clipboard | Secure clipboard from terminal | - ---- - -## Implementation Plan (Completed) - -### Phase 1: Tooling Setup ✅ - -**Goal**: Establish Bun-based build pipeline - -``` -src/webterm/ -├── static/ -│ ├── js/ -│ │ └── terminal.ts # New: our xterm wrapper -│ ├── css/ -│ │ └── xterm.css # Copied from xterm.js package -│ └── monospace.css # Existing -├── package.json # New: npm dependencies -└── bunfig.toml # New: Bun configuration -``` - -**Tasks**: -- [x] Create `package.json` with xterm.js 6.0 dependencies -- [x] Create `bunfig.toml` for build configuration -- [x] Add `Makefile` targets: `make bundle`, `make bundle-watch` -- [x] Add `.gitignore` entries for `node_modules/` -- [x] Document Bun installation in README - -**package.json** (final): -```json -{ - "name": "webterm-frontend", - "private": true, - "type": "module", - "dependencies": { - "@xterm/xterm": "^6.0.0", - "@xterm/addon-fit": "^0.10.0", - "@xterm/addon-webgl": "^0.18.0", - "@xterm/addon-canvas": "^0.7.0", - "@xterm/addon-unicode11": "^0.8.0", - "@xterm/addon-web-links": "^0.11.0", - "@xterm/addon-clipboard": "^0.2.0" - }, - "devDependencies": { - "typescript": "^5.7.0" - }, - "scripts": { - "build": "bun build src/webterm/static/js/terminal.ts --outfile=src/webterm/static/js/terminal.js --minify --target=browser", - "watch": "bun build src/webterm/static/js/terminal.ts --outfile=src/webterm/static/js/terminal.js --watch --target=browser" - } -} -``` - -### Phase 2: Terminal Client Implementation ✅ - -**Goal**: Create `terminal.ts` that replicates textual.js functionality - -**Tasks**: -- [x] Implement Terminal wrapper class -- [x] WebSocket connection with reconnection logic -- [x] Message protocol handling (stdin, resize, ping/pong) -- [x] Addon initialization (fit, webgl, canvas, unicode11, web-links, clipboard) -- [x] Configurable options via data attributes or window config - -See `src/webterm/static/js/terminal.ts` for the full implementation (~230 lines). - -### Phase 3: Server Integration ✅ - -**Goal**: Update local_server.py to use new bundle - -**Tasks**: -- [x] Update HTML template to load our bundle instead of textual.js -- [x] Remove canvas monkey-patch workaround -- [x] Add data attributes for scrollback, theme configuration -- [x] Copy xterm.css to our static folder -- [x] Update static file routes - -### Phase 4: Configuration Support ✅ - -**Goal**: Make terminal appearance configurable - -**Tasks**: -- [x] Pass config to HTML template via data attributes (`data-scrollback`, `data-font-size`) -- [ ] Add terminal config to CLI (--scrollback, --font-family) - *Future enhancement* -- [ ] Add terminal config to TOML manifest files - *Future enhancement* - -### Phase 5: Remove textual-serve Dependency ✅ - -**Goal**: Eliminate dependency once our bundle is stable - -**Tasks**: -- [x] Remove `textual-serve` from pyproject.toml dependencies -- [x] Update ARCHITECTURE.md to document new frontend -- [x] Update README.md with build instructions -- [x] Commit pre-built bundle so users don't need Bun - -### Phase 6: Testing & Polish - -**Goal**: Ensure reliability across browsers - -**Tasks**: -- [ ] Cross-browser testing (Chrome, Firefox, Safari, Edge) -- [ ] Mobile browser testing (iOS Safari, Chrome Android) -- [x] WebGL fallback to Canvas (implemented in terminal.ts) -- [x] Reconnection logic (implemented with exponential backoff) -- [ ] Performance comparison vs textual.js -- [x] Bundle size: 560 KB (acceptable for full xterm.js + addons) - ---- - -## Build Integration (Reference) - -### Makefile Additions - -```makefile -# Frontend build -.PHONY: bundle bundle-watch bundle-clean - -bundle: node_modules - bun run build - -bundle-watch: node_modules - bun run watch - -bundle-clean: - rm -rf node_modules src/webterm/static/js/terminal.js - -node_modules: package.json - bun install -``` - -### Dockerfile Changes - -```dockerfile -# Add Bun for frontend build -RUN curl -fsSL https://bun.sh/install | bash -ENV PATH="/root/.bun/bin:${PATH}" - -# Build frontend -COPY package.json bunfig.toml ./ -RUN bun install -COPY src/webterm/static/js/terminal.ts src/webterm/static/js/ -RUN bun run build -``` - -### CI/CD Considerations - -- Pre-commit hook to verify `terminal.js` matches `terminal.ts` -- Or: commit built bundle to repo (simpler for users without Bun) -- GitHub Actions step to build and verify bundle - ---- - -## Risk Mitigation - -| Risk | Mitigation | -|------|------------| -| xterm.js 6.0 breaking changes | Pin exact version, test thoroughly | -| Bun compatibility issues | Fall back to esbuild if needed | -| WebSocket protocol mismatch | Keep protocol identical to textual.js | -| Performance regression | Benchmark before/after, keep WebGL | -| Missing addon features | Test each addon explicitly | - ---- - -## Timeline Estimate - -| Phase | Effort | Dependencies | -|-------|--------|--------------| -| Phase 1: Tooling Setup | 0.5 days | None | -| Phase 2: Terminal Client | 1-2 days | Phase 1 | -| Phase 3: Server Integration | 0.5 days | Phase 2 | -| Phase 4: Configuration | 0.5 days | Phase 3 | -| Phase 5: Remove Dependency | 0.5 days | Phase 4 | -| Phase 6: Testing | 1 day | Phase 5 | -| **Total** | **4-5 days** | | - ---- - -## Decision Checkpoints - -1. **After Phase 2**: Verify terminal.ts works in isolation before integrating -2. **After Phase 3**: Side-by-side comparison with textual.js -3. **After Phase 5**: Confirm no regressions before removing dependency -4. **After Phase 6**: Final sign-off for release - ---- - -## Success Criteria - -- [ ] Terminal renders correctly in Chrome, Firefox, Safari -- [x] Scrollback history works (configurable limit) -- [x] Custom fonts load without workarounds -- [x] WebGL rendering enabled with Canvas fallback -- [x] Bundle size: 560 KB (larger than target due to full addon suite, but acceptable) -- [x] No textual-serve dependency in pyproject.toml -- [x] All existing tests pass (302 tests) -- [x] Documentation updated - ---- ---- - -# Future: Go Reimplementation - -This section analyzes what it would take to reimplement webterm in Go for lighter deployment. - -## Status: 📋 Planning - -Not yet started. This would be a separate project (`webterm-go`) providing a lightweight alternative. - -## Executive Summary - -**Most functionality can be reimplemented in Go** with mature libraries. The main challenge is the terminal emulator (pyte equivalent) - GoPyte exists but is less battle-tested than Python's pyte. Benefits would be a single static binary, lower memory footprint, and better concurrency. - ---- - -## Component Mapping - -| Python Component | Go Equivalent | Library | Maturity | -|-----------------|---------------|---------|----------| -| **aiohttp** (HTTP/WS server) | net/http + websocket | `gorilla/websocket` or `nhooyr.io/websocket` | ⭐⭐⭐⭐⭐ Excellent | -| **pyte** (terminal emulator) | GoPyte | `github.com/scottpeterman/gopyte` | ⭐⭐⭐ Good | -| **PTY handling** | go-pty | `github.com/aymanbagabas/go-pty` | ⭐⭐⭐⭐ Very Good | -| **asyncio** (concurrency) | goroutines/channels | stdlib | ⭐⭐⭐⭐⭐ Native | -| **SSE** | Custom handler | stdlib `net/http` | ⭐⭐⭐⭐ Simple | -| **Docker stats** | Docker SDK | `github.com/docker/docker/client` | ⭐⭐⭐⭐⭐ Official | -| **SVG generation** | SVGo | `github.com/ajstarks/svgo` | ⭐⭐⭐⭐⭐ Mature | -| **YAML parsing** | yaml.v3 | `gopkg.in/yaml.v3` | ⭐⭐⭐⭐⭐ Standard | -| **CLI** | cobra | `github.com/spf13/cobra` | ⭐⭐⭐⭐⭐ Standard | - ---- - -## pyte vs GoPyte: Thorough Comparison - -This section compares the Python **pyte** terminal emulator and the Go **GoPyte** emulator (per their upstream documentation/README). The focus is on capture accuracy, Unicode handling, performance expectations, and integration risk for webterm. Where GoPyte details are unclear, we call them out explicitly as validation items. - -### Feature Matrix (Capture-Relevant) - -| Capability | pyte (Python) | GoPyte (Go) | Capture Impact / Gaps | -|-----------|---------------|-------------|------------------------| -| **Terminal standards** | VTXXX/ANSI (VT100-style) | VT100/VT220/ANSI (claims) | Verify DEC private modes, OSC handling. | -| **Screen buffer** | Screen + HistoryScreen (cells with attrs) | Screen buffer (claims) | Data model differences may affect attrs. | -| **Alt screen** | Supported | Supported (claims) | Full-screen apps depend on correct switching. | -| **Scrollback/history** | HistoryScreen (configurable) | Built-in scrollback (claims) | Semantics may differ; check memory cost. | -| **Resize behavior** | Resizes + cursor + dirty state | Resizes + content (claims) | Must preserve content on resize. | -| **SGR attributes** | Bold/underline/reverse/color | SGR attrs (claims) | Verify italics, faint, strikethrough, blink. | -| **Underline styles** | Basic underline | Unknown | Double/curly underline may be missing. | -| **Color depth** | ANSI + 256 + (truecolor in practice) | ANSI + 256? (verify) | Truecolor required for accurate screenshots. | -| **Default colors** | SGR 39/49 respected | Unknown | Default fg/bg must be stable. | -| **Reverse video** | Supported | Unknown | Affects screenshot accuracy. | -| **DEC special graphics** | Supported (line drawing) | Unknown | Box-drawing is critical for TUIs. | -| **Character sets (G0/G1)** | Supported | Unknown | Needed for legacy line-drawing. | -| **Scroll regions** | Supported | Unknown | Needed for curses-style UI. | -| **Insert/delete (IL/DL/ICH/DCH)** | Supported | Unknown | Affects screen fidelity during updates. | -| **Tabs / tab stops** | Supported | Unknown | UI alignment depends on tabs. | -| **Autowrap/origin modes** | Supported | Unknown | Impacts cursor positioning and layout. | -| **Cursor save/restore** | Supported | Unknown | Common in TUIs and prompts. | -| **Cursor visibility/style** | Basic visibility | Unknown | Useful for accurate snapshots. | -| **Erase semantics (ED/EL/ECH)** | Supported | Unknown | Correct clearing is vital for screenshots. | -| **Selective erase (DECSEL/DECSERA)** | Unknown | Unknown | Might matter for some TUIs. | -| **Unicode width** | wcwidth-style width | go-runewidth | Width differences can misalign SVG. | -| **Combining marks / ZWJ** | Unicode-aware | runewidth-based | Emoji sequences may differ in width. | -| **Dirty tracking** | Exposes dirty set / DiffScreen | Unknown | Needed for efficient screenshot caching. | -| **Images (sixel/kitty)** | Not supported | Not supported | Not required, but a capture limitation. | -| **Performance** | Python, moderate | Go, claims high throughput | Benchmark under heavy output. | -| **API stability** | Mature, widely used | Newer, smaller ecosystem | Risk of breaking changes. | -| **Test maturity** | Established | Claims high coverage | Must run our own parity suite. | - -### Unicode & Emoji Handling (Critical for SVG Capture) - -**pyte** -- Uses Python Unicode handling plus width calculation (wcwidth-style). -- Generally robust for CJK and emoji, though edge cases exist (ZWJ, variation selectors). - -**GoPyte** -- Uses `go-runewidth` for width calculation. -- Width differences vs wcwidth can shift glyph placement in SVG output. - -**Capture Impact** -- Width mismatches change x-coordinates -> visually incorrect screenshots. -- Emoji sequences (ZWJ, VS16) may render as 1 cell in one emulator and 2 in another. -- Must validate CJK + emoji + combining marks across sample workloads. - -### Performance & Memory (Capture-Heavy Workloads) - -**pyte** -- Pure Python; adequate for typical workloads. -- Predictable, but slower under heavy output. - -**GoPyte** -- Go implementation; upstream claims high throughput. -- Performance depends on allocation strategy and screen model. - -**Action**: Benchmark with fast-output scenarios, large scrollback, and frequent screenshots. - -### Required Features for webterm Capture - -We depend on the emulator for **accurate snapshot state**, not just live display: - -- Stable **screen buffer** with per-cell fg/bg/bold/underline/reverse. -- Correct **cursor position** after control sequences and resizes. -- Accurate **alt screen** behavior for full-screen TUIs. -- Consistent **Unicode width** for precise SVG positioning. -- **Dirty tracking or diffability** to avoid re-rendering unchanged screens. -- Reliable **color mapping** (ANSI 16 + 256 + truecolor). -- Correct **DEC line drawing** (box-drawing characters). -- Correct **erase semantics** (ED/EL/ECH) to avoid stale cells. - -If any of these are missing, screenshots will be wrong or expensive to compute. - -### Capture-Enhancing (Nice-to-Have) Features - -These aren’t required for parity, but would improve capture quality or UX: - -- **OSC 8 hyperlink metadata** to annotate clickable URLs in SVG output. -- **OSC 52 clipboard** metadata for copy workflows. -- **Underline styles** (double/curly) for richer text attributes. -- **Cursor style/shape** metadata for accurate snapshots. -- **Grapheme cluster awareness** (emoji sequences treated as single cells). -- **Structured diffs** from emulator (explicit dirty regions instead of full screen). - -### Known Gaps / Validation Checklist - -Before relying on GoPyte for parity, verify: - -- [ ] Full-screen app behavior (vim, htop, less) with alt screen. -- [ ] DEC line drawing / special graphics set (box-drawing correctness). -- [ ] Character set switching (G0/G1) for legacy graphics. -- [ ] Scroll regions (DECSTBM) and origin mode behavior. -- [ ] Insert/delete line/char (IL/DL/ICH/DCH) correctness. -- [ ] Tab stops and tab clear (alignment in TUIs). -- [ ] SGR coverage: bold/underline/italic/reverse + 256/truecolor. -- [ ] Underline styles, faint, strikethrough, blink. -- [ ] Default colors (SGR 39/49) and reverse video. -- [ ] Unicode width parity with pyte (emoji + CJK samples). -- [ ] Cursor save/restore (DECSC/DECRC) and visibility toggles. -- [ ] Erase semantics (ED/EL/ECH) and autowrap correctness. -- [ ] Resize correctness (content preservation, cursor placement). -- [ ] Performance at high output rates (100k+ lines, low latency). - -### Integration Implications for webterm - -- **Screen buffer mapping**: GoPyte cells must map to our SVG exporter schema (fg/bg/bold/underline/reverse). -- **Dirty tracking**: pyte exposes dirty state; GoPyte may need explicit diff tracking. -- **Color translation**: Ensure SGR parsing aligns with our ANSI palette and truecolor handling. -- **Replay buffer**: Replay must stay consistent with screen state for accurate screenshots. - -### Alternatives (Brief) - -If GoPyte does not meet parity, consider: - -- **govte** (`github.com/cliofy/govte`): More comprehensive xterm-like parser/buffer, less documented. -- **vito/vt100**: Lightweight VT100 emulator, limited scrollback/alt-screen. -- **xyproto/vt100**: TUI canvas focus, not a drop-in emulator. - -These alternatives still need capture parity validation. - - -## What We'd Gain - -| Benefit | Impact | -|---------|--------| -| **Single static binary** | No Python/pip dependency, simpler deployment | -| **Lower memory** | ~10-20MB vs ~50-100MB for Python | -| **Better concurrency** | Goroutines vs asyncio - more intuitive | -| **Faster startup** | Instant vs Python interpreter load | -| **Cross-compilation** | Easy builds for Linux/macOS/Windows/ARM | -| **Smaller Docker image** | ~20MB vs ~200MB+ with Python | - -## What We'd Lose - -| Loss | Impact | -|------|--------| -| **Textual app support** | Removed in webterm | -| **Rapid prototyping** | Go requires more boilerplate | -| **pyte maturity** | GoPyte is less proven | - ---- - -## Required Go Dependencies - -```go -// go.mod -module github.com/rcarmo/webterm-go - -go 1.22 - -require ( - // HTTP/WebSocket - github.com/gorilla/websocket v1.5.1 - - // Terminal emulation - github.com/scottpeterman/gopyte v0.1.0 - - // PTY handling - github.com/aymanbagabas/go-pty v0.2.2 - - // Docker stats - github.com/docker/docker v25.0.0 - - // SVG generation - github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b - - // CLI - github.com/spf13/cobra v1.8.0 - - // YAML parsing - gopkg.in/yaml.v3 v3.0.1 -) -``` - ---- - -## Implementation Plan - -### Phase 1: Project Setup & Core Server (2 days) - -**Goal**: Basic HTTP server with WebSocket support - -**Tasks**: -- [ ] Initialize Go module with dependencies -- [ ] Create basic HTTP server with routing -- [ ] Implement WebSocket upgrade handler -- [ ] Port JSON message protocol (stdin, resize, ping/pong) -- [ ] Add graceful shutdown handling - -**Files**: -``` -cmd/ - webterm/ - main.go # Entry point -internal/ - server/ - server.go # HTTP server - websocket.go # WebSocket handler - routes.go # Route definitions -``` - -### Phase 2: PTY Session Management (2 days) - -**Goal**: Spawn and manage terminal sessions - -**Tasks**: -- [ ] Integrate go-pty for PTY creation -- [ ] Implement session lifecycle (create, resize, close) -- [ ] Build session manager with route mapping -- [ ] Add replay buffer for reconnection -- [ ] Handle concurrent session access - -**Files**: -``` -internal/ - session/ - manager.go # Session registry - terminal.go # PTY session wrapper - buffer.go # Replay ring buffer -``` - -### Phase 3: Terminal Emulation (2 days) - -**Goal**: Parse ANSI sequences for screen state - -**Tasks**: -- [ ] Integrate GoPyte terminal emulator -- [ ] Feed PTY output through emulator -- [ ] Extract screen buffer for screenshots -- [ ] Implement dirty tracking for cache invalidation -- [ ] Handle resize events - -**Files**: -``` -internal/ - terminal/ - emulator.go # GoPyte wrapper - screen.go # Screen buffer access -``` - -### Phase 4: SVG Screenshot Generation (1.5 days) - -**Goal**: Generate terminal screenshots as SVG - -**Tasks**: -- [ ] Port character positioning logic from Python -- [ ] Implement ANSI color palette (16 + 256 + truecolor) -- [ ] Handle box-drawing character scaling -- [ ] Add screenshot caching with ETag support -- [ ] Implement cache TTL backoff - -**Files**: -``` -internal/ - screenshot/ - svg.go # SVG renderer - colors.go # ANSI color handling - cache.go # Screenshot cache -``` - -### Phase 5: Dashboard & SSE (1.5 days) - -**Goal**: Landing page with live updates - -**Tasks**: -- [ ] Embed static assets (HTML, CSS, xterm.js bundle) -- [ ] Implement SSE endpoint for activity notifications -- [ ] Port dashboard HTML template -- [ ] Add tile rendering with screenshots - -**Files**: -``` -internal/ - dashboard/ - handler.go # Dashboard HTTP handler - sse.go # Server-Sent Events - static/ - embed.go # Embedded assets -``` - -### Phase 6: Docker Stats (1 day) - -**Goal**: CPU sparklines for compose mode - -**Tasks**: -- [ ] Integrate Docker SDK client -- [ ] Implement container stats polling -- [ ] Calculate CPU percentage from deltas -- [ ] Generate sparkline SVGs -- [ ] Filter by compose project label - -**Files**: -``` -internal/ - docker/ - stats.go # Stats collector - sparkline.go # SVG sparkline -``` - -### Phase 7: CLI & Configuration (1 day) - -**Goal**: Feature-complete CLI - -**Tasks**: -- [ ] Implement Cobra CLI with flags -- [ ] Parse YAML landing manifests -- [ ] Parse Docker Compose manifests -- [ ] Add version command -- [ ] Environment variable support - -**Files**: -``` -cmd/ - webterm/ - main.go # CLI entry point -internal/ - config/ - config.go # Configuration types - manifest.go # YAML parsing -``` - -### Phase 8: Testing & Polish (2 days) - -**Goal**: Production-ready release - -**Tasks**: -- [ ] Unit tests for core components -- [ ] Integration tests for WebSocket protocol -- [ ] Cross-browser testing -- [ ] Build scripts for multiple platforms -- [ ] Docker image (FROM scratch) -- [ ] Documentation - -**Files**: -``` -Makefile # Build targets -Dockerfile # Multi-stage build -README.md # Usage docs -``` - ---- - -## Effort Summary - -| Phase | Component | Days | -|-------|-----------|------| -| 1 | Project Setup & Core Server | 2 | -| 2 | PTY Session Management | 2 | -| 3 | Terminal Emulation | 2 | -| 4 | SVG Screenshot Generation | 1.5 | -| 5 | Dashboard & SSE | 1.5 | -| 6 | Docker Stats | 1 | -| 7 | CLI & Configuration | 1 | -| 8 | Testing & Polish | 2 | -| **Total** | | **13 days** | - ---- - -## File Structure (Final) - -``` -webterm-go/ -├── cmd/ -│ └── webterm/ -│ └── main.go -├── internal/ -│ ├── config/ -│ │ ├── config.go -│ │ └── manifest.go -│ ├── dashboard/ -│ │ ├── handler.go -│ │ └── sse.go -│ ├── docker/ -│ │ ├── sparkline.go -│ │ └── stats.go -│ ├── screenshot/ -│ │ ├── cache.go -│ │ ├── colors.go -│ │ └── svg.go -│ ├── server/ -│ │ ├── routes.go -│ │ ├── server.go -│ │ └── websocket.go -│ ├── session/ -│ │ ├── buffer.go -│ │ ├── manager.go -│ │ └── terminal.go -│ ├── static/ -│ │ └── embed.go -│ └── terminal/ -│ ├── emulator.go -│ └── screen.go -├── static/ -│ ├── css/ -│ │ └── xterm.css -│ └── js/ -│ └── terminal.js -├── go.mod -├── go.sum -├── Makefile -├── Dockerfile -└── README.md -``` - ---- - -## Build & Release - -### Makefile Targets - -```makefile -.PHONY: build build-all test clean - -BINARY := webterm -VERSION := $(shell git describe --tags --always) -LDFLAGS := -s -w -X main.version=$(VERSION) - -build: - go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/webterm - -build-all: - GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY)-linux-amd64 ./cmd/webterm - GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY)-linux-arm64 ./cmd/webterm - GOOS=darwin GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY)-darwin-amd64 ./cmd/webterm - GOOS=darwin GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY)-darwin-arm64 ./cmd/webterm - -test: - go test -v ./... - -clean: - rm -rf bin/ -``` - -### Minimal Docker Image - -```dockerfile -FROM golang:1.22-alpine AS builder -WORKDIR /app -COPY go.mod go.sum ./ -RUN go mod download -COPY . . -RUN CGO_ENABLED=0 go build -ldflags "-s -w" -o webterm ./cmd/webterm - -FROM scratch -COPY --from=builder /app/webterm /webterm -ENTRYPOINT ["/webterm"] -``` - -**Result**: ~15-20MB Docker image vs ~200MB+ for Python version. - ---- - -## Decision Criteria - -Proceed with Go reimplementation if: - -- [ ] Deployment size is critical (embedded, edge, IoT) -- [x] No need for Textual app support -- [ ] Want single-binary distribution -- [ ] Memory constraints matter - -Keep Python version if: - -- [ ] Need Textual app support -- [ ] Rapid iteration is priority -- [ ] Team more familiar with Python -- [ ] Current deployment size is acceptable - ---- - -## References - -- GoPyte: https://github.com/scottpeterman/gopyte -- go-pty: https://github.com/aymanbagabas/go-pty -- Gorilla WebSocket: https://github.com/gorilla/websocket -- Docker Go SDK: https://pkg.go.dev/github.com/docker/docker/client -- SVGo: https://github.com/ajstarks/svgo -- Cobra CLI: https://github.com/spf13/cobra diff --git a/docs/pyte-patches.md b/docs/pyte-patches.md deleted file mode 100644 index 8d71719..0000000 --- a/docs/pyte-patches.md +++ /dev/null @@ -1,91 +0,0 @@ -# pyte Patches - -webterm uses [pyte](https://github.com/selectel/pyte) (v0.8.2) as a headless terminal emulator to capture screen state for SVG screenshots. pyte doesn't implement several standard escape sequences, so `AltScreen` in `alt_screen.py` monkeypatches pyte at import time and provides additional methods. - -## Patch 1: CSI S / CSI T — Scroll Up & Down (SU/SD) - -**Status:** Primary fix for ghost content in screenshots. - -### Problem - -When `TERM=xterm-256color`, tmux uses `CSI n S` (Scroll Up) to shift content upward — for example, when Ink-based CLI apps (GitHub Copilot CLI) issue `/clear`. pyte does not implement CSI S or CSI T, silently ignoring the sequences. Old content remains in the screen buffer, appearing as ghost lines in SVG screenshots. - -Real terminals (Ghostty, iTerm2, etc.) handle CSI S/T correctly, so the issue only manifests in pyte-rendered screenshots. - -### Root cause - -pyte's CSI dispatch table has no entry for `"S"` (SU — Scroll Up) or `"T"` (SD — Scroll Down). The `xterm-256color` terminfo entry includes `indn=\E[%p1%dS` and `rin=\E[%p1%dT`, so tmux sends these when the outer terminal advertises support. With simpler TERM values (e.g. `xterm-color`), tmux falls back to DECSTBM + index, which pyte handles correctly. - -### Fix - -At module load time, `alt_screen.py` patches pyte's dispatch tables and event set: - -```python -pyte.ByteStream.csi["S"] = "scroll_up" -pyte.ByteStream.csi["T"] = "scroll_down" -pyte.Stream.csi["S"] = "scroll_up" -pyte.Stream.csi["T"] = "scroll_down" -pyte.Stream.events = pyte.Stream.events | frozenset(["scroll_up", "scroll_down"]) -``` - -`AltScreen` implements `scroll_up(count)` and `scroll_down(count)`, which shift buffer lines within the current scroll region (respecting DECSTBM margins) and blank the vacated rows. - -### Verification - -```python -from webterm.alt_screen import AltScreen -import pyte - -screen = AltScreen(80, 24) -stream = pyte.ByteStream(screen) - -# Fill screen with content -for i in range(24): - stream.feed(f"Line {i}\r\n".encode()) - -# CSI 6 S — scroll up 6 lines (top 6 lines lost, bottom 6 blanked) -stream.feed(b"\x1b[6S") - -# Lines 6-23 shifted to rows 0-17, rows 18-23 are blank -assert screen.display[0].strip() == "Line 6" -assert screen.display[17].strip() == "Line 23" -assert screen.display[18].strip() == "" -``` - -## Patch 2: Alternate Screen Buffer (DECSET/DECRST 1049) - -**Status:** Core feature, implemented since v1.0. - -### Problem - -pyte doesn't implement private mode 1049 (or 47/1047/1048) for alternate screen buffers. Full-screen programs (tmux, vim, less) switch to an alternate buffer on entry and restore the main buffer on exit. Without this, exiting vim inside tmux would leave vim's screen content overlaid on the shell prompt in screenshots. - -### Fix - -`AltScreen` overrides `set_mode()` and `reset_mode()` to intercept private modes 47/1047/1048/1049. On entry, it deep-copies the current buffer and cursor; on exit, it restores them. The saved buffer is invalidated on resize. - -## Patch 3: Ink Partial Clear Expansion (best-effort) - -**Status:** Secondary defense; largely superseded by Patch 1. - -### Problem - -Ink-based CLI frameworks erase their previous output using repeated `EL2 + CUU1` (erase line + cursor up) pairs. When `/clear` resets Ink's internal line counter, the next frame erases fewer lines than needed. In a real terminal the old content has scrolled into the scrollback buffer, but pyte's fixed-size screen retains it. - -### Fix - -`AltScreen.expand_clear_sequences(data)` pre-processes incoming bytes before feeding to pyte. It detects runs of 3+ `EL2+CUU1` pairs that don't reach row 0 and extends them to cover all lines above the cursor. - -Both `TerminalSession._update_screen()` and `DockerExecSession._update_screen()` call this method after C1 normalization and before `stream.feed()`. - -### Why this is safe - -- Only triggers on runs of **3 or more** `EL2+CUU1` pairs (normal editing uses 1–2). -- Only adds erase operations for lines that would already be empty in a real terminal. -- Short runs (< 3 pairs) and runs that already reach row 0 are left unchanged. - -## Related - -- `docs/tmux-da-response-filtering.md` — filtering Device Attributes responses from tmux. -- `docs/ROADMAP.md` — future Go reimplementation would replace pyte entirely. -- `WEBTERM_SCREENSHOT_FORCE_REDRAW` env var — sends SIGWINCH to force app redraw before screenshots. diff --git a/docs/tmux-da-response-filtering.md b/docs/tmux-da-response-filtering.md deleted file mode 100644 index 42021a9..0000000 --- a/docs/tmux-da-response-filtering.md +++ /dev/null @@ -1,51 +0,0 @@ -# Fix for "1;10;0c" Display Issue in tmux - -## Problem -When entering or refreshing a webterm session running tmux, the text "1;10;0c" -appears on the screen. This is a terminal Device Attributes (DA) response that -should be filtered out. - -## Root Cause -1. **Tmux sends a DA2 query** (`ESC[>c`) when it initializes to detect terminal capabilities -2. The terminal responds with `ESC[>1;10;0c` (DA2 response format) -3. The filtering code only handled DA1 responses (`ESC[?...c`) but **not DA2** (`ESC[>...c`) -4. The unfiltered DA2 response appeared as visible text "1;10;0c" in the replay buffer - -## Solution -Updated the DA response filtering patterns in three files to handle all DA variants: - -- **DA1** (Primary): `ESC[?...c` - already handled ✓ -- **DA2** (Secondary): `ESC[>...c` - **NOW FIXED** ✓ -- **DA3** (Tertiary): `ESC[=...c` - added for completeness ✓ - -### Changed Files -1. `src/webterm/terminal_session.py` -2. `src/webterm/docker_exec_session.py` -3. `src/webterm/local_server.py` - -### Pattern Changes -**Before:** -```python -DA_RESPONSE_PATTERN = re.compile(rb"\x1b\[\?[\d;]+c") -DA_PARTIAL_PATTERN = re.compile(rb"\x1b(?:\[(?:\?[\d;]*)?)?$") -``` - -**After:** -```python -DA_RESPONSE_PATTERN = re.compile(rb"\x1b\[[?>=][\d;]*c") -DA_PARTIAL_PATTERN = re.compile(rb"\x1b(?:\[(?:[?>=][\d;]*)?)?$") -``` - -## Testing -- All 342 existing tests pass ✓ -- Verified the pattern matches DA1, DA2, and DA3 responses ✓ -- Confirmed partial buffering works for all variants ✓ - -## Technical Details -Device Attributes queries/responses: -- **DA1**: `ESC[c` → `ESC[?1;10;0c` (terminal capabilities) -- **DA2**: `ESC[>c` → `ESC[>1;10;0c` (terminal version - **sent by tmux**) -- **DA3**: `ESC[=c` → `ESC[=1;0c` (rarely used) - -The fix ensures all three variants are filtered from both live output and -replay buffers when clients reconnect. diff --git a/go/webterm/docker_exec_session.go b/go/webterm/docker_exec_session.go index 45abc51..44df49c 100644 --- a/go/webterm/docker_exec_session.go +++ b/go/webterm/docker_exec_session.go @@ -147,14 +147,7 @@ func (s *DockerExecSession) handleOutput(data []byte) { tracker := s.tracker connector := s.connector s.mu.Unlock() - if len(filtered) == 0 { - return - } - s.replay.Add(filtered) - if tracker != nil { - _ = tracker.Feed(filtered) - } - connector.OnData(filtered) + dispatchSessionOutput(filtered, tracker, s.replay, connector) } func (s *DockerExecSession) createExec() (string, error) { @@ -317,14 +310,7 @@ func (s *DockerExecSession) GetScreenSnapshot() terminalstate.Snapshot { tracker := s.tracker width, height := s.width, s.height s.mu.RUnlock() - if tracker == nil { - return terminalstate.Snapshot{ - Width: width, - Height: height, - Buffer: make([][]terminalstate.Cell, height), - } - } - return tracker.Snapshot() + return snapshotFromTracker(tracker, width, height) } func (s *DockerExecSession) UpdateConnector(connector SessionConnector) { diff --git a/go/webterm/docker_stats.go b/go/webterm/docker_stats.go index cc1508f..763ccc8 100644 --- a/go/webterm/docker_stats.go +++ b/go/webterm/docker_stats.go @@ -67,6 +67,8 @@ func (d *DockerStatsCollector) Start(serviceNames []string) { d.mu.Unlock() return } + d.stopCh = make(chan struct{}) + d.doneCh = make(chan struct{}) d.serviceList = append([]string{}, serviceNames...) d.running = true d.mu.Unlock() diff --git a/go/webterm/docker_stats_test.go b/go/webterm/docker_stats_test.go index b59ece5..50e53f6 100644 --- a/go/webterm/docker_stats_test.go +++ b/go/webterm/docker_stats_test.go @@ -93,3 +93,11 @@ func TestDockerStatsHelperConversions(t *testing.T) { t.Fatalf("max mismatch: %d", got) } } + +func TestDockerStatsCollectorCanRestart(t *testing.T) { + collector := NewDockerStatsCollector("/tmp/does-not-exist.sock", "") + collector.Start([]string{"svc"}) + collector.Stop() + collector.Start([]string{"svc"}) + collector.Stop() +} diff --git a/go/webterm/docker_watcher.go b/go/webterm/docker_watcher.go index f097a67..af8b308 100644 --- a/go/webterm/docker_watcher.go +++ b/go/webterm/docker_watcher.go @@ -199,8 +199,8 @@ func (w *DockerWatcher) handleEvent(event map[string]any) { } } -func (w *DockerWatcher) watchEvents(ctx context.Context) { - defer close(w.waitDone) +func (w *DockerWatcher) watchEvents(ctx context.Context, waitDone chan struct{}) { + defer close(waitDone) filters := url.QueryEscape(`{"event":["start","die"],"type":["container"]}`) requestURL := "http://unix/events?filters=" + filters for { @@ -252,12 +252,14 @@ func (w *DockerWatcher) Start() { w.mu.Unlock() return } + waitDone := make(chan struct{}) ctx, cancel := context.WithCancel(context.Background()) w.cancel = cancel + w.waitDone = waitDone w.running = true w.mu.Unlock() w.ScanExisting() - go w.watchEvents(ctx) + go w.watchEvents(ctx, waitDone) } func (w *DockerWatcher) Stop() { @@ -268,9 +270,10 @@ func (w *DockerWatcher) Stop() { } w.running = false cancel := w.cancel + waitDone := w.waitDone w.mu.Unlock() if cancel != nil { cancel() } - <-w.waitDone + <-waitDone } diff --git a/go/webterm/docker_watcher_test.go b/go/webterm/docker_watcher_test.go index f8f35a6..c520f34 100644 --- a/go/webterm/docker_watcher_test.go +++ b/go/webterm/docker_watcher_test.go @@ -21,3 +21,11 @@ func TestDockerWatcherCommandAndThemeParsing(t *testing.T) { t.Fatalf("unexpected slug: %q", slug) } } + +func TestDockerWatcherCanRestart(t *testing.T) { + watcher := NewDockerWatcher(NewSessionManager(nil), "/tmp/does-not-exist.sock", nil, nil) + watcher.Start() + watcher.Stop() + watcher.Start() + watcher.Stop() +} diff --git a/go/webterm/server.go b/go/webterm/server.go index 89cb276..f0edda7 100644 --- a/go/webterm/server.go +++ b/go/webterm/server.go @@ -48,11 +48,16 @@ type screenshotCacheEntry struct { type wsClient struct { routeKey string conn *websocket.Conn - send chan []byte + send chan wsOutbound done chan struct{} closed atomic.Bool } +type wsOutbound struct { + messageType int + payload []byte +} + type LocalServer struct { host string port int @@ -89,12 +94,12 @@ type localClientConnector struct { func (c *localClientConnector) OnData(data []byte) { c.server.markRouteActivity(c.routeKey) - c.server.enqueueWSData(c.routeKey, data) + c.server.enqueueWSFrame(c.routeKey, websocket.BinaryMessage, data) } func (c *localClientConnector) OnBinary(payload []byte) { c.server.markRouteActivity(c.routeKey) - c.server.enqueueWSData(c.routeKey, payload) + c.server.enqueueWSFrame(c.routeKey, websocket.BinaryMessage, payload) } func (c *localClientConnector) OnMeta(_ map[string]any) {} @@ -162,9 +167,11 @@ func findStaticPath() string { } } candidates := []string{ - filepath.Join(".", "src", "webterm", "static"), - filepath.Join("..", "src", "webterm", "static"), - filepath.Join("..", "..", "src", "webterm", "static"), + filepath.Join(".", "webterm", "static"), + filepath.Join(".", "go", "webterm", "static"), + filepath.Join("..", "webterm", "static"), + filepath.Join("..", "go", "webterm", "static"), + filepath.Join("..", "..", "webterm", "static"), } for _, candidate := range candidates { if stat, err := os.Stat(candidate); err == nil && stat.IsDir() { @@ -179,28 +186,37 @@ func (s *LocalServer) markRouteActivity(routeKey string) { s.mu.Lock() s.routeLastActivity[routeKey] = now last := s.routeLastSSE[routeKey] - if now.Sub(last) >= 250*time.Millisecond { - s.routeLastSSE[routeKey] = now - for subscriber := range s.sseSubscribers { - select { - case subscriber <- routeKey: - default: - } - } + if now.Sub(last) < 250*time.Millisecond { + s.mu.Unlock() + return + } + s.routeLastSSE[routeKey] = now + subscribers := make([]chan string, 0, len(s.sseSubscribers)) + for subscriber := range s.sseSubscribers { + subscribers = append(subscribers, subscriber) } s.mu.Unlock() + for _, subscriber := range subscribers { + select { + case subscriber <- routeKey: + default: + } + } } -func (s *LocalServer) enqueueWSData(routeKey string, data []byte) { +func (s *LocalServer) enqueueWSFrame(routeKey string, messageType int, data []byte) { s.mu.RLock() client := s.wsClients[routeKey] s.mu.RUnlock() if client == nil || client.closed.Load() { return } - payload := append([]byte{}, data...) + frame := wsOutbound{ + messageType: messageType, + payload: append([]byte{}, data...), + } select { - case client.send <- payload: + case client.send <- frame: default: // Drop oldest, try again select { @@ -208,7 +224,7 @@ func (s *LocalServer) enqueueWSData(routeKey string, data []byte) { default: } select { - case client.send <- payload: + case client.send <- frame: default: } } @@ -229,14 +245,9 @@ func (s *LocalServer) stopWSClient(routeKey string) { func (s *LocalServer) wsSender(client *wsClient) { defer close(client.done) - for payload := range client.send { + for outbound := range client.send { _ = client.conn.SetWriteDeadline(time.Now().Add(wsSendTimeout)) - // Detect JSON messages (start with '[') vs binary terminal data - msgType := websocket.BinaryMessage - if len(payload) > 0 && payload[0] == '[' { - msgType = websocket.TextMessage - } - if err := client.conn.WriteMessage(msgType, payload); err != nil { + if err := client.conn.WriteMessage(outbound.messageType, outbound.payload); err != nil { return } } @@ -304,7 +315,7 @@ func (s *LocalServer) handleWebSocket(w http.ResponseWriter, r *http.Request) { client := &wsClient{ routeKey: routeKey, conn: conn, - send: make(chan []byte, wsSendQueueMax), + send: make(chan wsOutbound, wsSendQueueMax), done: make(chan struct{}), } s.mu.Lock() @@ -319,8 +330,12 @@ func (s *LocalServer) handleWebSocket(w http.ResponseWriter, r *http.Request) { if err != nil || client.closed.Load() { return } + frame := wsOutbound{ + messageType: websocket.TextMessage, + payload: data, + } select { - case client.send <- data: + case client.send <- frame: default: } } @@ -333,7 +348,7 @@ func (s *LocalServer) handleWebSocket(w http.ResponseWriter, r *http.Request) { sessionCreated = true replay := daResponsePattern.ReplaceAll(session.GetReplayBuffer(), nil) if len(replay) > 0 { - s.enqueueWSData(routeKey, replay) + s.enqueueWSFrame(routeKey, websocket.BinaryMessage, replay) } } else { s.sessionManager.OnSessionEnd(sessionID) @@ -445,9 +460,18 @@ func (s *LocalServer) handleScreenshot(w http.ResponseWriter, r *http.Request) { if !ok && routeKey != "" { if _, exists := s.sessionManager.AppBySlug(routeKey); exists { _ = s.createTerminalSession(routeKey, DefaultTerminalWidth, DefaultTerminalHeight) - time.Sleep(500 * time.Millisecond) - session = s.sessionManager.GetSessionByRouteKey(routeKey) - ok = session != nil + deadline := time.Now().Add(500 * time.Millisecond) + for { + session = s.sessionManager.GetSessionByRouteKey(routeKey) + if session != nil { + ok = true + break + } + if time.Now().After(deadline) { + break + } + time.Sleep(20 * time.Millisecond) + } } } if !ok || session == nil { @@ -555,7 +579,6 @@ func (s *LocalServer) handleEvents(w http.ResponseWriter, r *http.Request) { defer func() { s.mu.Lock() delete(s.sseSubscribers, channel) - close(channel) s.mu.Unlock() }() diff --git a/go/webterm/server_test.go b/go/webterm/server_test.go index 35e1538..c5b87a7 100644 --- a/go/webterm/server_test.go +++ b/go/webterm/server_test.go @@ -214,3 +214,31 @@ func TestRootTerminalPageAndSparklineValidation(t *testing.T) { t.Fatalf("expected 400 for missing container, got %d", resp2.StatusCode) } } + +func TestMarkRouteActivityBroadcastsWithoutBlockingGlobalLock(t *testing.T) { + server := NewLocalServer(Config{}, ServerOptions{}) + ready := make(chan string, 1) + full := make(chan string, 1) + full <- "occupied" + + server.mu.Lock() + server.sseSubscribers[ready] = struct{}{} + server.sseSubscribers[full] = struct{}{} + server.routeLastSSE["route-a"] = time.Now().Add(-time.Second) + server.mu.Unlock() + + start := time.Now() + server.markRouteActivity("route-a") + if elapsed := time.Since(start); elapsed > 100*time.Millisecond { + t.Fatalf("markRouteActivity took too long: %s", elapsed) + } + + select { + case got := <-ready: + if got != "route-a" { + t.Fatalf("unexpected broadcast payload: %q", got) + } + default: + t.Fatalf("expected route activity broadcast") + } +} diff --git a/go/webterm/session.go b/go/webterm/session.go index b34b3c1..a0c34d9 100644 --- a/go/webterm/session.go +++ b/go/webterm/session.go @@ -32,3 +32,28 @@ func (noopConnector) OnData([]byte) {} func (noopConnector) OnBinary([]byte) {} func (noopConnector) OnMeta(map[string]any) {} func (noopConnector) OnClose() {} + +func dispatchSessionOutput(filtered []byte, tracker *terminalstate.Tracker, replay *ReplayBuffer, connector SessionConnector) { + if len(filtered) == 0 { + return + } + replay.Add(filtered) + if tracker != nil { + _ = tracker.Feed(filtered) + } + connector.OnData(filtered) +} + +func snapshotFromTracker(tracker *terminalstate.Tracker, width, height int) terminalstate.Snapshot { + if tracker != nil { + return tracker.Snapshot() + } + if height < 0 { + height = 0 + } + return terminalstate.Snapshot{ + Width: width, + Height: height, + Buffer: make([][]terminalstate.Cell, height), + } +} diff --git a/go/webterm/session_helpers_test.go b/go/webterm/session_helpers_test.go new file mode 100644 index 0000000..f75377e --- /dev/null +++ b/go/webterm/session_helpers_test.go @@ -0,0 +1,64 @@ +package webterm + +import ( + "testing" + + "github.com/rcarmo/webterm-go-port/internal/terminalstate" +) + +type captureConnector struct { + data [][]byte +} + +func (c *captureConnector) OnData(data []byte) { + c.data = append(c.data, append([]byte{}, data...)) +} + +func (c *captureConnector) OnBinary([]byte) {} +func (c *captureConnector) OnMeta(map[string]any) {} +func (c *captureConnector) OnClose() {} + +func TestDispatchSessionOutput(t *testing.T) { + replay := NewReplayBuffer(1024) + connector := &captureConnector{} + tracker := terminalstate.NewTracker(80, 24) + + dispatchSessionOutput([]byte("hello\n"), tracker, replay, connector) + if got := string(replay.Bytes()); got != "hello\n" { + t.Fatalf("unexpected replay: %q", got) + } + if len(connector.data) != 1 || string(connector.data[0]) != "hello\n" { + t.Fatalf("unexpected connector data: %+v", connector.data) + } +} + +func TestDispatchSessionOutputEmpty(t *testing.T) { + replay := NewReplayBuffer(1024) + connector := &captureConnector{} + + dispatchSessionOutput(nil, nil, replay, connector) + dispatchSessionOutput([]byte{}, nil, replay, connector) + + if got := string(replay.Bytes()); got != "" { + t.Fatalf("expected empty replay, got %q", got) + } + if len(connector.data) != 0 { + t.Fatalf("expected no connector events") + } +} + +func TestSnapshotFromTrackerFallback(t *testing.T) { + snap := snapshotFromTracker(nil, 10, -2) + if snap.Width != 10 || snap.Height != 0 || len(snap.Buffer) != 0 { + t.Fatalf("unexpected fallback snapshot: %+v", snap) + } +} + +func TestSnapshotFromTrackerWithTracker(t *testing.T) { + tracker := terminalstate.NewTracker(4, 2) + _ = tracker.Feed([]byte("ab")) + snap := snapshotFromTracker(tracker, 1, 1) + if snap.Width != 4 || snap.Height != 2 { + t.Fatalf("unexpected tracker snapshot dimensions: %+v", snap) + } +} diff --git a/src/webterm/static/icons/webterm-192.png b/go/webterm/static/icons/webterm-192.png similarity index 100% rename from src/webterm/static/icons/webterm-192.png rename to go/webterm/static/icons/webterm-192.png diff --git a/src/webterm/static/icons/webterm-512.png b/go/webterm/static/icons/webterm-512.png similarity index 100% rename from src/webterm/static/icons/webterm-512.png rename to go/webterm/static/icons/webterm-512.png diff --git a/src/webterm/static/js/ghostty-vt.wasm b/go/webterm/static/js/ghostty-vt.wasm similarity index 100% rename from src/webterm/static/js/ghostty-vt.wasm rename to go/webterm/static/js/ghostty-vt.wasm diff --git a/src/webterm/static/js/terminal.js b/go/webterm/static/js/terminal.js similarity index 100% rename from src/webterm/static/js/terminal.js rename to go/webterm/static/js/terminal.js diff --git a/src/webterm/static/js/terminal.ts b/go/webterm/static/js/terminal.ts similarity index 100% rename from src/webterm/static/js/terminal.ts rename to go/webterm/static/js/terminal.ts diff --git a/src/webterm/static/manifest.json b/go/webterm/static/manifest.json similarity index 100% rename from src/webterm/static/manifest.json rename to go/webterm/static/manifest.json diff --git a/src/webterm/static/monospace.css b/go/webterm/static/monospace.css similarity index 100% rename from src/webterm/static/monospace.css rename to go/webterm/static/monospace.css diff --git a/go/webterm/terminal_session.go b/go/webterm/terminal_session.go index b00469f..aa14c05 100644 --- a/go/webterm/terminal_session.go +++ b/go/webterm/terminal_session.go @@ -136,15 +136,7 @@ func (s *TerminalSession) handleOutput(data []byte) { tracker := s.tracker connector := s.connector s.mu.Unlock() - - if len(filtered) == 0 { - return - } - s.replay.Add(filtered) - if tracker != nil { - _ = tracker.Feed(filtered) - } - connector.OnData(filtered) + dispatchSessionOutput(filtered, tracker, s.replay, connector) } func (s *TerminalSession) Close() error { @@ -233,15 +225,9 @@ func (s *TerminalSession) GetReplayBuffer() []byte { func (s *TerminalSession) GetScreenSnapshot() terminalstate.Snapshot { s.mu.RLock() tracker := s.tracker + width, height := s.width, s.height s.mu.RUnlock() - if tracker == nil { - return terminalstate.Snapshot{ - Width: s.width, - Height: s.height, - Buffer: make([][]terminalstate.Cell, s.height), - } - } - return tracker.Snapshot() + return snapshotFromTracker(tracker, width, height) } func (s *TerminalSession) UpdateConnector(connector SessionConnector) { diff --git a/package.json b/package.json index e548821..094090e 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,10 @@ "typescript": "^5.7.0" }, "scripts": { - "build": "bun run typecheck && bun build src/webterm/static/js/terminal.ts --outfile=src/webterm/static/js/terminal.js --minify --target=browser && cp node_modules/ghostty-web/ghostty-vt.wasm src/webterm/static/js/", - "build:fast": "bun build src/webterm/static/js/terminal.ts --outfile=src/webterm/static/js/terminal.js --minify --target=browser", - "watch": "bun build src/webterm/static/js/terminal.ts --outfile=src/webterm/static/js/terminal.js --watch --target=browser", + "build": "bun run typecheck && bun build go/webterm/static/js/terminal.ts --outfile=go/webterm/static/js/terminal.js --minify --target=browser && cp node_modules/ghostty-web/ghostty-vt.wasm go/webterm/static/js/", + "build:fast": "bun build go/webterm/static/js/terminal.ts --outfile=go/webterm/static/js/terminal.js --minify --target=browser", + "watch": "bun build go/webterm/static/js/terminal.ts --outfile=go/webterm/static/js/terminal.js --watch --target=browser", "typecheck": "bun x tsc --noEmit -p tsconfig.json", - "copy-wasm": "cp node_modules/ghostty-web/ghostty-vt.wasm src/webterm/static/js/" + "copy-wasm": "cp node_modules/ghostty-web/ghostty-vt.wasm go/webterm/static/js/" } } diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index 2291fc0..0000000 --- a/poetry.lock +++ /dev/null @@ -1,1168 +0,0 @@ -# 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 deleted file mode 100644 index 3416035..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,117 +0,0 @@ -[tool.poetry] -name = "webterm" -version = "1.2.18" -description = "Serve terminal sessions over the web" -authors = ["Will McGugan "] -license = "MIT" -readme = "README.md" -packages = [{include = "webterm", from = "src"}] -include = [ - { path = "src/webterm/static/monospace.css" }, - { path = "src/webterm/static/js/terminal.js" }, - { path = "src/webterm/static/js/ghostty-vt.wasm" }, -] - -[tool.poetry.dependencies] -python = "^3.9" -aiohttp = "^3.13.0" -uvloop = { version = "^0.22.0", markers = "sys_platform != 'win32'" } -click = "^8.1.7" -pydantic = "^2.7.0" -importlib-metadata = ">=6.0.0" -tomli = { version = "^2.0.1", python = "<3.11" } -pyyaml = "^6.0.0" -pyte = "^0.8.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] -webterm = "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 = ["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/webterm"] -branch = true -omit = [ - "*/tests/*", - "*/__pycache__/*", - # Thread/FD polling integration is harder to deterministically unit test - "*/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 (75% due to Docker-dependent code that requires integration tests) -fail_under = 75 diff --git a/src/webterm/__init__.py b/src/webterm/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/webterm/_two_way_dict.py b/src/webterm/_two_way_dict.py deleted file mode 100644 index 12fa7f0..0000000 --- a/src/webterm/_two_way_dict.py +++ /dev/null @@ -1,89 +0,0 @@ -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/webterm/alt_screen.py b/src/webterm/alt_screen.py deleted file mode 100644 index 9c06bd1..0000000 --- a/src/webterm/alt_screen.py +++ /dev/null @@ -1,208 +0,0 @@ -"""Custom pyte Screen with alternate screen buffer support. - -pyte's standard Screen class doesn't implement DECSET/DECRST 1049 (alternate screen buffer) -which causes issues when programs like tmux, vim, less, etc. switch between main and alternate -screens. Without this, screen clearing in tmux panes shows overlapping old and new content -in screenshots. - -This module provides AltScreen, a Screen subclass that properly saves and restores -the screen buffer when switching between main and alternate screen modes. -""" - -from __future__ import annotations - -import copy -import re -from typing import TYPE_CHECKING, Any - -import pyte -from pyte.screens import Margins - -# Pattern to match a run of 3+ (EL2 + CUU1) pairs used by Ink/React CLI -# to erase the previous frame before drawing the next one. -_INK_CLEAR_PATTERN = re.compile(rb"(\x1b\[2K\x1b\[1A){3,}") -_EL2_CUU1 = b"\x1b[2K\x1b[1A" - -# Patch pyte's CSI dispatch table to handle SU (Scroll Up, CSI S) and -# SD (Scroll Down, CSI T). Without this, tmux output using xterm-256color -# sends CSI S for scrolling which pyte silently ignores, causing ghost -# content to remain on screen. -pyte.ByteStream.csi["S"] = "scroll_up" -pyte.ByteStream.csi["T"] = "scroll_down" -pyte.Stream.csi["S"] = "scroll_up" -pyte.Stream.csi["T"] = "scroll_down" -# Update the events frozenset so pyte recognises these as valid events. -pyte.Stream.events = pyte.Stream.events | frozenset(["scroll_up", "scroll_down"]) - -if TYPE_CHECKING: - from pyte.screens import Char - -# Private mode alternate screen buffers (1047/1048/1049/47) - shifted by 5 per pyte's convention -DECALTBUF = 1049 << 5 -DECALTBUF_1047 = 1047 << 5 -DECALTBUF_1048 = 1048 << 5 -DECALTBUF_47 = 47 << 5 - - -class AltScreen(pyte.Screen): - """A pyte Screen with proper alternate screen buffer support. - - Implements DECSET/DECRST 1049 to save and restore the main screen buffer - when programs switch to alternate screen mode. - - """ - - def __init__(self, columns: int, lines: int, *args: Any, **kwargs: Any) -> None: - super().__init__(columns, lines, *args, **kwargs) - # Storage for main screen state when in alternate mode - self._saved_buffer: dict[int, dict[int, Char]] | None = None - self._saved_cursor: pyte.screens.Cursor | None = None - - def _save_main_screen(self) -> None: - """Save the current screen buffer and cursor for later restoration.""" - # Deep copy the buffer to avoid aliasing - # Save all rows within current screen bounds with all their column data - self._saved_buffer = {} - for row_idx in range(self.lines): - self._saved_buffer[row_idx] = { - col: self.buffer[row_idx][col] for col in range(self.columns) - } - # Save cursor state - self._saved_cursor = copy.copy(self.cursor) - - def _restore_main_screen(self) -> None: - """Restore the previously saved screen buffer and cursor.""" - if self._saved_buffer is not None: - # Restore buffer - copy characters into existing line structures - for row_idx in range(self.lines): - if row_idx in self._saved_buffer: - saved_row = self._saved_buffer[row_idx] - for col in range(self.columns): - if col in saved_row: - self.buffer[row_idx][col] = saved_row[col] - else: - self.buffer[row_idx][col] = self.default_char - else: - # Clear rows that weren't in saved buffer - for col in range(self.columns): - self.buffer[row_idx][col] = self.default_char - self._saved_buffer = None - - if self._saved_cursor is not None: - self.cursor = self._saved_cursor - self._saved_cursor = None - - # Mark all lines as dirty for re-render - self.dirty.update(range(self.lines)) - - def _is_alt_buffer_mode(self, modes: tuple[int, ...]) -> bool: - return 47 in modes or 1047 in modes or 1048 in modes or 1049 in modes - - def _has_alt_buffer_enabled(self) -> bool: - return ( - DECALTBUF in self.mode - or DECALTBUF_1047 in self.mode - or DECALTBUF_1048 in self.mode - or DECALTBUF_47 in self.mode - ) - - def set_mode(self, *modes: int, **kwargs: Any) -> None: - """Set (enable) modes, with special handling for alternate screen buffer.""" - # Check if we're entering alternate screen mode (private mode 47/1047/1048/1049) - if kwargs.get("private") and self._is_alt_buffer_mode(modes) and not self._has_alt_buffer_enabled(): - # Save main screen before switching - self._save_main_screen() - # Clear screen for alternate buffer - self.erase_in_display(2) - self.cursor_position() - - # Call parent implementation - super().set_mode(*modes, **kwargs) - - def reset_mode(self, *modes: int, **kwargs: Any) -> None: - """Reset (disable) modes, with special handling for alternate screen buffer.""" - # Check if we're leaving alternate screen mode (private mode 47/1047/1048/1049) - if kwargs.get("private") and self._is_alt_buffer_mode(modes) and self._has_alt_buffer_enabled(): - # Will be removed by parent, restore main screen after - super().reset_mode(*modes, **kwargs) - self._restore_main_screen() - return - - # Call parent implementation - super().reset_mode(*modes, **kwargs) - - def resize(self, lines: int | None = None, columns: int | None = None) -> None: - """Resize screen, clearing saved alternate buffer if size changes.""" - # If we're in alternate mode and resizing, the saved buffer may be invalid - if self._saved_buffer is not None and ( - (lines is not None and lines != self.lines) - or (columns is not None and columns != self.columns) - ): - # Invalidate saved buffer on resize - it won't match the new dimensions - self._saved_buffer = None - self._saved_cursor = None - - super().resize(lines, columns) - - def scroll_up(self, count: int = 1) -> None: - """Scroll the screen up by *count* lines within the scroll region. - - Lines scrolled off the top are lost; blank lines are added at the - bottom. The cursor position is not changed. - - Implements CSI n S (SU — Scroll Up), which pyte does not handle - natively. tmux sends this when TERM supports the ``indn`` - capability (e.g. xterm-256color). - """ - top, bottom = self.margins or Margins(0, self.lines - 1) - self.dirty.update(range(self.lines)) - for _ in range(min(count, bottom - top + 1)): - for y in range(top, bottom): - self.buffer[y] = self.buffer[y + 1] - self.buffer.pop(bottom, None) - - def scroll_down(self, count: int = 1) -> None: - """Scroll the screen down by *count* lines within the scroll region. - - Lines scrolled off the bottom are lost; blank lines are added at - the top. The cursor position is not changed. - - Implements CSI n T (SD — Scroll Down). - """ - top, bottom = self.margins or Margins(0, self.lines - 1) - self.dirty.update(range(self.lines)) - for _ in range(min(count, bottom - top + 1)): - for y in range(bottom, top, -1): - self.buffer[y] = self.buffer[y - 1] - self.buffer.pop(top, None) - - def expand_clear_sequences(self, data: bytes) -> bytes: - """Expand partial line-by-line clears to cover the full screen. - - CLI frameworks like Ink (React for terminals) erase their previous - output using repeated ``EL2 + CUU1`` (erase line, cursor up) sequences. - When the application's ``/clear`` command resets the framework's internal - line counter, the next frame only erases a few lines instead of the full - previous output. In a real terminal the old content has scrolled into the - scrollback buffer, but pyte keeps it visible, producing ghost content in - screenshots. - - This method detects such partial clears and extends them so that all - lines from the cursor position up to row 0 are erased. - """ - if _EL2_CUU1 not in data: - return data - - cursor_y = self.cursor.y - - def _extend(match: re.Match[bytes]) -> bytes: - nonlocal cursor_y - run = match.group(0) - pair_count = len(run) // len(_EL2_CUU1) - extra = cursor_y - pair_count - cursor_y = max(cursor_y - pair_count, 0) - if extra > 0: - return run + _EL2_CUU1 * extra - return run - - return _INK_CLEAR_PATTERN.sub(_extend, data) diff --git a/src/webterm/cli.py b/src/webterm/cli.py deleted file mode 100644 index 2488cb8..0000000 --- a/src/webterm/cli.py +++ /dev/null @@ -1,169 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -import os -import sys -from pathlib import Path - -import click -from importlib_metadata import PackageNotFoundError, version - -from . import constants -from .local_server import LocalServer - -FORMAT = "%(asctime)s %(levelname)s %(message)s" -logging.basicConfig( - level="DEBUG" if constants.DEBUG else "INFO", - format=FORMAT, - datefmt="%X", -) - -log = logging.getLogger("webterm") - - -def _package_version() -> str: - try: - return version("webterm") - except PackageNotFoundError: - return "0.0.0" - - -@click.command() -@click.version_option(_package_version()) -@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( - "--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.', -) -@click.option( - "--docker-watch", - "-D", - "docker_watch", - is_flag=True, - help='Watch Docker for containers with "webterm-command" label and add/remove sessions dynamically.', -) -@click.option( - "--theme", - "-t", - help="Terminal color theme (xterm, monokai, dark, light, dracula, catppuccin, nord, gruvbox, solarized, tokyo).", - default="xterm", -) -@click.option( - "--font-family", - "-f", - help="Terminal font family (CSS font stack).", - default=None, -) -@click.option( - "--font-size", - "-s", - type=int, - help="Terminal font size in pixels.", - default=16, -) -def app( - command: str | None, - port: int, - host: str, - landing_manifest: Path | None, - compose_manifest: Path | None, - docker_watch: bool, - theme: str, - font_family: str | None, - font_size: int, -) -> None: - """Serve a terminal over HTTP/WebSocket. - - COMMAND: Shell command to run in terminal (default: $SHELL) - - Examples: - - \b - webterm # Serve default shell - webterm htop # Serve htop in terminal - webterm --docker-watch # Watch Docker for labeled containers - """ - VERSION = _package_version() - log.info("webterm v%s", 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 = [] - is_compose_mode = False - is_docker_watch_mode = docker_watch - compose_project: str | None = None - if landing_manifest: - landing_apps = load_landing_yaml(landing_manifest) - elif compose_manifest: - landing_apps = load_compose_manifest(compose_manifest) - is_compose_mode = True - # Derive compose project name from directory (same as docker-compose default) - compose_project = compose_manifest.parent.name - - server = LocalServer( - "./", - _config, - host=host, - port=port, - landing_apps=landing_apps, - compose_mode=is_compose_mode, - compose_project=compose_project, - docker_watch_mode=is_docker_watch_mode, - theme=theme, - font_family=font_family, - font_size=font_size, - ) - for app_entry in landing_apps: - server.add_terminal(app_entry.name, app_entry.command, slug=app_entry.slug) - if command: - # Run command as terminal - server.add_terminal("Terminal", command, "") - log.info("Serving terminal: %s", command) - elif docker_watch: - # Docker watch mode - sessions added dynamically - log.info("Docker watch mode enabled - sessions will be added dynamically") - elif not landing_apps: - # Run default shell - terminal_command = os.environ.get("SHELL", "/bin/sh") - server.add_terminal("Terminal", terminal_command, "") - log.info("Serving terminal: %s", 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/webterm/config.py b/src/webterm/config.py deleted file mode 100644 index 18c7e64..0000000 --- a/src/webterm/config.py +++ /dev/null @@ -1,153 +0,0 @@ -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 - theme: str | None = None - - -class Config(BaseModel): - """Root configuration model.""" - - apps: 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) - - terminal_entries = config_data.get("terminal", {}) - app_entries = config_data.get("app", {}) - if app_entries: - raise ValueError("App manifests are no longer supported; use [terminal.*] entries only.") - - apps = [make_app(name, app, terminal=True) for name, app in terminal_entries.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 - theme = _extract_label(labels, "webterm-theme") - slug = slugify(name) - apps.append( - App( - name=name, - slug=slug, - command=command, - path=service.get("working_dir", "./"), - color="", - terminal=True, - theme=theme, - ) - ) - return apps diff --git a/src/webterm/constants.py b/src/webterm/constants.py deleted file mode 100644 index 693eae6..0000000 --- a/src/webterm/constants.py +++ /dev/null @@ -1,56 +0,0 @@ -""" -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 a truthy value, otherwise `False`. - """ - value = get_environ(name) - if value is None: - return False - return value.strip().lower() in {"1", "true", "yes", "on"} - - -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.""" - -SCREENSHOT_FORCE_REDRAW_ENV: Final = "WEBTERM_SCREENSHOT_FORCE_REDRAW" -"""Environment variable to force redraw before screenshots.""" diff --git a/src/webterm/docker_exec_session.py b/src/webterm/docker_exec_session.py deleted file mode 100644 index 76feb05..0000000 --- a/src/webterm/docker_exec_session.py +++ /dev/null @@ -1,464 +0,0 @@ -"""Docker exec-based terminal session using Docker API and socket.""" - -from __future__ import annotations - -import asyncio -import contextlib -import json -import logging -import re -import socket -from collections import deque -from dataclasses import dataclass -from typing import TYPE_CHECKING - -import pyte - -from .alt_screen import AltScreen -from .docker_stats import get_docker_socket_path -from .session import Session, SessionConnector - -if TYPE_CHECKING: - from .poller import Poller - from .types import Meta, SessionID - - -log = logging.getLogger("webterm") - -REPLAY_BUFFER_SIZE = 256 * 1024 # 256KB -DEFAULT_SCREEN_WIDTH = 132 -DEFAULT_SCREEN_HEIGHT = 45 - -# Pattern to filter out terminal device attribute responses that cause display issues -# These are responses to DA1/DA2/DA3 queries that shouldn't be displayed as text -# Matches complete responses like: -# \x1b[?1;10;0c (DA1 - Primary Device Attributes) -# \x1b[>1;10;0c (DA2 - Secondary Device Attributes, sent by tmux) -# \x1b[=1;0c (DA3 - Tertiary Device Attributes) -DA_RESPONSE_PATTERN = re.compile(rb"\x1b\[[?>=][\d;]*c") - -# Pattern to detect partial DA responses at end of data (incomplete escape sequence) -# Matches: \x1b, \x1b[, \x1b[?, \x1b[>, \x1b[=, \x1b[?1, \x1b[>1;10, etc. -# These need to be held back until more data arrives to see if they complete -DA_PARTIAL_PATTERN = re.compile(rb"\x1b(?:\[(?:[?>=][\d;]*)?)?$") - -# Map C1 control sequences to 7-bit ESC equivalents for pyte compatibility -CSI_C1 = b"\x9b" -OSC_C1 = b"\x9d" -ST_C1 = b"\x9c" -DCS_C1 = b"\x90" -SOS_C1 = b"\x98" -PM_C1 = b"\x9e" -APC_C1 = b"\x9f" - - -def _normalize_c1_controls(data: bytes, utf8_buffer: bytes = b"") -> tuple[bytes, bytes]: - if not data and not utf8_buffer: - return b"", b"" - data = utf8_buffer + data - out = bytearray() - pending_utf8 = bytearray() - expected_continuations = 0 - c1_map = { - 0x9B: b"\x1b[", - 0x9D: b"\x1b]", - 0x9C: b"\x1b\\", - 0x90: b"\x1bP", - 0x98: b"\x1bX", - 0x9E: b"\x1b^", - 0x9F: b"\x1b_", - } - idx = 0 - while idx < len(data): - byte = data[idx] - if expected_continuations: - if 0x80 <= byte <= 0xBF: - pending_utf8.append(byte) - expected_continuations -= 1 - idx += 1 - if expected_continuations == 0: - out.extend(pending_utf8) - pending_utf8.clear() - continue - out.extend(pending_utf8) - pending_utf8.clear() - expected_continuations = 0 - continue - if 0xC2 <= byte <= 0xDF: - pending_utf8.append(byte) - expected_continuations = 1 - idx += 1 - continue - if 0xE0 <= byte <= 0xEF: - pending_utf8.append(byte) - expected_continuations = 2 - idx += 1 - continue - if 0xF0 <= byte <= 0xF4: - pending_utf8.append(byte) - expected_continuations = 3 - idx += 1 - continue - replacement = c1_map.get(byte) - if replacement is not None: - out.extend(replacement) - else: - out.append(byte) - idx += 1 - if pending_utf8: - return bytes(out), bytes(pending_utf8) - return bytes(out), b"" - - -@dataclass(frozen=True) -class DockerExecSpec: - container: str - command: list[str] - user: str | None = None - - -class DockerExecSession(Session): - """Terminal session backed by Docker exec API.""" - - def __init__( - self, - poller: Poller, - session_id: SessionID, - exec_spec: DockerExecSpec, - socket_path: str | None = None, - ) -> None: - self.poller = poller - self.session_id = session_id - self.exec_spec = exec_spec - self._socket_path = socket_path or get_docker_socket_path() - self.master_fd: int | None = None - self._sock: socket.socket | None = None - self._task: asyncio.Task | None = None - self._connector = SessionConnector() - self._replay_buffer: deque[bytes] = deque() - self._replay_buffer_size = 0 - self._replay_lock = asyncio.Lock() - self._screen = AltScreen(DEFAULT_SCREEN_WIDTH, DEFAULT_SCREEN_HEIGHT) - self._stream = pyte.ByteStream(self._screen) - self._screen_lock = asyncio.Lock() - self._last_width = DEFAULT_SCREEN_WIDTH - self._last_height = DEFAULT_SCREEN_HEIGHT - self._change_counter = 0 - self._last_snapshot_counter = 0 - self._exec_id: str | None = None - self._pending_output = b"" - # Buffer for handling escape sequences split across socket reads - self._escape_buffer = b"" - self._utf8_buffer = b"" - - def __repr__(self) -> str: - return ( - "DockerExecSession(session_id=" - f"{self.session_id!r}, container={self.exec_spec.container!r})" - ) - - def _read_http_response(self, sock: socket.socket) -> tuple[int, dict, bytes]: - sock.settimeout(10.0) - data = b"" - while b"\r\n\r\n" not in data: - chunk = sock.recv(4096) - if not chunk: - break - data += chunk - if b"\r\n\r\n" not in data: - return 0, {}, b"" - header_bytes, body = data.split(b"\r\n\r\n", 1) - headers = header_bytes.decode("utf-8", errors="replace").split("\r\n") - status_line = headers[0] if headers else "" - status = 0 - if status_line: - parts = status_line.split() - if len(parts) >= 2: - try: - status = int(parts[1]) - except ValueError: - status = 0 - header_map: dict[str, str] = {} - for header in headers[1:]: - if ":" not in header: - continue - key, value = header.split(":", 1) - header_map[key.strip().lower()] = value.strip() - if "content-length" in header_map: - try: - length = int(header_map["content-length"]) - except ValueError: - length = 0 - remaining = length - len(body) - while remaining > 0: - chunk = sock.recv(min(4096, remaining)) - if not chunk: - break - body += chunk - remaining -= len(chunk) - return status, header_map, body - - def _request_json(self, method: str, path: str, payload: dict | None = None) -> dict: - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - try: - sock.connect(self._socket_path) - body = json.dumps(payload or {}).encode("utf-8") if payload is not None else b"" - headers = [ - f"{method} {path} HTTP/1.1", - "Host: localhost", - ] - if payload is not None: - headers.append("Content-Type: application/json") - headers.append(f"Content-Length: {len(body)}") - headers.append("") - headers.append("") - request = "\r\n".join(headers).encode("utf-8") + body - sock.sendall(request) - status, _headers, body_bytes = self._read_http_response(sock) - finally: - sock.close() - if status < 200 or status >= 300: - detail = body_bytes.decode("utf-8", errors="replace") - raise RuntimeError(f"Docker API request failed ({status}): {detail}") - if not body_bytes: - return {} - try: - return json.loads(body_bytes.decode("utf-8", errors="replace")) - except json.JSONDecodeError as exc: - raise RuntimeError("Docker API returned invalid JSON") from exc - - def _create_exec(self) -> str: - payload = { - "AttachStdin": True, - "AttachStdout": True, - "AttachStderr": True, - "Tty": True, - "Cmd": self.exec_spec.command, - } - if self.exec_spec.user: - payload["User"] = self.exec_spec.user - response = self._request_json( - "POST", f"/containers/{self.exec_spec.container}/exec", payload - ) - exec_id = response.get("Id") - if not isinstance(exec_id, str) or not exec_id: - raise RuntimeError("Docker API did not return exec ID") - return exec_id - - def _start_exec_socket(self, exec_id: str) -> socket.socket: - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - try: - sock.connect(self._socket_path) - payload = json.dumps({"Detach": False, "Tty": True}).encode("utf-8") - headers = [ - f"POST /exec/{exec_id}/start HTTP/1.1", - "Host: localhost", - "Content-Type: application/json", - f"Content-Length: {len(payload)}", - "Connection: Upgrade", - "Upgrade: tcp", - "", - "", - ] - sock.sendall("\r\n".join(headers).encode("utf-8") + payload) - status, _headers, body = self._read_http_response(sock) - if status not in (101,) and (status < 200 or status >= 300): - detail = body.decode("utf-8", errors="replace") - raise RuntimeError(f"Docker API exec start failed ({status}): {detail}") - # Don't save body from HTTP upgrade - it contains protocol handshake data, - # not real terminal output (e.g., device attribute responses like "\x1b[?1;10;0c") - sock.settimeout(None) - return sock - except Exception: - sock.close() - raise - - def _resize_exec(self, width: int, height: int) -> None: - assert self._exec_id is not None - path = f"/exec/{self._exec_id}/resize?h={height}&w={width}" - self._request_json("POST", path, None) - - async def open(self, width: int = 80, height: int = 24) -> None: - log.info( - "Opening Docker exec session %s for %s", - self.session_id, - self.exec_spec.container, - ) - self._last_width = width - self._last_height = height - async with self._screen_lock: - self._screen = AltScreen(width, height) - self._stream = pyte.ByteStream(self._screen) - exec_id = await asyncio.to_thread(self._create_exec) - self._exec_id = exec_id - self._sock = await asyncio.to_thread(self._start_exec_socket, exec_id) - self.master_fd = self._sock.fileno() - await asyncio.to_thread(self._resize_exec, width, height) - - async def set_terminal_size(self, width: int, height: int) -> None: - self._last_width = width - self._last_height = height - async with self._screen_lock: - self._screen.resize(height, width) - self._change_counter += 1 - if self._exec_id: - await asyncio.to_thread(self._resize_exec, width, height) - - async def force_redraw(self) -> None: - await self.set_terminal_size(self._last_width, self._last_height) - - async def _add_to_replay_buffer(self, data: bytes) -> None: - 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 = self._replay_buffer.popleft() - self._replay_buffer_size -= len(old) - - async def _update_screen(self, data: bytes) -> None: - async with self._screen_lock: - try: - normalized, self._utf8_buffer = _normalize_c1_controls(data, self._utf8_buffer) - if not normalized: - return - normalized = self._screen.expand_clear_sequences(normalized) - self._stream.feed(normalized) - if self._screen.dirty: - self._change_counter += 1 - except Exception as exc: - log.warning( - "Docker exec screen update failed (%s): %s", - type(exc).__name__, - exc, - ) - - async def _drain_pending_output(self) -> None: - if not self._pending_output: - return - data = self._pending_output - self._pending_output = b"" - await self._add_to_replay_buffer(data) - await self._update_screen(data) - if self._connector: - await self._connector.on_data(data) - - async def get_replay_buffer(self) -> bytes: - async with self._replay_lock: - return b"".join(self._replay_buffer) - - async def get_screen_lines(self) -> list[str]: - async with self._screen_lock: - return [line.rstrip() for line in self._screen.display] - - async def get_screen_snapshot(self) -> tuple[int, int, list, bool]: - async with self._screen_lock: - width = self._screen.columns - height = self._screen.lines - has_changes = self._change_counter > self._last_snapshot_counter - self._last_snapshot_counter = self._change_counter - snapshot = [ - [self._screen.buffer[row][col] for col in range(width)] for row in range(height) - ] - - buffer = [] - for row_data in snapshot: - row_chars = [] - for char in row_data: - row_chars.append( - { - "data": char.data if char.data else " ", - "fg": char.fg, - "bg": char.bg, - "bold": char.bold, - "italics": char.italics, - "underscore": char.underscore, - "reverse": char.reverse, - } - ) - buffer.append(row_chars) - return (width, height, buffer, has_changes) - - def update_connector(self, connector: SessionConnector) -> None: - self._connector = connector - - async def start(self, connector: SessionConnector) -> asyncio.Task: - self._connector = connector - if self.master_fd is None: - raise RuntimeError("Docker exec session not opened") - if self._task is not None: - 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: - await self._drain_pending_output() - while True: - data = await queue.get() - if not data: - break - # Prepend any buffered partial escape sequence from previous read - if self._escape_buffer: - data = self._escape_buffer + data - self._escape_buffer = b"" - - # Filter out complete DA1/DA2 responses (e.g., \x1b[?1;10;0c) - data = DA_RESPONSE_PATTERN.sub(b"", data) - if not data: - continue - - # Check for partial escape sequence at end that might be a DA response - # Hold it back until we get more data to see if it completes - match = DA_PARTIAL_PATTERN.search(data) - if match: - self._escape_buffer = data[match.start() :] - data = data[: match.start()] - if not data: - continue - - await self._add_to_replay_buffer(data) - await self._update_screen(data) - if self._connector: - await self._connector.on_data(data) - except OSError: - log.exception("error in docker exec session 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 - self.poller.remove_file(fd) - if self._sock is not None: - self._sock.close() - self._sock = None - - async def send_bytes(self, data: bytes) -> bool: - fd = self.master_fd - if fd is None: - return False - try: - await self.poller.write(fd, data) - except (KeyError, OSError): - return False - return True - - async def send_meta(self, data: Meta) -> bool: - return True - - async def close(self) -> None: - if self._task is not None and not self._task.done(): - self._task.cancel() - if self._sock is not None: - self._sock.close() - self._sock = None - - async def wait(self, timeout: float = 2.0) -> None: - if self._task is not None: - with contextlib.suppress(asyncio.CancelledError, TimeoutError): - await asyncio.wait_for(asyncio.shield(self._task), timeout=timeout) - - def is_running(self) -> bool: - return not (self.master_fd is None or self._task is None) diff --git a/src/webterm/docker_stats.py b/src/webterm/docker_stats.py deleted file mode 100644 index fc3ce7f..0000000 --- a/src/webterm/docker_stats.py +++ /dev/null @@ -1,385 +0,0 @@ -"""Docker container CPU stats via Unix socket. - -Reads container stats from Docker socket using only asyncio and stdlib. -""" - -from __future__ import annotations - -import asyncio -import contextlib -import json -import logging -import os -import socket -from collections import deque -from pathlib import Path - -log = logging.getLogger("webterm") - -DEFAULT_DOCKER_SOCKET = "/var/run/docker.sock" - - -def get_docker_socket_path() -> str: - """Get Docker socket path from DOCKER_HOST env var or default. - - Supports unix:// scheme or plain path in DOCKER_HOST. - """ - docker_host = os.environ.get("DOCKER_HOST", "") - if docker_host: - if docker_host.startswith("unix://"): - return docker_host[7:] # Strip unix:// prefix - if docker_host.startswith("/"): - return docker_host - return DEFAULT_DOCKER_SOCKET - - -STATS_HISTORY_SIZE = 180 # Number of CPU readings to keep (30 min at 10s interval) -POLL_INTERVAL = 10.0 # Seconds between polls - - -class DockerStatsCollector: - """Collects CPU stats from Docker containers via the Docker socket.""" - - def __init__(self, socket_path: str | None = None, compose_project: str | None = None) -> None: - self._socket_path = socket_path or get_docker_socket_path() - self._compose_project = compose_project - # container_name -> deque of CPU % values (0-100) - self._cpu_history: dict[str, deque[float]] = {} - self._running = False - self._task: asyncio.Task | None = None - # Track previous CPU values for delta calculation - self._prev_cpu: dict[str, tuple[int, int]] = {} - # Service names to poll (can be modified dynamically) - self._service_names: list[str] = [] - self._service_names_lock = asyncio.Lock() - - @property - def available(self) -> bool: - """Check if Docker socket is available and accessible.""" - path = Path(self._socket_path) - if not path.exists(): - return False - # Also check we can actually connect - try: - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - sock.settimeout(2.0) - sock.connect(self._socket_path) - sock.close() - return True - except (OSError, TimeoutError) as e: - log.warning("Docker socket exists but not accessible: %s", e) - return False - - def get_cpu_history(self, container_name: str) -> list[float]: - """Get CPU history for a container.""" - if container_name not in self._cpu_history: - return [] - return list(self._cpu_history[container_name]) - - async def _make_request(self, path: str) -> dict | list | None: - """Make HTTP request to Docker socket.""" - loop = asyncio.get_event_loop() - - def _sync_request() -> bytes | None: - sock: socket.socket | None = None - try: - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - sock.settimeout(10.0) # Increased timeout - sock.connect(self._socket_path) - - # Use HTTP/1.0 to avoid chunked encoding - request = f"GET {path} HTTP/1.0\r\nHost: localhost\r\n\r\n" - sock.sendall(request.encode()) - - # Read response - chunks = [] - while True: - chunk = sock.recv(8192) - if not chunk: - break - chunks.append(chunk) - return b"".join(chunks) - except (OSError, TimeoutError) as e: - log.debug("Socket error for %s: %s", path, e) - return None - finally: - if sock is not None: - sock.close() - - response = await loop.run_in_executor(None, _sync_request) - if response is None: - log.debug("No response from Docker socket for %s", path) - return None - - return self._parse_docker_response(path, response) - - def _parse_docker_response(self, path: str, response: bytes) -> dict | list | None: - """Parse HTTP response from Docker socket.""" - try: - response_str = response.decode("utf-8", errors="replace") - - # Split headers and body - if "\r\n\r\n" not in response_str: - return None - - headers, body = response_str.split("\r\n\r\n", 1) - - # Check for error status - first_line = headers.split("\r\n")[0] - if "200" not in first_line and "OK" not in first_line: - return None - - body = body.strip() - - # With HTTP/1.0, body should be plain JSON - if body.startswith("{") or body.startswith("["): - return json.loads(body) - - # Fallback: try to find JSON in body - for line in body.split("\r\n"): - stripped = line.strip() - if stripped.startswith("{") or stripped.startswith("["): - return json.loads(stripped) - - return None - except (json.JSONDecodeError, Exception): - return None - - async def _discover_containers(self, service_names: list[str]) -> dict[str, str]: - """Map service names to container IDs by querying Docker. - - Returns: - Dict mapping service_name -> container_id - """ - # List all containers - containers = await self._make_request("/containers/json") - if not isinstance(containers, list): - return {} - - mapping: dict[str, str] = {} - for container in containers: - if not isinstance(container, dict): - continue - - container_id = container.get("Id", "")[:12] # Short ID - names = container.get("Names", []) - labels = container.get("Labels", {}) - - # Filter by compose project if specified - if self._compose_project: - project = labels.get("com.docker.compose.project", "") - if project != self._compose_project: - continue - - # Check compose service label - service = labels.get("com.docker.compose.service", "") - if service in service_names: - mapping[service] = container_id - continue - - # Fall back to container name matching - for name in names: - # Docker names start with / - clean_name = name.lstrip("/") - # Check if service name is part of container name - for svc in service_names: - if svc in clean_name or clean_name == svc: - mapping[svc] = container_id - break - - if mapping: - log.debug( - "Discovered %d containers for stats (project=%s)", - len(mapping), - self._compose_project, - ) - - return mapping - - def _calculate_cpu_percent( - self, container: str, cpu_stats: dict, precpu_stats: dict - ) -> float | None: - """Calculate CPU percentage from stats. - - Formula: (cpu_delta / system_delta) * num_cpus * 100 - """ - try: - cpu_usage = cpu_stats.get("cpu_usage", {}) - precpu_usage = precpu_stats.get("cpu_usage", {}) - - cpu_total = cpu_usage.get("total_usage", 0) - precpu_total = precpu_usage.get("total_usage", 0) - system_cpu = cpu_stats.get("system_cpu_usage", 0) - presystem_cpu = precpu_stats.get("system_cpu_usage", 0) - - # Use previous values if precpu_stats is empty (first read) - if precpu_total == 0 and container in self._prev_cpu: - precpu_total, presystem_cpu = self._prev_cpu[container] - - # Store current values for next calculation - self._prev_cpu[container] = (cpu_total, system_cpu) - - cpu_delta = cpu_total - precpu_total - system_delta = system_cpu - presystem_cpu - - if system_delta <= 0 or cpu_delta < 0: - return None - - # Get number of CPUs - online_cpus = cpu_stats.get("online_cpus") - if online_cpus is None: - percpu = cpu_usage.get("percpu_usage", []) - online_cpus = len(percpu) if percpu else 1 - - cpu_percent = (cpu_delta / system_delta) * online_cpus * 100.0 - return min(cpu_percent, 100.0 * online_cpus) # Cap at max possible - - except (KeyError, TypeError, ZeroDivisionError): - return None - - async def _poll_container(self, service_name: str, container_id: str) -> None: - """Poll stats for a single container.""" - path = f"/containers/{container_id}/stats?stream=false" - stats = await self._make_request(path) - - if not isinstance(stats, dict): - return - - cpu_stats = stats.get("cpu_stats", {}) - precpu_stats = stats.get("precpu_stats", {}) - - cpu_percent = self._calculate_cpu_percent(service_name, cpu_stats, precpu_stats) - if cpu_percent is not None: - if service_name not in self._cpu_history: - self._cpu_history[service_name] = deque(maxlen=STATS_HISTORY_SIZE) - self._cpu_history[service_name].append(cpu_percent) - - async def _poll_loop(self) -> None: - """Background polling loop.""" - # Discover container IDs on first run and periodically refresh - service_to_container: dict[str, str] = {} - refresh_counter = 0 - warned_no_containers = False - - while self._running: - # Get current service names (may change dynamically) - service_names = list(self._service_names) - - # Refresh container mapping every 30 iterations (~5 minutes at 10s interval) - # or immediately if service list changed - if refresh_counter % 30 == 0 or set(service_to_container.keys()) != set(service_names): - service_to_container = await self._discover_containers(service_names) - if not service_to_container and service_names and not warned_no_containers: - log.warning( - "No Docker containers found for CPU stats. " - "Ensure Docker socket is mounted (-v /var/run/docker.sock:/var/run/docker.sock)" - ) - warned_no_containers = True - - refresh_counter += 1 - - for service_name in service_names: - if not self._running: - break - container_id = service_to_container.get(service_name) - if not container_id: - continue - try: - await self._poll_container(service_name, container_id) - except Exception as e: - log.debug("Error polling stats for %s: %s", service_name, e) - - await asyncio.sleep(POLL_INTERVAL) - - def start(self, service_names: list[str]) -> None: - """Start collecting stats for given service names.""" - if not self.available: - log.debug("Docker socket not available at %s", self._socket_path) - return - - if self._running: - return - - self._service_names = list(service_names) - self._running = True - self._task = asyncio.create_task(self._poll_loop()) - log.info("Started Docker stats collection for %d services", len(service_names)) - - def add_service(self, service_name: str) -> None: - """Add a service to the polling list dynamically. - - This is safe to call while the collector is running - the poll loop - will pick up the new service on its next container discovery cycle. - """ - if service_name not in self._service_names: - self._service_names.append(service_name) - log.debug("Added service to stats collector: %s", service_name) - - def remove_service(self, service_name: str) -> None: - """Remove a service from the polling list.""" - if service_name in self._service_names: - self._service_names.remove(service_name) - # Clean up history for removed service - self._cpu_history.pop(service_name, None) - self._prev_cpu.pop(service_name, None) - log.debug("Removed service from stats collector: %s", service_name) - - async def stop(self) -> None: - """Stop collecting stats.""" - self._running = False - if self._task: - self._task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await self._task - self._task = None - - -def render_sparkline_svg( - values: list[float], - width: int = 100, - height: int = 20, - stroke_color: str = "#4ade80", - fill_color: str = "rgba(74, 222, 128, 0.2)", -) -> str: - """Render a list of values as an SVG sparkline. - - Args: - values: List of values to plot (0-100 range expected for CPU %) - width: SVG width in pixels - height: SVG height in pixels - stroke_color: Line color - fill_color: Fill color under the line - - Returns: - SVG string - """ - if not values: - # Empty placeholder - return f'' - - # Prepend zero to start sparkline from baseline - values = [0.0, *values] - - # Normalize values to 0-1 range - max_val = max(values) if max(values) > 0 else 1 - normalized = [v / max_val for v in values] - - # Calculate points - points = [] - x_step = width / max(len(values) - 1, 1) - for i, v in enumerate(normalized): - x = i * x_step - y = height - (v * (height - 2)) - 1 # Leave 1px margin - points.append(f"{x:.1f},{y:.1f}") - - path_line = " ".join(points) - - # Create filled area path (line + close to bottom) - fill_points = [*points, f"{width},{height}", f"0,{height}"] - path_fill = " ".join(fill_points) - - svg = f''' - - -''' - return svg diff --git a/src/webterm/docker_watcher.py b/src/webterm/docker_watcher.py deleted file mode 100644 index 00af317..0000000 --- a/src/webterm/docker_watcher.py +++ /dev/null @@ -1,329 +0,0 @@ -"""Docker container event watcher for dynamic session management. - -Watches Docker events and creates/removes terminal sessions for containers -with the 'webterm-command' label. -""" - -from __future__ import annotations - -import asyncio -import contextlib -import json -import logging -import os -from typing import TYPE_CHECKING, Callable - -from .docker_stats import get_docker_socket_path - -if TYPE_CHECKING: - from .session_manager import SessionManager - -log = logging.getLogger("webterm") - -LABEL_NAME = "webterm-command" -THEME_LABEL = "webterm-theme" -# All labels that trigger container inclusion -WEBTERM_LABELS = (LABEL_NAME, THEME_LABEL) -AUTO_COMMAND_ENV = "WEBTERM_DOCKER_AUTO_COMMAND" -DEFAULT_COMMAND = "/bin/bash" -AUTO_COMMAND_SENTINEL = "__docker_exec__" - - -def _has_webterm_label(attributes: dict) -> bool: - """Check if a container has any webterm label.""" - return any(label in attributes for label in WEBTERM_LABELS) - - -def _get_auto_command() -> str: - return os.environ.get(AUTO_COMMAND_ENV, DEFAULT_COMMAND) - - -def _is_auto_label(value: str | None) -> bool: - if value is None: - return True - stripped = value.strip() - return stripped == "" or stripped.lower() == "auto" - - -class DockerWatcher: - """Watch Docker events and manage terminal sessions dynamically.""" - - def __init__( - self, - session_manager: SessionManager, - on_container_added: Callable[[str, str, str], None] | None = None, - on_container_removed: Callable[[str], None] | None = None, - socket_path: str | None = None, - ) -> None: - """Initialize Docker watcher. - - Args: - session_manager: Session manager for adding/removing apps. - on_container_added: Callback(slug, name, command) when container is added. - on_container_removed: Callback(slug) when container is removed. - socket_path: Docker socket path (default: /var/run/docker.sock). - """ - self._session_manager = session_manager - self._on_container_added = on_container_added - self._on_container_removed = on_container_removed - self._socket_path = socket_path or get_docker_socket_path() - self._running = False - self._task: asyncio.Task | None = None - # Track containers we're managing: slug -> container_id - self._managed_containers: dict[str, str] = {} - - async def _docker_request(self, method: str, path: str) -> tuple[int, str]: - """Make HTTP request to Docker socket. - - Returns: - Tuple of (status_code, body). - """ - reader, writer = await asyncio.open_unix_connection(self._socket_path) - try: - request = f"{method} {path} HTTP/1.1\r\nHost: localhost\r\n\r\n" - writer.write(request.encode()) - await writer.drain() - - # Read status line - status_line = await reader.readline() - status_line_text = status_line.decode("utf-8", errors="replace") - status_code = int(status_line_text.split()[1]) - - # Read headers - content_length = 0 - chunked = False - while True: - line = await reader.readline() - if line == b"\r\n": - break - header = line.decode("utf-8", errors="replace").lower() - if header.startswith("content-length:"): - content_length = int(header.split(":")[1].strip()) - if "transfer-encoding: chunked" in header: - chunked = True - - # Read body - if chunked: - body_parts = [] - while True: - size_line = await reader.readline() - size = int(size_line.decode("utf-8", errors="replace").strip(), 16) - if size == 0: - break - chunk = await reader.readexactly(size) - body_parts.append(chunk) - await reader.readline() # trailing CRLF - body = b"".join(body_parts).decode("utf-8", errors="replace") - elif content_length > 0: - body = (await reader.readexactly(content_length)).decode("utf-8", errors="replace") - else: - body = "" - - return status_code, body - finally: - writer.close() - await writer.wait_closed() - - async def _get_labeled_containers(self) -> list[dict]: - """Get all running containers with any webterm label. - - Queries for both webterm-command and webterm-theme labels, - merging results and deduplicating by container ID. - """ - seen_ids: set[str] = set() - result: list[dict] = [] - - for label in WEBTERM_LABELS: - path = f'/containers/json?filters={{"label":["{label}"]}}' - status, body = await self._docker_request("GET", path) - if status != 200: - log.error("Failed to list containers for label %s: %s", label, body) - continue - for container in json.loads(body): - container_id = container.get("Id", "") - if container_id and container_id not in seen_ids: - seen_ids.add(container_id) - result.append(container) - - return result - - def _get_container_command(self, container: dict) -> str: - """Get command for container from label. - - If label is 'auto', empty, or missing, returns default exec command. - """ - labels = container.get("Labels") or {} - label_value = labels.get(LABEL_NAME) - - if _is_auto_label(label_value): - return AUTO_COMMAND_SENTINEL - return label_value or AUTO_COMMAND_SENTINEL - - def _get_container_theme(self, container: dict) -> str | None: - labels = container.get("Labels") or {} - value = labels.get(THEME_LABEL) - if isinstance(value, str) and value.strip(): - return value.strip() - return None - - def _get_container_name(self, container: dict) -> str: - """Get container name (without leading /).""" - names = container.get("Names", []) - if names: - return names[0].lstrip("/") - return container.get("Id", "unknown")[:12] - - def _container_to_slug(self, container: dict) -> str: - """Convert container to URL slug.""" - return self._get_container_name(container).replace("_", "-").replace(".", "-") - - async def _add_container(self, container: dict) -> None: - """Add a container as a terminal session.""" - slug = self._container_to_slug(container) - name = self._get_container_name(container) - command = self._get_container_command(container) - theme = self._get_container_theme(container) - container_id = container.get("Id", "") - - if slug in self._managed_containers: - log.debug("Container %s already managed", name) - return - - log.info("Adding container: %s (slug=%s, cmd=%s)", name, slug, command) - self._managed_containers[slug] = container_id - self._session_manager.add_app(name, command, slug, terminal=True, theme=theme) - - if self._on_container_added: - self._on_container_added(slug, name, command) - - async def _remove_container(self, container_id: str) -> None: - """Remove a container's terminal session.""" - # Find slug by container_id - slug = None - for s, cid in list(self._managed_containers.items()): - if cid == container_id or cid.startswith(container_id): - slug = s - break - - if not slug: - return - - log.info("Removing container: %s", slug) - del self._managed_containers[slug] - - # Close any active session for this slug before removing the app, - # so session cleanup can still look up the app if needed. - route_key = slug - session = self._session_manager.get_session_by_route_key(route_key) - if session: - session_id = self._session_manager.routes.get(route_key) - if session_id: - await self._session_manager.close_session(session_id) - - # Remove from session manager's apps - if slug in self._session_manager.apps_by_slug: - app = self._session_manager.apps_by_slug.pop(slug) - if app in self._session_manager.apps: - self._session_manager.apps.remove(app) - - if self._on_container_removed: - self._on_container_removed(slug) - - async def _watch_events(self) -> None: - """Watch Docker events stream.""" - filters = json.dumps({"event": ["start", "die"], "type": ["container"]}) - path = f"/events?filters={filters}" - - while self._running: - try: - reader, writer = await asyncio.open_unix_connection(self._socket_path) - try: - request = f"GET {path} HTTP/1.1\r\nHost: localhost\r\n\r\n" - writer.write(request.encode()) - await writer.drain() - - # Skip HTTP headers - while True: - line = await reader.readline() - if line == b"\r\n": - break - - # Read event stream (chunked encoding) - while self._running: - size_line = await reader.readline() - if not size_line: - break - try: - size = int(size_line.decode("utf-8", errors="replace").strip(), 16) - except ValueError: - continue - if size == 0: - break - - chunk = await reader.readexactly(size) - await reader.readline() # trailing CRLF - - try: - event = json.loads(chunk.decode("utf-8", errors="replace")) - await self._handle_event(event) - except json.JSONDecodeError: - continue - finally: - writer.close() - await writer.wait_closed() - except Exception as e: - if self._running: - log.warning("Docker event stream error: %s, reconnecting...", e) - await asyncio.sleep(5) - - async def _handle_event(self, event: dict) -> None: - """Handle a Docker event.""" - action = event.get("Action", "") - actor = event.get("Actor", {}) - container_id = actor.get("ID", "") - if action == "start": - # Get full container info - status, body = await self._docker_request("GET", f"/containers/{container_id}/json") - if status == 200: - container_info = json.loads(body) - # Convert to list format expected by _add_container - # Labels can be None if container has no labels - labels = container_info.get("Config", {}).get("Labels") or {} - container = { - "Id": container_id, - "Names": ["/" + container_info.get("Name", "").lstrip("/")], - "Labels": labels, - } - if _has_webterm_label(labels): - await self._add_container(container) - elif action == "die": - await self._remove_container(container_id) - - async def scan_existing(self) -> None: - """Scan for existing labeled containers and add them.""" - containers = await self._get_labeled_containers() - for container in containers: - await self._add_container(container) - log.info("Found %d existing containers with %s label", len(containers), LABEL_NAME) - - async def start(self) -> None: - """Start watching Docker events.""" - if self._running: - return - - self._running = True - # First scan existing containers - await self.scan_existing() - # Then start watching for new events - self._task = asyncio.create_task(self._watch_events()) - log.info("Docker watcher started") - - async def stop(self) -> None: - """Stop watching Docker events.""" - self._running = False - if self._task: - self._task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await self._task - self._task = None - log.info("Docker watcher stopped") diff --git a/src/webterm/exit_poller.py b/src/webterm/exit_poller.py deleted file mode 100644 index f1a6dc6..0000000 --- a/src/webterm/exit_poller.py +++ /dev/null @@ -1,53 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -from time import monotonic -from typing import TYPE_CHECKING - -EXIT_POLL_RATE = 5 - -log = logging.getLogger("webterm") - -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/webterm/identity.py b/src/webterm/identity.py deleted file mode 100644 index b536e29..0000000 --- a/src/webterm/identity.py +++ /dev/null @@ -1,11 +0,0 @@ -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/webterm/local_server.py b/src/webterm/local_server.py deleted file mode 100644 index eb37ae2..0000000 --- a/src/webterm/local_server.py +++ /dev/null @@ -1,1655 +0,0 @@ -"""Local server implementation for serving terminals over HTTP/WebSocket.""" - -from __future__ import annotations - -import asyncio -import contextlib -import hashlib -import json -import logging -import re -import signal -import time -from pathlib import Path -from typing import TYPE_CHECKING - -import aiohttp -from aiohttp import WSMsgType, web - -from . import constants -from .docker_stats import DockerStatsCollector, render_sparkline_svg -from .docker_watcher import AUTO_COMMAND_SENTINEL -from .exit_poller import ExitPoller -from .identity import generate -from .poller import Poller -from .session import SessionConnector -from .session_manager import SessionManager -from .svg_exporter import render_terminal_svg -from .types import Meta, RouteKey, SessionID - -# Pattern to filter terminal device attribute responses (DA1/DA2/DA3) from replay buffer. -# These responses can appear as visible text like "1;10;0c" if split across reads. -# Matches: \x1b[?...c (DA1), \x1b[>...c (DA2 from tmux), \x1b[=...c (DA3) -# See docker_exec_session.py and terminal_session.py for main filtering. -DA_RESPONSE_PATTERN = re.compile(rb"\x1b\[[?>=][\d;]*c") - -if TYPE_CHECKING: - from .config import Config - -log = logging.getLogger("webterm") - -DEFAULT_TERMINAL_SIZE = (132, 45) - -SCREENSHOT_CACHE_SECONDS = 0.3 -SCREENSHOT_MAX_CACHE_SECONDS = 20.0 -SCREENSHOT_FORCE_REDRAW = constants.get_environ_bool(constants.SCREENSHOT_FORCE_REDRAW_ENV) -WS_SEND_QUEUE_MAX = 256 -WS_SEND_TIMEOUT = 2.0 -STDIN_WRITE_TIMEOUT = 2.0 - - -WEBTERM_STATIC_PATH = Path(__file__).parent / "static" - -# Theme background colors - must match terminal.ts THEMES -THEME_BACKGROUNDS: dict[str, str] = { - "tango": "#000000", - "xterm": "#000000", - "monokai": "#2d2a2e", - "ristretto": "#2d2525", - "dark": "#1e1e1e", - "light": "#ffffff", - "dracula": "#282a36", - "catppuccin": "#1e1e2e", - "nord": "#2e3440", - "gruvbox": "#282828", - "solarized": "#002b36", - "tokyo": "#1a1b26", -} - -# Theme palettes - must match terminal.ts THEMES -THEME_PALETTES: dict[str, dict[str, str]] = { - "tango": { - "background": "#000000", - "foreground": "#d3d7cf", - "black": "#2e3436", - "red": "#cc0000", - "green": "#4e9a06", - "yellow": "#c4a000", - "blue": "#3465a4", - "magenta": "#75507b", - "cyan": "#06989a", - "white": "#d3d7cf", - "brightblack": "#555753", - "brightred": "#ef2929", - "brightgreen": "#8ae234", - "brightyellow": "#fce94f", - "brightblue": "#729fcf", - "brightmagenta": "#ad7fa8", - "brightcyan": "#34e2e2", - "brightwhite": "#eeeeec", - }, - "xterm": { - "background": "#000000", - "foreground": "#e5e5e5", - "black": "#000000", - "red": "#cd0000", - "green": "#00cd00", - "yellow": "#cdcd00", - "blue": "#0000cd", - "magenta": "#cd00cd", - "cyan": "#00cdcd", - "white": "#e5e5e5", - "brightblack": "#4d4d4d", - "brightred": "#ff0000", - "brightgreen": "#00ff00", - "brightyellow": "#ffff00", - "brightblue": "#0000ff", - "brightmagenta": "#ff00ff", - "brightcyan": "#00ffff", - "brightwhite": "#ffffff", - }, - "monokai": { - "background": "#2d2a2e", - "foreground": "#fcfcfa", - "black": "#403e41", - "red": "#ff6188", - "green": "#a9dc76", - "yellow": "#ffd866", - "blue": "#fc9867", - "magenta": "#ab9df2", - "cyan": "#78dce8", - "white": "#fcfcfa", - "brightblack": "#727072", - "brightred": "#ff6188", - "brightgreen": "#a9dc76", - "brightyellow": "#ffd866", - "brightblue": "#fc9867", - "brightmagenta": "#ab9df2", - "brightcyan": "#78dce8", - "brightwhite": "#fcfcfa", - }, - "ristretto": { - "background": "#2d2525", - "foreground": "#fff1f3", - "black": "#2c2525", - "red": "#fd6883", - "green": "#adda78", - "yellow": "#f9cc6c", - "blue": "#f38d70", - "magenta": "#a8a9eb", - "cyan": "#85dacc", - "white": "#f9f8f5", - "brightblack": "#655761", - "brightred": "#fd6883", - "brightgreen": "#adda78", - "brightyellow": "#f9cc6c", - "brightblue": "#f38d70", - "brightmagenta": "#a8a9eb", - "brightcyan": "#85dacc", - "brightwhite": "#f9f8f5", - }, - "dark": { - "background": "#1e1e1e", - "foreground": "#d4d4d4", - "black": "#000000", - "red": "#cd3131", - "green": "#0dbc79", - "yellow": "#e5e510", - "blue": "#2472c8", - "magenta": "#bc3fbc", - "cyan": "#11a8cd", - "white": "#e5e5e5", - "brightblack": "#666666", - "brightred": "#f14c4c", - "brightgreen": "#23d18b", - "brightyellow": "#f5f543", - "brightblue": "#3b8eea", - "brightmagenta": "#d670d6", - "brightcyan": "#29b8db", - "brightwhite": "#ffffff", - }, - "light": { - "background": "#ffffff", - "foreground": "#383a42", - "black": "#000000", - "red": "#e45649", - "green": "#50a14f", - "yellow": "#c18401", - "blue": "#4078f2", - "magenta": "#a626a4", - "cyan": "#0184bc", - "white": "#a0a1a7", - "brightblack": "#5c6370", - "brightred": "#e06c75", - "brightgreen": "#98c379", - "brightyellow": "#d19a66", - "brightblue": "#61afef", - "brightmagenta": "#c678dd", - "brightcyan": "#56b6c2", - "brightwhite": "#ffffff", - }, - "dracula": { - "background": "#282a36", - "foreground": "#f8f8f2", - "black": "#21222c", - "red": "#ff5555", - "green": "#50fa7b", - "yellow": "#f1fa8c", - "blue": "#bd93f9", - "magenta": "#ff79c6", - "cyan": "#8be9fd", - "white": "#f8f8f2", - "brightblack": "#6272a4", - "brightred": "#ff6e6e", - "brightgreen": "#69ff94", - "brightyellow": "#ffffa5", - "brightblue": "#d6acff", - "brightmagenta": "#ff92df", - "brightcyan": "#a4ffff", - "brightwhite": "#ffffff", - }, - "catppuccin": { - "background": "#1e1e2e", - "foreground": "#cdd6f4", - "black": "#45475a", - "red": "#f38ba8", - "green": "#a6e3a1", - "yellow": "#f9e2af", - "blue": "#89b4fa", - "magenta": "#f5c2e7", - "cyan": "#94e2d5", - "white": "#bac2de", - "brightblack": "#585b70", - "brightred": "#f38ba8", - "brightgreen": "#a6e3a1", - "brightyellow": "#f9e2af", - "brightblue": "#89b4fa", - "brightmagenta": "#f5c2e7", - "brightcyan": "#94e2d5", - "brightwhite": "#a6adc8", - }, - "nord": { - "background": "#2e3440", - "foreground": "#d8dee9", - "black": "#3b4252", - "red": "#bf616a", - "green": "#a3be8c", - "yellow": "#ebcb8b", - "blue": "#81a1c1", - "magenta": "#b48ead", - "cyan": "#88c0d0", - "white": "#e5e9f0", - "brightblack": "#4c566a", - "brightred": "#bf616a", - "brightgreen": "#a3be8c", - "brightyellow": "#ebcb8b", - "brightblue": "#81a1c1", - "brightmagenta": "#b48ead", - "brightcyan": "#8fbcbb", - "brightwhite": "#eceff4", - }, - "gruvbox": { - "background": "#282828", - "foreground": "#ebdbb2", - "black": "#282828", - "red": "#cc241d", - "green": "#98971a", - "yellow": "#d79921", - "blue": "#458588", - "magenta": "#b16286", - "cyan": "#689d6a", - "white": "#a89984", - "brightblack": "#928374", - "brightred": "#fb4934", - "brightgreen": "#b8bb26", - "brightyellow": "#fabd2f", - "brightblue": "#83a598", - "brightmagenta": "#d3869b", - "brightcyan": "#8ec07c", - "brightwhite": "#ebdbb2", - }, - "solarized": { - "background": "#002b36", - "foreground": "#839496", - "black": "#073642", - "red": "#dc322f", - "green": "#859900", - "yellow": "#b58900", - "blue": "#268bd2", - "magenta": "#d33682", - "cyan": "#2aa198", - "white": "#eee8d5", - "brightblack": "#586e75", - "brightred": "#cb4b16", - "brightgreen": "#586e75", - "brightyellow": "#657b83", - "brightblue": "#839496", - "brightmagenta": "#6c71c4", - "brightcyan": "#93a1a1", - "brightwhite": "#fdf6e3", - }, - "tokyo": { - "background": "#1a1b26", - "foreground": "#a9b1d6", - "black": "#15161e", - "red": "#f7768e", - "green": "#9ece6a", - "yellow": "#e0af68", - "blue": "#7aa2f7", - "magenta": "#bb9af7", - "cyan": "#7dcfff", - "white": "#a9b1d6", - "brightblack": "#414868", - "brightred": "#f7768e", - "brightgreen": "#9ece6a", - "brightyellow": "#e0af68", - "brightblue": "#7aa2f7", - "brightmagenta": "#bb9af7", - "brightcyan": "#7dcfff", - "brightwhite": "#c0caf5", - }, -} - - -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: - self.server.mark_route_activity(str(self.route_key)) - 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) - - -def _format_command_label(command: str) -> str: - """Format command for display in UI, replacing sentinel with readable label.""" - if command == AUTO_COMMAND_SENTINEL: - return "" - return command - - -class LocalServer: - def mark_route_activity(self, route_key: str) -> None: - try: - now = asyncio.get_running_loop().time() - except RuntimeError: - now = time.monotonic() - self._route_last_activity[route_key] = now - # Throttle SSE notifications - max once per 250ms per route - last_notified = self._route_last_sse_notification.get(route_key, 0.0) - if now - last_notified >= 0.25: - self._route_last_sse_notification[route_key] = now - self._notify_activity(route_key) - - def _notify_activity(self, route_key: str) -> None: - """Notify SSE subscribers that a route has activity.""" - for queue in self._sse_subscribers: - with contextlib.suppress(asyncio.QueueFull): - queue.put_nowait(route_key) - - def _get_cached_screenshot_response( - self, request: web.Request, route_key: str - ) -> web.Response | None: - cached = self._screenshot_cache.get(route_key) - if cached is None: - return None - - etag = self._screenshot_cache_etag.get(route_key) - if etag and request.headers.get("If-None-Match") == etag: - raise web.HTTPNotModified(headers={"ETag": etag, "Cache-Control": "no-cache"}) - - headers = {"Cache-Control": "no-cache"} - if etag: - headers["ETag"] = etag - return web.Response(text=cached[1], content_type="image/svg+xml", headers=headers) - - def _get_screenshot_cache_ttl(self, route_key: str, now: float) -> float: - last_activity = self._route_last_activity.get(route_key, 0.0) - idle_for = max(0.0, now - last_activity) - - # Active sessions refresh quickly; idle sessions back off aggressively. - if idle_for < 3.0: - return SCREENSHOT_CACHE_SECONDS - if idle_for < 15.0: - return 2.0 - if idle_for < 120.0: - return 5.0 - return SCREENSHOT_MAX_CACHE_SECONDS - - """Manages local terminal sessions 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, - compose_mode: bool = False, - compose_project: str | None = None, - docker_watch_mode: bool = False, - theme: str = "xterm", - font_family: str | None = None, - font_size: int = 16, - ) -> None: - self.host = host - self.port = port - self.theme = theme - self.font_family = font_family - self.font_size = font_size - - 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._ws_send_queues: dict[RouteKey, asyncio.Queue[bytes | None]] = {} - self._ws_send_tasks: dict[RouteKey, asyncio.Task] = {} - self._landing_apps = landing_apps or [] - self._compose_mode = compose_mode - self._compose_project = compose_project - self._docker_watch_mode = docker_watch_mode - - self._screenshot_cache: dict[str, tuple[float, str]] = {} - self._screenshot_cache_etag: dict[str, str] = {} - self._screenshot_locks: dict[str, asyncio.Lock] = {} - self._route_last_activity: dict[str, float] = {} - self._route_last_sse_notification: dict[str, float] = {} - - # SSE subscribers for activity notifications - self._sse_subscribers: list[asyncio.Queue[str]] = [] - - # Background tasks for fire-and-forget stdin writes (prevent GC) - self._stdin_tasks: set[asyncio.Task] = set() - - # Docker stats collector (only used in compose mode) - self._docker_stats: DockerStatsCollector | None = None - # Docker watcher (only used in docker watch mode) - self._docker_watcher = None - self._slug_to_service: dict[str, str] = {} - - @property - def app_count(self) -> int: - return len(self.session_manager.apps) - - def add_app(self, name: str, command: str, slug: str = "", theme: str | None = None) -> None: - slug = slug or generate().lower() - self.session_manager.add_app(name, command, slug=slug, theme=theme) - - def add_terminal( - self, name: str, command: str, slug: str = "", theme: str | None = None - ) -> None: - if constants.WINDOWS: - log.warning("Sorry, webterm does not currently support terminals on Windows") - return - slug = slug or generate().lower() - self.session_manager.add_app(name, command, slug=slug, terminal=True, theme=theme) - - def _track_stdin_task(self, task: asyncio.Task, route_key: str) -> None: - self._stdin_tasks.add(task) - task.add_done_callback(lambda done: self._finalize_stdin_task(done, route_key)) - - def _finalize_stdin_task(self, task: asyncio.Task, route_key: str) -> None: - self._stdin_tasks.discard(task) - if task.cancelled(): - return - exc = task.exception() - if exc: - log.warning("Stdin write task failed for route %s: %s", route_key, exc) - - 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("/cpu-sparkline.svg", self._handle_cpu_sparkline), - web.get("/events", self._handle_sse), - web.get("/health", self._handle_health_check), - web.get("/tiles", self._handle_tiles), - web.get("/", self._handle_root), - ] - - if WEBTERM_STATIC_PATH.exists(): - routes.append(web.static("/static", WEBTERM_STATIC_PATH)) - log.info("Static assets served from: %s", WEBTERM_STATIC_PATH) - else: - log.error( - "Static assets not found at %s - terminal UI will not work", WEBTERM_STATIC_PATH - ) - - return routes - - async def _shutdown(self) -> None: - # Set exit event first so main loop exits immediately - self.exit_event.set() - - # Clean up resources with timeout (best effort, don't block exit) - async def cleanup() -> None: - for ws in list(self._websocket_connections.values()): - with contextlib.suppress(Exception): - await ws.close() - with contextlib.suppress(Exception): - await self.session_manager.close_all() - - try: - await asyncio.wait_for(cleanup(), timeout=3.0) - except TimeoutError: - log.warning("Shutdown timed out, forcing exit") - - async def _run_local_server(self) -> None: - app = web.Application() - app.add_routes(self._build_routes()) - - runner = web.AppRunner(app) - async with contextlib.AsyncExitStack() as stack: - await runner.setup() - stack.push_async_callback(runner.cleanup) - - # Start Docker stats collector in compose mode or docker watch mode - if (self._compose_mode and self._landing_apps) or self._docker_watch_mode: - self._docker_stats = DockerStatsCollector(compose_project=self._compose_project) - if self._docker_stats.available: - # Pass service names (not slugs) for Docker matching - service_names = [ - app.name - for app in ( - self._landing_apps if self._compose_mode else self.session_manager.apps - ) - ] - self._docker_stats.start(service_names) - # Create slug->name mapping for lookups - self._slug_to_service = { - app.slug: app.name - for app in ( - self._landing_apps if self._compose_mode else self.session_manager.apps - ) - } - log.info("Slug to service mapping: %s", self._slug_to_service) - stack.push_async_callback(self._docker_stats.stop) - - # Start Docker watcher in docker watch mode - if self._docker_watch_mode: - from .docker_watcher import DockerWatcher - - self._docker_watcher = DockerWatcher( - self.session_manager, - on_container_added=self._on_docker_container_added, - on_container_removed=self._on_docker_container_removed, - ) - await self._docker_watcher.start() - stack.push_async_callback(self._docker_watcher.stop) - - site = web.TCPSite(runner, self.host, self.port) - await site.start() - - log.info("Local server started on %s:%s", self.host, self.port) - if self._docker_watch_mode: - log.info("Docker watch mode: sessions added dynamically from labeled containers") - else: - log.info( - "Available apps: %s", ", ".join(app.name for app in self.session_manager.apps) - ) - - await self.exit_event.wait() - - def _on_docker_container_added(self, slug: str, name: str, command: str) -> None: - """Callback when a Docker container is added.""" - log.info("Container added to dashboard: %s -> %s", name, slug) - # Update slug-to-service mapping for sparklines - self._slug_to_service[slug] = name - # Register new service with stats collector so it starts polling - if self._docker_stats: - self._docker_stats.add_service(name) - log.debug("Added sparkline mapping: %s -> %s", slug, name) - # Notify SSE subscribers about dashboard change - self._notify_activity("__dashboard__") - - def _on_docker_container_removed(self, slug: str) -> None: - """Callback when a Docker container is removed.""" - log.info("Container removed from dashboard: %s", slug) - # Remove from stats collector and slug mapping - service_name = self._slug_to_service.pop(slug, None) - if self._docker_stats and service_name: - self._docker_stats.remove_service(service_name) - # Invalidate any cached screenshots - self._screenshot_cache.pop(slug, None) - self._screenshot_cache_etag.pop(slug, None) - # Notify SSE subscribers about dashboard change - self._notify_activity("__dashboard__") - - async def _handle_stdin( - self, envelope: list, route_key: str, _ws: web.WebSocketResponse - ) -> None: - self.mark_route_activity(route_key) - data = envelope[1] if len(envelope) > 1 else "" - session_process = self.session_manager.get_session_by_route_key(RouteKey(route_key)) - if session_process: - # Fire-and-forget: don't block the WS receive loop waiting for the - # PTY fd to become writable. A slow or stalled child process must - # never prevent subsequent keystrokes from being dispatched. - task = asyncio.create_task(self._write_stdin(session_process, data, route_key)) - self._track_stdin_task(task, route_key) - - async def _write_stdin(self, session_process, data: str, route_key: str) -> None: - """Write stdin data to session with a timeout to avoid indefinite stalls.""" - try: - await asyncio.wait_for( - session_process.send_bytes(data.encode("utf-8")), - timeout=STDIN_WRITE_TIMEOUT, - ) - except asyncio.TimeoutError: - log.warning("Stdin write timeout for route %s; dropping input", route_key) - except OSError as exc: - log.warning("Stdin write failed for route %s: %s", route_key, exc) - - async def _handle_resize( - self, envelope: list, route_key: str, _ws: web.WebSocketResponse - ) -> bool: - """Handle resize message. Returns True if a new session was created.""" - self.mark_route_activity(route_key) - 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)))) - - session_process = self.session_manager.get_session_by_route_key(RouteKey(route_key)) - if session_process is None: - await self._create_terminal_session(route_key, width, height) - return True - await session_process.set_terminal_size(width, height) - # Invalidate screenshot cache on resize - content needs to re-render - self._screenshot_cache.pop(route_key, None) - self._screenshot_cache_etag.pop(route_key, None) - return False - - async def _handle_ping( - self, envelope: list, _route_key: str, ws: web.WebSocketResponse - ) -> None: - data = envelope[1] if len(envelope) > 1 else "" - await ws.send_json(["pong", data]) - - 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": - await self._handle_stdin(envelope, route_key, ws) - elif msg_type == "resize": - if not session_created: - session_created = await self._handle_resize(envelope, route_key, ws) - else: - await self._handle_resize(envelope, route_key, ws) - elif msg_type == "ping": - await self._handle_ping(envelope, route_key, ws) - - return session_created - - 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 - queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=WS_SEND_QUEUE_MAX) - self._ws_send_queues[route_key] = queue - self._ws_send_tasks[route_key] = asyncio.create_task(self._ws_sender(route_key, ws, queue)) - - session_id = self.session_manager.routes.get(RouteKey(route_key)) - session = None - 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 = None - - session_created = session_id is not None - - if session_created and session is not None and hasattr(session, "get_replay_buffer"): - replay = await session.get_replay_buffer() - if replay: - # Filter out any DA1/DA2 responses that may have been captured - # in the replay buffer before filtering was added to session classes - replay = DA_RESPONSE_PATTERN.sub(b"", replay) - if replay: - await ws.send_bytes(replay) - - 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 json.JSONDecodeError as e: - log.warning("Invalid JSON in WebSocket message: %s", e) - except (TypeError, KeyError, ValueError) as e: - log.warning("Malformed WebSocket message: %s", e) - except OSError as e: - log.error("I/O 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._stop_ws_sender(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: - # Create session with default dimensions - await self._create_terminal_session( - route_key, - width=DEFAULT_TERMINAL_SIZE[0], - height=DEFAULT_TERMINAL_SIZE[1], - ) - session_process = self.session_manager.get_session_by_route_key(RouteKey(route_key)) - # Give the session a moment to start and produce initial output - if session_process is not None: - await asyncio.sleep(0.5) - - if session_process is None or not hasattr(session_process, "get_screen_snapshot"): - raise web.HTTPNotFound(text="Session not found") - - # Get the actual screen state from the terminal session's pyte screen - cached = self._screenshot_cache.get(route_key) - - lock = self._screenshot_locks.get(route_key) - if lock is None: - lock = asyncio.Lock() - self._screenshot_locks[route_key] = lock - - async with lock: - now = asyncio.get_event_loop().time() - ttl = self._get_screenshot_cache_ttl(route_key, now) - cached = self._screenshot_cache.get(route_key) - if cached is not None and (now - cached[0]) < ttl: - cached_response = self._get_cached_screenshot_response(request, route_key) - if cached_response is not None: - return cached_response - - if SCREENSHOT_FORCE_REDRAW and hasattr(session_process, "force_redraw"): - await session_process.force_redraw() # type: ignore[union-attr] - - # Use non-mutating snapshot method to avoid affecting terminal state - ( - screen_width, - screen_height, - screen_buffer, - has_changes, - ) = await session_process.get_screen_snapshot() # type: ignore[union-attr] - - if not has_changes and cached is not None: - cached_response = self._get_cached_screenshot_response(request, route_key) - if cached_response is not None: - return cached_response - - app = self.session_manager.apps_by_slug.get(route_key) - theme_name = app.theme.lower() if app is not None and app.theme else self.theme.lower() - - palette = THEME_PALETTES.get(theme_name) - if palette is None: - palette = THEME_PALETTES.get("xterm") - - background = palette.get("background", THEME_BACKGROUNDS.get("xterm", "#000000")) - foreground = palette.get("foreground", "#e5e5e5") - - def _render_svg() -> str: - # Use custom SVG exporter - simpler and more reliable than Rich - return render_terminal_svg( - screen_buffer, - width=screen_width, - height=screen_height, - title="webterm", - background=background, - foreground=foreground, - palette=palette, - ) - - svg = await asyncio.to_thread(_render_svg) - etag = hashlib.sha1(svg.encode("utf-8"), usedforsecurity=False).hexdigest() - self._screenshot_cache[route_key] = (asyncio.get_event_loop().time(), svg) - self._screenshot_cache_etag[route_key] = etag - headers = {"Cache-Control": "no-cache", "ETag": etag} - return web.Response(text=svg, content_type="image/svg+xml", headers=headers) - - async def _handle_cpu_sparkline(self, request: web.Request) -> web.Response: - """Return CPU sparkline SVG for a container.""" - container = request.query.get("container", "") - if not container: - raise web.HTTPBadRequest(text="Missing container parameter") - - # Get dimensions from query params - try: - width = int(request.query.get("width", "100")) - except ValueError: - width = 100 - width = max(50, min(300, width)) - - try: - height = int(request.query.get("height", "20")) - except ValueError: - height = 20 - height = max(10, min(100, height)) - - # Get CPU history - map slug to service name if needed - values: list[float] = [] - if self._docker_stats: - # Container param is slug, but stats are stored by service name - service_name = self._slug_to_service.get(container, container) - values = self._docker_stats.get_cpu_history(service_name) - - svg = render_sparkline_svg(values, width=width, height=height) - headers = {"Cache-Control": "no-cache, max-age=0"} - return web.Response(text=svg, content_type="image/svg+xml", headers=headers) - - async def _handle_sse(self, request: web.Request) -> web.StreamResponse: - """Server-Sent Events endpoint for activity notifications.""" - response = web.StreamResponse( - status=200, - reason="OK", - headers={ - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - "Connection": "keep-alive", - }, - ) - await response.prepare(request) - - # Create queue for this subscriber - queue: asyncio.Queue[str] = asyncio.Queue(maxsize=100) - self._sse_subscribers.append(queue) - - try: - while True: - try: - # Wait for activity with timeout for keepalive - route_key = await asyncio.wait_for(queue.get(), timeout=30.0) - # Send activity event - await response.write(f"event: activity\ndata: {route_key}\n\n".encode()) - except asyncio.TimeoutError: - # Send keepalive comment - await response.write(b": keepalive\n\n") - except ( - ConnectionResetError, - ConnectionAbortedError, - aiohttp.ClientConnectionError, - ): - break - finally: - self._sse_subscribers.remove(queue) - - return response - - 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.""" - - # Extract forwarded headers (take first value if comma-separated) - def first_header(name: str) -> str: - return request.headers.get(name, "").split(",")[0].strip().lower() - - forwarded_proto = first_header("X-Forwarded-Proto") - forwarded_host = first_header("X-Forwarded-Host") - forwarded_port = first_header("X-Forwarded-Port") - - # Determine WebSocket protocol - if forwarded_proto in ("https", "wss"): - ws_proto = "wss" - elif forwarded_proto in ("http", "ws"): - ws_proto = "ws" - else: - ws_proto = "wss" if request.secure else "ws" - - # Determine host and port (priority: forwarded > Host header > server config) - ws_host, ws_port = "", "" - for candidate in (forwarded_host, request.headers.get("Host", "")): - if candidate: - ws_host, _, ws_port = candidate.rpartition(":") - if not ws_host: # No colon found, entire string is host - ws_host, ws_port = candidate, "" - break - - if not ws_host: - ws_host = "localhost" if self.host == "0.0.0.0" else self.host - ws_port = str(self.port) - - ws_port = ws_port or forwarded_port - - # Include port in URL only for non-standard ports - 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_tiles(self, request: web.Request) -> web.Response: - """Return current tiles as JSON (for dynamic dashboard updates).""" - if self._docker_watch_mode: - apps_for_dashboard = self.session_manager.apps - else: - apps_for_dashboard = self._landing_apps - - tiles = [ - {"slug": app.slug, "name": app.name, "command": _format_command_label(app.command)} - for app in apps_for_dashboard - ] - return web.json_response(tiles) - - async def _handle_root(self, request: web.Request) -> web.Response: - route_key_param = request.query.get("route_key") - - # Show dashboard if we have landing apps, are in docker watch mode, or explicitly have apps - show_dashboard = (self._landing_apps or self._docker_watch_mode) and not route_key_param - - if show_dashboard: - # In docker watch mode, use session_manager.apps (dynamically updated) - # Otherwise use landing_apps - if self._docker_watch_mode: - apps_for_dashboard = self.session_manager.apps - else: - apps_for_dashboard = self._landing_apps - - tiles = [ - {"slug": app.slug, "name": app.name, "command": _format_command_label(app.command)} - for app in apps_for_dashboard - ] - tiles_json = json.dumps(tiles) - # Show CPU sparklines in both compose mode and docker watch mode - compose_mode_js = "true" if (self._compose_mode or self._docker_watch_mode) else "false" - docker_watch_js = "true" if self._docker_watch_mode else "false" - html_content = f""" - - - Session Dashboard - - - - - - - -

Sessions

-
-
-
-
-
Type to search \u2022 \u2191\u2193 to navigate \u2022 Enter to open \u2022 Esc to clear
- - -""" - 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 = """ - - - Webterm Server - - -

No Apps Available

-

No terminal 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) - page_title = available_app.name if available_app else "Webterm" - - # Build data attributes for terminal configuration - theme = available_app.theme or self.theme - data_attrs = ( - f'data-session-websocket-url="{ws_url}" data-font-size="{self.font_size}" ' - f'data-scrollback="1000" data-theme="{theme}"' - ) - font_family = self.font_family or "var(--webterm-mono)" - # Escape quotes for HTML attribute - escaped_font = font_family.replace('"', """) - data_attrs += f' data-font-family="{escaped_font}"' - - # Get theme background color (fallback to black if unknown theme) - theme_bg = THEME_BACKGROUNDS.get(theme.lower(), "#000000") - - html_content = f""" - - - {page_title} - - - - -
- - -""" - return web.Response(text=html_content, content_type="text/html") - - async def handle_session_data(self, route_key: RouteKey, data: bytes) -> None: - self.mark_route_activity(str(route_key)) - self._enqueue_ws_data(route_key, data) - - async def handle_binary_message(self, route_key: RouteKey, payload: bytes) -> None: - self.mark_route_activity(str(route_key)) - self._enqueue_ws_data(route_key, payload) - - async def handle_session_close(self, session_id: SessionID, route_key: RouteKey) -> None: - self.session_manager.on_session_end(session_id) - await self._stop_ws_sender(route_key) - 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() - - def _enqueue_ws_data(self, route_key: RouteKey, data: bytes) -> None: - queue = self._ws_send_queues.get(route_key) - if queue is None: - return - try: - queue.put_nowait(data) - except asyncio.QueueFull: - # Drop oldest data to avoid blocking terminal sessions on slow clients. - with contextlib.suppress(asyncio.QueueEmpty): - queue.get_nowait() - try: - queue.put_nowait(data) - except asyncio.QueueFull: - log.warning("WebSocket send queue full for route %s; dropping output", route_key) - - async def _ws_sender( - self, - route_key: RouteKey, - ws: web.WebSocketResponse, - queue: asyncio.Queue[bytes | None], - ) -> None: - try: - while True: - data = await queue.get() - if data is None: - break - try: - await asyncio.wait_for(ws.send_bytes(data), timeout=WS_SEND_TIMEOUT) - except asyncio.TimeoutError: - log.warning("WebSocket send timeout for route %s; closing", route_key) - break - except ( - ConnectionResetError, - ConnectionAbortedError, - aiohttp.ClientConnectionError, - ) as exc: - log.warning("WebSocket send failed for route %s: %s", route_key, exc) - break - finally: - if not ws.closed: - with contextlib.suppress(Exception): - await ws.close() - - async def _stop_ws_sender(self, route_key: RouteKey) -> None: - queue = self._ws_send_queues.pop(route_key, None) - if queue is not None: - with contextlib.suppress(asyncio.QueueFull): - queue.put_nowait(None) - task = self._ws_send_tasks.pop(route_key, None) - if task is not None: - task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await task diff --git a/src/webterm/poller.py b/src/webterm/poller.py deleted file mode 100644 index efcb9dc..0000000 --- a/src/webterm/poller.py +++ /dev/null @@ -1,161 +0,0 @@ -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) - try: - self._selector.modify(file_descriptor, selectors.EVENT_READ | selectors.EVENT_WRITE) - - except KeyError: - # File descriptor removed concurrently - - new_write.done_event.set() - - return - await new_write.done_event.wait() - - async def write_with_timeout( - self, file_descriptor: int, data: bytes, timeout: float = 2.0 - ) -> bool: - """Write data to a file descriptor with a timeout. - - Args: - file_descriptor: File descriptor. - data: Data to write. - timeout: Maximum seconds to wait for the write to complete. - - Returns: - True if the write completed, False on timeout. - """ - try: - await asyncio.wait_for(self.write(file_descriptor, data), timeout=timeout) - return True - except asyncio.TimeoutError: - return False - - 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: - # Process all pending writes while fd is writable - while 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) - break - 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: - # Partial write — fd buffer full, try again next cycle - break - 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/webterm/session.py b/src/webterm/session.py deleted file mode 100644 index 0e3dd84..0000000 --- a/src/webterm/session.py +++ /dev/null @@ -1,108 +0,0 @@ -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/webterm/session_manager.py b/src/webterm/session_manager.py deleted file mode 100644 index 4eec39f..0000000 --- a/src/webterm/session_manager.py +++ /dev/null @@ -1,222 +0,0 @@ -from __future__ import annotations - -import asyncio -import contextlib -import logging -import os -import shlex -import sys -from typing import TYPE_CHECKING - -from . import config, constants -from ._two_way_dict import TwoWayDict -from .docker_exec_session import DockerExecSession, DockerExecSpec -from .docker_watcher import AUTO_COMMAND_SENTINEL, _get_auto_command -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("webterm") - - -if not constants.WINDOWS: - from .terminal_session import TerminalSession - - -class SessionManager: - """Manage terminal sessions.""" - - 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, - theme: str | None = None, - ) -> 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, theme=theme - ) - 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: - with contextlib.suppress(KeyError): - del self.routes[route_key] - log.debug("Session %s ended", session_id) - - 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 constants.WINDOWS: - log.warning("Sorry, webterm does not currently support terminals on Windows") - return None - if app.command == AUTO_COMMAND_SENTINEL: - docker_user = os.environ.get("WEBTERM_DOCKER_USERNAME") - # Support {container} placeholder in auto command for per-container customization - # e.g., WEBTERM_DOCKER_AUTO_COMMAND="tmux new-session -ADs {container}" - auto_cmd = _get_auto_command().replace("{container}", app.name) - exec_spec = DockerExecSpec( - container=app.name, - command=shlex.split(auto_cmd), - user=docker_user, - ) - session_process = DockerExecSession(self.poller, session_id, exec_spec) - else: - session_process = TerminalSession( - self.poller, - session_id, - app.command, - ) - log.info("Created terminal session %s", session_id) - - # Open the session BEFORE registering it, so it's fully initialized - # when other code can access it via sessions/routes dicts - await session_process.open(*size) - log.debug("Session %s opened and ready", session_id) - - # Now register the fully initialized session - self.sessions[session_id] = session_process - self.routes[route_key] = session_id - - return session_process - - async def close_session(self, session_id: SessionID) -> None: - """Close a session and remove it from tracking. - - Args: - session_id: Session identity. - """ - session_process = self.sessions.get(session_id, None) - if session_process is None: - return - await session_process.close() - self.on_session_end(session_id) - - 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/webterm/slugify.py b/src/webterm/slugify.py deleted file mode 100644 index 0f388f9..0000000 --- a/src/webterm/slugify.py +++ /dev/null @@ -1,18 +0,0 @@ -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/webterm/static/__init__.py b/src/webterm/static/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/webterm/svg_exporter.py b/src/webterm/svg_exporter.py deleted file mode 100644 index 2dda53c..0000000 --- a/src/webterm/svg_exporter.py +++ /dev/null @@ -1,314 +0,0 @@ -"""Custom SVG exporter for terminal screenshots. - -Generates SVG directly from pyte screen buffer, avoiding Rich's export_svg() quirks. -""" - -from __future__ import annotations - -import html -from typing import TypedDict - -# ANSI color names to hex values (standard 16-color palette) -ANSI_COLORS: dict[str, str] = { - # Normal colors - "black": "#000000", - "red": "#cc0000", - "green": "#4e9a06", - "yellow": "#c4a000", - "blue": "#3465a4", - "magenta": "#75507b", - "cyan": "#06989a", - "white": "#d3d7cf", - # Bright colors - "brightblack": "#555753", - "brightred": "#ef2929", - "brightgreen": "#8ae234", - "brightyellow": "#fce94f", - "brightblue": "#729fcf", - "brightmagenta": "#ad7fa8", - "brightcyan": "#34e2e2", - "brightwhite": "#eeeeec", - # Alternative names - "gray": "#555753", - "grey": "#555753", - "lightgray": "#d3d7cf", - "lightgrey": "#d3d7cf", - "brown": "#c4a000", -} - -# Default colors -DEFAULT_FG = "#d3d7cf" -DEFAULT_BG = "#000000" - -# Font settings -FONT_FAMILY = ( - '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' -) -FONT_SIZE = 14 -LINE_HEIGHT = 1.2 -CHAR_WIDTH = 8 # Width of monospace character at 14px (typically ~0.57 ratio) - -# Box drawing characters that need vertical scaling to fill line height -# These are designed to connect between lines but the font's em-box is smaller -# than our line height, creating gaps -BOX_DRAWING_CHARS = frozenset( - # Light and heavy box drawing (U+2500-U+257F) - "─━│┃┄┅┆┇┈┉┊┋┌┍┎┏┐┑┒┓└┕┖┗┘┙┚┛├┝┞┟┠┡┢┣┤┥┦┧┨┩┪┫┬┭┮┯┰┱┲┳┴┵┶┷┸┹┺┻┼┽┾┿╀╁╂╃╄╅╆╇╈╉╊╋" - # Double box drawing - "═║╒╓╔╕╖╗╘╙╚╛╜╝╞╟╠╡╢╣╤╥╦╧╨╩╪╫╬" - # Rounded corners - "╭╮╯╰" - # Light and heavy dashed (U+2571-U+257F) - "\u2571\u2572\u2573╴╵╶╷╸╹╺╻╼╽╾╿" -) - - -def _is_box_drawing(char: str) -> bool: - """Check if character is a box-drawing character that needs scaling.""" - return len(char) == 1 and char in BOX_DRAWING_CHARS - - -class CharData(TypedDict): - """Character data from pyte screen buffer.""" - - data: str - fg: str - bg: str - bold: bool - italics: bool - underscore: bool - reverse: bool - - -def _normalize_palette(palette: dict[str, str]) -> dict[str, str]: - normalized = {key.lower(): value for key, value in palette.items()} - normalized.setdefault("gray", normalized.get("brightblack", ANSI_COLORS["gray"])) - normalized.setdefault("grey", normalized.get("brightblack", ANSI_COLORS["grey"])) - normalized.setdefault("lightgray", normalized.get("white", ANSI_COLORS["lightgray"])) - normalized.setdefault("lightgrey", normalized.get("white", ANSI_COLORS["lightgrey"])) - normalized.setdefault("brown", normalized.get("yellow", ANSI_COLORS["brown"])) - return normalized - - -def _color_to_hex( - color: str, - is_foreground: bool = True, - *, - palette: dict[str, str] | None = None, - default_fg: str = DEFAULT_FG, - default_bg: str = DEFAULT_BG, -) -> str: - """Convert pyte color to hex value.""" - if color == "default": - return default_fg if is_foreground else default_bg - - # Already a hex color with # - if color.startswith("#"): - return color - - # Hex color without # prefix (pyte's 256-color/truecolor format) - # Check if it looks like a hex color (6 hex digits) - if len(color) == 6 and all(c in "0123456789abcdefABCDEF" for c in color): - return f"#{color}" - - # Named color lookup (case-insensitive) - lower = color.lower() - palette_map = palette if palette is not None else ANSI_COLORS - if lower in palette_map: - return palette_map[lower] - - # RGB format "rgb(r,g,b)" - rarely used but handle it - if lower.startswith("rgb("): - # Not common in terminal output, return default - return default_fg if is_foreground else default_bg - - return default_fg if is_foreground else default_bg - - -def _escape_xml(text: str) -> str: - """Escape special XML characters.""" - return html.escape(text, quote=True) - - -def render_terminal_svg( - screen_buffer: list[list[CharData]], - width: int, - height: int, - *, - title: str = "Terminal", - font_size: int = FONT_SIZE, - char_width: float = CHAR_WIDTH, - line_height: float = LINE_HEIGHT, - background: str = DEFAULT_BG, - foreground: str = DEFAULT_FG, - palette: dict[str, str] | None = None, -) -> str: - """Render terminal screen buffer to SVG. - - Args: - screen_buffer: 2D list of CharData dicts from pyte - width: Terminal width in columns - height: Terminal height in rows - title: SVG title (for accessibility) - font_size: Font size in pixels - char_width: Width of a single character - line_height: Line height multiplier - background: Background color - foreground: Default foreground color - - Returns: - SVG string - """ - # Calculate dimensions - actual_line_height = font_size * line_height - svg_width = width * char_width + 20 # Add padding - svg_height = height * actual_line_height + 20 - - # Start building SVG - parts: list[str] = [] - parts.append( - f'' - ) - parts.append(f"{_escape_xml(title)}") - - # Style definitions - # Note: We use alphabetic baseline (default) and offset text y by font_size - # to align text top with rect top. This is more compatible across browsers - # than dominant-baseline: text-before-edge which has Safari issues. - parts.append("") - - # Background rectangle - parts.append( - f'' - ) - - # Text content group - parts.append('') - - # Render each row - use explicit x position for EACH character - # to ensure pixel-perfect alignment regardless of font metrics - palette_map = _normalize_palette(palette) if palette is not None else ANSI_COLORS - - for row_idx, row_data in enumerate(screen_buffer): - # rect_y is the top of the cell - rect_y = 10 + row_idx * actual_line_height - # text_y is the baseline position (alphabetic baseline = bottom of lowercase letters) - # For most fonts, baseline is roughly at font_size from top of em box - text_y = rect_y + font_size - - if not row_data: - continue - - # Collect background rects and text spans - row_bg_rects: list[str] = [] - row_tspans: list[str] = [] - - # Track current style for potential span merging (only merge if same style AND adjacent) - col = 0 - while col < len(row_data): - char = row_data[col] - char_data = char["data"] - - # Skip empty placeholder cells (after wide characters) - if not char_data: - col += 1 - continue - - x = 10.0 + col * char_width - - # Get colors, handling reverse video - fg = _color_to_hex( - char["fg"], - is_foreground=True, - palette=palette_map, - default_fg=foreground, - default_bg=background, - ) - bg = _color_to_hex( - char["bg"], - is_foreground=False, - palette=palette_map, - default_fg=foreground, - default_bg=background, - ) - if char["reverse"]: - fg, bg = bg, fg - - # Count columns for this character (wide chars take 2) - char_cols = 1 - if col + 1 < len(row_data) and not row_data[col + 1]["data"]: - char_cols = 2 # Wide character - - # Background rect if not default - # Add 0.5px overlap in both directions to eliminate sub-pixel gaps at high zoom - if bg != background: - bg_width = char_cols * char_width + 0.5 - row_bg_rects.append( - f'' - ) - - # Build tspan with explicit x position - attrs = [f'x="{x:.1f}"'] - - if fg != foreground: - attrs.append(f'fill="{fg}"') - - classes = [] - if char["bold"]: - classes.append("bold") - if char["italics"]: - classes.append("italic") - if char["underscore"]: - classes.append("underline") - if classes: - attrs.append(f'class="{" ".join(classes)}"') - - # Box-drawing characters need vertical scaling to fill line height - # Render them as separate text elements with transform - if _is_box_drawing(char_data): - # Scale vertically by line_height ratio, anchored at top of cell - # The transform scales around (x, rect_y) to stretch the glyph - fill_attr = f' fill="{fg}"' if fg != foreground else "" - class_attr = f' class="{" ".join(classes)}"' if classes else "" - row_bg_rects.append( - f'{_escape_xml(char_data)}" - ) - else: - row_tspans.append(f"{_escape_xml(char_data)}") - - col += char_cols - - # Add background rects first, then text - if row_bg_rects or row_tspans: - parts.extend(row_bg_rects) - if row_tspans: - parts.append(f'') - parts.extend(row_tspans) - parts.append("") - - parts.append("") - parts.append("") - - return "".join(parts) diff --git a/src/webterm/terminal_session.py b/src/webterm/terminal_session.py deleted file mode 100644 index f933534..0000000 --- a/src/webterm/terminal_session.py +++ /dev/null @@ -1,487 +0,0 @@ -from __future__ import annotations - -import array -import asyncio -import contextlib -import fcntl -import logging -import os -import pty -import re -import shlex -import signal -import termios -from collections import deque -from typing import TYPE_CHECKING - -import pyte -from importlib_metadata import PackageNotFoundError, version - -from .alt_screen import AltScreen -from .session import Session, SessionConnector - -if TYPE_CHECKING: - from .poller import Poller - from .types import Meta, SessionID - -log = logging.getLogger("webterm") - -# Maximum bytes to keep in replay buffer for reconnection -REPLAY_BUFFER_SIZE = 256 * 1024 # 256KB - -# Default screen size for pyte emulator -DEFAULT_SCREEN_WIDTH = 132 -DEFAULT_SCREEN_HEIGHT = 45 - -# Pattern to filter out terminal device attribute responses that cause display issues -# These are responses to DA1/DA2/DA3 queries that shouldn't be displayed as text -# Matches complete responses like: -# \x1b[?1;10;0c (DA1 - Primary Device Attributes) -# \x1b[>1;10;0c (DA2 - Secondary Device Attributes, sent by tmux) -# \x1b[=1;0c (DA3 - Tertiary Device Attributes) -DA_RESPONSE_PATTERN = re.compile(rb"\x1b\[[?>=][\d;]*c") - -# Pattern to detect partial DA responses at end of data (incomplete escape sequence) -# Matches: \x1b, \x1b[, \x1b[?, \x1b[>, \x1b[=, \x1b[?1, \x1b[>1;10, etc. -DA_PARTIAL_PATTERN = re.compile(rb"\x1b(?:\[(?:[?>=][\d;]*)?)?$") - -# Map C1 control sequences to 7-bit ESC equivalents for pyte compatibility -CSI_C1 = b"\x9b" -OSC_C1 = b"\x9d" -ST_C1 = b"\x9c" -DCS_C1 = b"\x90" -SOS_C1 = b"\x98" -PM_C1 = b"\x9e" -APC_C1 = b"\x9f" - - -def _normalize_c1_controls(data: bytes, utf8_buffer: bytes = b"") -> tuple[bytes, bytes]: - if not data and not utf8_buffer: - return b"", b"" - data = utf8_buffer + data - out = bytearray() - pending_utf8 = bytearray() - expected_continuations = 0 - c1_map = { - 0x9B: b"\x1b[", - 0x9D: b"\x1b]", - 0x9C: b"\x1b\\", - 0x90: b"\x1bP", - 0x98: b"\x1bX", - 0x9E: b"\x1b^", - 0x9F: b"\x1b_", - } - idx = 0 - while idx < len(data): - byte = data[idx] - if expected_continuations: - if 0x80 <= byte <= 0xBF: - pending_utf8.append(byte) - expected_continuations -= 1 - idx += 1 - if expected_continuations == 0: - out.extend(pending_utf8) - pending_utf8.clear() - continue - out.extend(pending_utf8) - pending_utf8.clear() - expected_continuations = 0 - continue - if 0xC2 <= byte <= 0xDF: - pending_utf8.append(byte) - expected_continuations = 1 - idx += 1 - continue - if 0xE0 <= byte <= 0xEF: - pending_utf8.append(byte) - expected_continuations = 2 - idx += 1 - continue - if 0xF0 <= byte <= 0xF4: - pending_utf8.append(byte) - expected_continuations = 3 - idx += 1 - continue - replacement = c1_map.get(byte) - if replacement is not None: - out.extend(replacement) - else: - out.append(byte) - idx += 1 - if pending_utf8: - return bytes(out), bytes(pending_utf8) - return bytes(out), b"" - - -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() - # pyte screen for accurate terminal state tracking (AltScreen for alternate buffer support) - self._screen = AltScreen(DEFAULT_SCREEN_WIDTH, DEFAULT_SCREEN_HEIGHT) - self._stream = pyte.ByteStream(self._screen) - self._screen_lock = asyncio.Lock() - # Track last known terminal size for reconnection - self._last_width = DEFAULT_SCREEN_WIDTH - self._last_height = DEFAULT_SCREEN_HEIGHT - # Change counter for reliable activity detection (monotonically increasing) - self._change_counter = 0 - self._last_snapshot_counter = 0 - # Buffer for handling escape sequences split across reads - self._escape_buffer = b"" - self._utf8_buffer = b"" - super().__init__() - - def __repr__(self) -> str: - return f"TerminalSession(session_id={self.session_id!r}, command={self.command!r})" - - @staticmethod - def _package_version() -> str: - try: - return version("webterm") - except PackageNotFoundError: - return "0.0.0" - - async def open(self, width: int = 80, height: int = 24) -> None: - log.info("Opening terminal session %s with command: %s", self.session_id, self.command) - # Track the initial size - self._last_width = width - self._last_height = height - # Initialize pyte screen with the requested size (under lock to prevent races) - async with self._screen_lock: - self._screen = AltScreen(width, height) - self._stream = pyte.ByteStream(self._screen) - - pid, master_fd = pty.fork() - self.pid = pid - self.master_fd = master_fd - if pid == pty.CHILD: - os.environ["TERM_PROGRAM"] = "webterm" - os.environ["TERM_PROGRAM_VERSION"] = self._package_version() - 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("Terminal session %s opened successfully", self.session_id) - - 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) - - def _get_terminal_size(self) -> tuple[int, int]: - """Get actual PTY size. Returns (width, height).""" - assert self.master_fd is not None - buf = array.array("h", [0, 0, 0, 0]) - fcntl.ioctl(self.master_fd, termios.TIOCGWINSZ, buf) - return (buf[1], buf[0]) # cols, rows - - async def _sync_pyte_to_pty(self) -> None: - """Sync pyte screen size to actual PTY size.""" - if self.master_fd is None: - return - loop = asyncio.get_running_loop() - # Hold lock during PTY read to ensure consistency with concurrent set_terminal_size - async with self._screen_lock: - width, height = await loop.run_in_executor(None, self._get_terminal_size) - if self._screen.columns != width or self._screen.lines != height: - log.debug( - "Syncing pyte screen from %dx%d to %dx%d", - self._screen.columns, - self._screen.lines, - width, - height, - ) - self._screen.resize(height, width) - self._last_width = width - self._last_height = height - - async def set_terminal_size(self, width: int, height: int) -> None: - """Set terminal size.""" - loop = asyncio.get_running_loop() - # Hold lock during PTY write to ensure consistency with concurrent _sync_pyte_to_pty - async with self._screen_lock: - self._last_width = width - self._last_height = height - await loop.run_in_executor(None, self._set_terminal_size, width, height) - # Resize pyte screen to match - self._screen.resize(height, width) - # Increment change counter since resize changes screen content - self._change_counter += 1 - - async def force_redraw(self) -> None: - """Force a terminal redraw by re-sending current size.""" - loop = asyncio.get_running_loop() - await loop.run_in_executor( - None, self._set_terminal_size, self._last_width, self._last_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 _update_screen(self, data: bytes) -> None: - """Update the pyte screen with new terminal data.""" - async with self._screen_lock: - try: - normalized, self._utf8_buffer = _normalize_c1_controls(data, self._utf8_buffer) - if not normalized: - return - normalized = self._screen.expand_clear_sequences(normalized) - self._stream.feed(normalized) - # Increment change counter when screen is modified - if self._screen.dirty: - self._change_counter += 1 - except Exception as exc: - # Don't let pyte errors crash the session - log.warning( - "Terminal screen update failed (%s): %s", - type(exc).__name__, - exc, - ) - - 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) - - async def get_screen_lines(self) -> list[str]: - """Get the current screen state as a list of lines. - - Returns properly rendered terminal content with all escape sequences - interpreted, suitable for screenshot generation. - """ - async with self._screen_lock: - return [line.rstrip() for line in self._screen.display] - - async def get_screen_has_changes(self) -> bool: - """Check if the screen has changed since the last snapshot. - - This is a non-mutating read-only check that compares the change counter. - """ - async with self._screen_lock: - return self._change_counter > self._last_snapshot_counter - - async def get_screen_snapshot(self) -> tuple[int, int, list, bool]: - """Get a read-only snapshot of the current screen state for screenshots. - - This method does NOT mutate terminal state - safe for dashboard screenshots. - - Returns: - Tuple of (width, height, buffer, has_changes) where: - - width: screen width in columns - - height: screen height in rows - - buffer: list of rows, each containing character data with styling - - has_changes: True if screen has changed since last snapshot - """ - async with self._screen_lock: - width = self._screen.columns - height = self._screen.lines - has_changes = self._change_counter > self._last_snapshot_counter - # Update the snapshot counter to track what we've seen - self._last_snapshot_counter = self._change_counter - # Snapshot buffer cells quickly to minimize lock hold time - snapshot = [ - [self._screen.buffer[row][col] for col in range(width)] for row in range(height) - ] - - buffer = [] - for row_data in snapshot: - row_chars = [] - for char in row_data: - row_chars.append( - { - "data": char.data if char.data else " ", - "fg": char.fg, - "bg": char.bg, - "bold": char.bold, - "italics": char.italics, - "underscore": char.underscore, - "reverse": char.reverse, - } - ) - buffer.append(row_chars) - return (width, height, buffer, has_changes) - - async def get_screen_state(self) -> tuple[int, int, list, bool]: - """Get the current screen state including dimensions and character buffer. - - Note: This method syncs pyte to PTY size and clears dirty flags. - For read-only screenshot access, use get_screen_snapshot() instead. - - Returns: - Tuple of (width, height, buffer, has_changes) where: - - width: screen width in columns - - height: screen height in rows - - buffer: list of rows, each containing character data with styling - - has_changes: True if screen has changed since last call - """ - # Sync pyte to actual PTY size before reading state - await self._sync_pyte_to_pty() - - async with self._screen_lock: - width = self._screen.columns - height = self._screen.lines - # Check if any rows are dirty (changed since last clear) - has_changes = len(self._screen.dirty) > 0 - # Clear dirty set after checking - self._screen.dirty.clear() - # Snapshot buffer cells quickly to minimize lock hold time - snapshot = [ - [self._screen.buffer[row][col] for col in range(width)] for row in range(height) - ] - - buffer = [] - for row_data in snapshot: - row_chars = [] - for char in row_data: - row_chars.append( - { - "data": char.data if char.data else " ", - "fg": char.fg, - "bg": char.bg, - "bold": char.bold, - "italics": char.italics, - "underscore": char.underscore, - "reverse": char.reverse, - } - ) - buffer.append(row_chars) - return (width, height, buffer, has_changes) - - def update_connector(self, connector: SessionConnector) -> None: - """Update the connector for reconnection without restarting the session.""" - self._connector = connector - log.debug("Updated connector for session %s", 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 - - # Prepend any buffered partial escape sequence from previous read - if self._escape_buffer: - data = self._escape_buffer + data - self._escape_buffer = b"" - - # Filter out complete DA1/DA2 responses (e.g., \x1b[?1;10;0c) - data = DA_RESPONSE_PATTERN.sub(b"", data) - if not data: - continue - - # Check for partial escape sequence at end that might be a DA response - # Hold it back until we get more data to see if it completes - match = DA_PARTIAL_PATTERN.search(data) - if match: - self._escape_buffer = data[match.start() :] - data = data[: match.start()] - if not data: - continue - - # Store in replay buffer for reconnection - await self._add_to_replay_buffer(data) - # Update pyte screen state for screenshots - await self._update_screen(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: - fd = self.master_fd - if fd is None: - return False - try: - await self.poller.write(fd, data) - except (KeyError, OSError): - return False - return True - - async def send_meta(self, data: Meta) -> bool: - return True - - async def close(self) -> None: - # Cancel the read task first to unblock any waiting queue.get() - if self._task is not None and not self._task.done(): - self._task.cancel() - 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("Error closing terminal session %s: %s", self.session_id, e) - - async def wait(self, timeout: float = 2.0) -> None: - if self._task is not None: - with contextlib.suppress(asyncio.CancelledError, TimeoutError): - await asyncio.wait_for(asyncio.shield(self._task), timeout=timeout) - - 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/webterm/types.py b/src/webterm/types.py deleted file mode 100644 index feb7c6c..0000000 --- a/src/webterm/types.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 70d0ba2..0000000 --- a/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for webterm.""" diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 40b6b67..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,166 +0,0 @@ -"""Pytest configuration and fixtures for webterm tests.""" - -from __future__ import annotations - -import asyncio -from typing import TYPE_CHECKING -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from webterm.config import App, Config -from webterm.local_server import LocalServer -from webterm.poller import Poller -from 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 mock_request() -> MagicMock: - """Create a mock request with common attributes.""" - request = MagicMock() - request.headers = {} - request.secure = False - request.query = {} - return request - - -@pytest.fixture -def screen_buffer_factory(): - def _make(rows: list[str], width: int = 80): - return [ - [ - { - "data": c, - "fg": "default", - "bg": "default", - "bold": False, - "italics": False, - "underscore": False, - "reverse": False, - } - for c in (row + " " * width)[:width] - ] - for row in rows - ] - - return _make - - -@pytest.fixture -def mock_session(): - session = MagicMock() - session.get_screen_has_changes = AsyncMock(return_value=False) - session.get_screen_state = AsyncMock(return_value=(80, 24, [], True)) - session.get_screen_snapshot = AsyncMock(return_value=(80, 24, [], True)) - return session - - -@pytest.fixture -def poller() -> Poller: - """Create a Poller instance.""" - return Poller() - - -@pytest.fixture -def mock_poller() -> MagicMock: - """Create a mock Poller for unit tests.""" - return MagicMock() - - -class DummyAsyncLock: - """A dummy async context manager for replacing locks in tests.""" - - async def __aenter__(self): - return None - - async def __aexit__(self, exc_type, exc, tb): - return False - - -@pytest.fixture -def dummy_lock() -> DummyAsyncLock: - """Create a dummy async lock for tests.""" - return DummyAsyncLock() - - -@pytest.fixture -def mock_screen_char(): - """Factory for creating mock pyte screen characters.""" - - def _make( - data: str = " ", - fg: int = 0, - bg: int = 0, - bold: bool = False, - italics: bool = False, - underscore: bool = False, - reverse: bool = False, - ) -> MagicMock: - char = MagicMock() - char.data = data - char.fg = fg - char.bg = bg - char.bold = bold - char.italics = italics - char.underscore = underscore - char.reverse = reverse - return char - - return _make - - -@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_alt_screen.py b/tests/test_alt_screen.py deleted file mode 100644 index 4d71c6c..0000000 --- a/tests/test_alt_screen.py +++ /dev/null @@ -1,301 +0,0 @@ -"""Tests for the AltScreen class with alternate screen buffer support.""" - -import pyte -import pytest - -from webterm.alt_screen import DECALTBUF, DECALTBUF_1047, DECALTBUF_1048, AltScreen - - -class TestAltScreen: - """Tests for AltScreen alternate buffer support.""" - - def test_basic_screen_operations(self): - """Test that basic screen operations still work.""" - screen = AltScreen(40, 10) - stream = pyte.Stream(screen) - - stream.feed("Hello World\r\n") - stream.feed("Line 2") - - assert "Hello World" in screen.display[0] - assert "Line 2" in screen.display[1] - - def test_alternate_screen_save_restore(self): - """Test DECSET/DECRST 1049 saves and restores main screen.""" - screen = AltScreen(40, 10) - stream = pyte.Stream(screen) - - # Write to main screen - stream.feed("MAIN SCREEN LINE 1\r\n") - stream.feed("MAIN SCREEN LINE 2\r\n") - assert "MAIN SCREEN LINE 1" in screen.display[0] - assert "MAIN SCREEN LINE 2" in screen.display[1] - - # Enter alternate screen (DECSET 1049) - stream.feed("\x1b[?1049h") - # Screen should be cleared - assert screen.display[0].strip() == "" - assert screen.display[1].strip() == "" - - # Write to alternate screen - stream.feed("ALT SCREEN CONTENT\r\n") - assert "ALT SCREEN CONTENT" in screen.display[0] - - # Exit alternate screen (DECRST 1049) - stream.feed("\x1b[?1049l") - # Main screen should be restored - assert "MAIN SCREEN LINE 1" in screen.display[0] - assert "MAIN SCREEN LINE 2" in screen.display[1] - - def test_alternate_screen_mode_flag(self): - """Test that DECALTBUF mode flag is set correctly.""" - screen = AltScreen(40, 10) - stream = pyte.Stream(screen) - - assert DECALTBUF not in screen.mode - - stream.feed("\x1b[?1049h") - assert DECALTBUF in screen.mode - - stream.feed("\x1b[?1049l") - assert DECALTBUF not in screen.mode - - @pytest.mark.parametrize( - ("enter_seq", "exit_seq", "mode_flag"), - [ - ("\x1b[?1047h", "\x1b[?1047l", DECALTBUF_1047), - ("\x1b[?1048h", "\x1b[?1048l", DECALTBUF_1048), - ], - ) - def test_alternate_screen_mode_variants(self, enter_seq, exit_seq, mode_flag): - """Test that DECSET/DECRST 1047/1048 trigger alternate buffer handling.""" - screen = AltScreen(40, 10) - stream = pyte.Stream(screen) - - stream.feed("MAIN SCREEN\r\n") - assert "MAIN SCREEN" in screen.display[0] - - stream.feed(enter_seq) - assert mode_flag in screen.mode - assert screen.display[0].strip() == "" - - stream.feed("ALT BUFFER\r\n") - assert "ALT BUFFER" in screen.display[0] - - stream.feed(exit_seq) - assert mode_flag not in screen.mode - assert "MAIN SCREEN" in screen.display[0] - - def test_multiple_alt_screen_switches(self): - """Test multiple switches between main and alternate screen.""" - screen = AltScreen(40, 10) - stream = pyte.Stream(screen) - - # Main content - stream.feed("MAIN 1\r\n") - stream.feed("\x1b[?1049h") # Enter alt - stream.feed("ALT 1\r\n") - stream.feed("\x1b[?1049l") # Exit alt - assert "MAIN 1" in screen.display[0] - - # More main content - stream.feed("MAIN 2\r\n") - stream.feed("\x1b[?1049h") # Enter alt again - assert screen.display[0].strip() == "" # Alt screen is clear - stream.feed("\x1b[?1049l") # Exit alt - assert "MAIN 1" in screen.display[0] - assert "MAIN 2" in screen.display[1] - - def test_resize_invalidates_saved_buffer(self): - """Test that resizing clears the saved alternate screen buffer.""" - screen = AltScreen(40, 10) - stream = pyte.Stream(screen) - - stream.feed("MAIN CONTENT\r\n") - stream.feed("\x1b[?1049h") # Enter alt - assert screen._saved_buffer is not None - - # Resize while in alt mode - screen.resize(20, 80) - assert screen._saved_buffer is None - - def test_ed_clear_still_works(self): - """Test that explicit ED (erase display) still works.""" - screen = AltScreen(40, 10) - stream = pyte.Stream(screen) - - stream.feed("Line 1\r\n") - stream.feed("Line 2\r\n") - stream.feed("\x1b[2J") # ED 2 - erase entire display - - assert all(line.strip() == "" for line in screen.display) - - -class TestExpandClearSequences: - """Tests for expand_clear_sequences (Ink partial clear fix).""" - - def test_no_clear_sequences(self): - """Data without EL2+CUU1 runs is returned unchanged.""" - screen = AltScreen(80, 24) - data = b"Hello world\r\n" - assert screen.expand_clear_sequences(data) == data - - def test_short_clear_not_expanded(self): - """Runs of fewer than 3 EL2+CUU1 pairs are not modified.""" - screen = AltScreen(80, 24) - data = b"\x1b[2K\x1b[1A\x1b[2K\x1b[1A" # 2 pairs - assert screen.expand_clear_sequences(data) == data - - def test_full_clear_not_expanded(self): - """A clear that already reaches row 0 is not extended.""" - screen = AltScreen(80, 24) - stream = pyte.ByteStream(screen) - # Put cursor at row 5 - stream.feed(b"\r\n" * 5) - assert screen.cursor.y == 5 - - # 5-pair clear already covers rows 5 down to 0 - data = b"\x1b[2K\x1b[1A" * 5 - result = screen.expand_clear_sequences(data) - assert result == data - - def test_partial_clear_is_extended(self): - """A partial clear that doesn't reach row 0 gets extended.""" - screen = AltScreen(80, 24) - stream = pyte.ByteStream(screen) - # Draw content to push cursor to row 20 - for i in range(20): - stream.feed(f"Line {i}\r\n".encode()) - assert screen.cursor.y == 20 - - # Only clear 5 lines (should extend to clear all 20) - data = b"\x1b[2K\x1b[1A" * 5 - result = screen.expand_clear_sequences(data) - expected_pairs = 20 # extend from 5 to 20 - assert result.count(b"\x1b[2K\x1b[1A") == expected_pairs - - def test_partial_clear_produces_correct_screen(self): - """Simulates Ink /clear: partial clear + redraw leaves clean screen.""" - screen = AltScreen(80, 24) - stream = pyte.ByteStream(screen) - - # Draw 15 lines of content (Ink frame 1) - for i in range(15): - stream.feed(f"Old line {i}\r\n".encode()) - - # Ink /clear: only clears 5 lines then redraws fresh prompt - clear = b"\x1b[2K\x1b[1A" * 5 + b"\x1b[2K\x1b[G" - new_content = b"Fresh prompt\r\n" - - expanded = screen.expand_clear_sequences(clear) - stream.feed(expanded) - stream.feed(new_content) - - # Old content should be gone - non_empty = [line.rstrip() for line in screen.display if line.strip()] - assert len(non_empty) == 1 - assert non_empty[0] == "Fresh prompt" - - def test_data_around_clear_preserved(self): - """Text before and after a clear run is preserved.""" - screen = AltScreen(80, 24) - stream = pyte.ByteStream(screen) - stream.feed(b"\r\n" * 10) - - data = b"before\x1b[2K\x1b[1A" * 0 + b"before" + b"\x1b[2K\x1b[1A" * 5 + b"after" - result = screen.expand_clear_sequences(data) - assert result.startswith(b"before") - assert result.endswith(b"after") - - -class TestScrollUpDown: - """Tests for CSI S (SU) and CSI T (SD) support.""" - - def test_scroll_up_basic(self): - """CSI S scrolls content up, adding blank lines at bottom.""" - screen = AltScreen(40, 10) - stream = pyte.ByteStream(screen) - for i in range(10): - stream.feed(f"Line {i}\r\n".encode()) - - # After writing, Line 0 already scrolled off; rows 0-8 have Lines 1-9 - # Scroll up 3 more lines - stream.feed(b"\x1b[3S") - - assert "Line 4" in screen.display[0] - assert "Line 9" in screen.display[5] - assert screen.display[6].strip() == "" - - def test_scroll_up_default_one(self): - """CSI S with no parameter defaults to 1 line.""" - screen = AltScreen(40, 5) - stream = pyte.ByteStream(screen) - for i in range(5): - stream.feed(f"L{i}\r\n".encode()) - - stream.feed(b"\x1b[S") - assert "L1" in screen.display[0] - - def test_scroll_down_basic(self): - """CSI T scrolls content down, adding blank lines at top.""" - screen = AltScreen(40, 10) - stream = pyte.ByteStream(screen) - for i in range(10): - stream.feed(f"Line {i}\r\n".encode()) - - stream.feed(b"\x1b[3T") - - assert screen.display[0].strip() == "" - assert screen.display[2].strip() == "" - assert "Line 1" in screen.display[3] - - def test_scroll_up_with_margins(self): - """SU respects the scroll region set by DECSTBM.""" - screen = AltScreen(40, 10) - stream = pyte.ByteStream(screen) - for i in range(10): - stream.feed(f"Row {i}\r\n".encode()) - - # After writing, rows 0-8 have Row 1..Row 9 - # Set scroll region to rows 3-7 (1-based: 4;8) - stream.feed(b"\x1b[4;8r") - stream.feed(b"\x1b[2S") - - # Rows outside the region should be unchanged - assert "Row 1" in screen.display[0] - assert "Row 2" in screen.display[1] - assert "Row 3" in screen.display[2] - # Rows inside the region shifted up by 2 - assert "Row 6" in screen.display[3] - - def test_scroll_up_clears_ghost_content(self): - """Simulates tmux sending SU during Ink /clear — ghost content is eliminated.""" - screen = AltScreen(80, 24) - stream = pyte.ByteStream(screen) - - # Fill screen with "old" content - for i in range(24): - stream.feed(f"Old line {i}\r\n".encode()) - - non_empty_before = sum(1 for line in screen.display if line.strip()) - assert non_empty_before > 15 - - # Simulate tmux clear: set margins, scroll up, reset margins - stream.feed(b"\x1b[1;23r") # Set scroll region - stream.feed(b"\x1b[20S") # Scroll up 20 lines - stream.feed(b"\x1b[r") # Reset scroll region - - non_empty_after = sum(1 for line in screen.display if line.strip()) - assert non_empty_after <= 5, f"Expected <= 5 non-empty lines, got {non_empty_after}" - - def test_scroll_up_cursor_unchanged(self): - """SU does not move the cursor position.""" - screen = AltScreen(40, 10) - stream = pyte.ByteStream(screen) - stream.feed(b"\x1b[5;10H") # Move cursor to row 5, col 10 - - saved_y, saved_x = screen.cursor.y, screen.cursor.x - stream.feed(b"\x1b[3S") - - assert screen.cursor.y == saved_y - assert screen.cursor.x == saved_x diff --git a/tests/test_cli.py b/tests/test_cli.py deleted file mode 100644 index 4c0157a..0000000 --- a/tests/test_cli.py +++ /dev/null @@ -1,175 +0,0 @@ -"""Tests for CLI module.""" - -from click.testing import CliRunner - -from webterm import cli - - -def _close_coroutine(coro) -> None: - coro.close() - - -class TestCLI: - """Tests for CLI command.""" - - def test_cli_help(self): - """Test CLI help output.""" - cli_app = 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): - 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", _close_coroutine) - - 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 - - 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", _close_coroutine) - - runner = CliRunner() - result = runner.invoke(cli.app, []) - assert result.exit_code == 0 - assert calls["terminal"][1] == os.environ["SHELL"] - - def test_cli_version(self): - """Test CLI version output.""" - cli_app = cli.app - - runner = CliRunner() - result = runner.invoke(cli_app, ["--version"]) - assert result.exit_code == 0 - assert "version" in result.output - - def test_cli_port_option(self): - """Test CLI port option parsing.""" - cli_app = 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.""" - cli_app = cli.app - - runner = CliRunner() - result = runner.invoke(cli_app, ["--help"]) - assert "--host" in result.output or "-H" in result.output - - -class TestCLIOptions: - """Tests for CLI option handling.""" - - def test_debug_option(self): - """Test --debug option exists.""" - cli_app = cli.app - - runner = CliRunner() - result = runner.invoke(cli_app, ["--help"]) - assert "--docker-watch" in result.output - - def test_no_run_option(self): - """Test --no-run option exists.""" - cli_app = cli.app - - runner = CliRunner() - result = runner.invoke(cli_app, ["--help"]) - # Check that basic options are documented - assert "port" in result.output.lower() - - -def test_package_version_fallback(monkeypatch): - def raise_missing(_name: str): - raise cli.PackageNotFoundError("webterm") - - monkeypatch.setattr(cli, "version", raise_missing) - assert cli._package_version() == "0.0.0" - - -def test_cli_docker_watch_mode(monkeypatch): - 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) - def run_and_close(coro): - calls.setdefault("run", True) - coro.close() - - monkeypatch.setattr(cli.asyncio, "run", run_and_close) - monkeypatch.setattr(cli.constants, "DEBUG", True) - - runner = CliRunner() - result = runner.invoke(cli.app, ["--docker-watch"]) - assert result.exit_code == 0 - assert "terminal" not in calls - - -def test_cli_windows_branch(monkeypatch): - 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.constants, "WINDOWS", True) - def run_and_close(coro): - calls.setdefault("run", True) - coro.close() - - monkeypatch.setattr(cli.asyncio, "run", run_and_close) - - runner = CliRunner() - result = runner.invoke(cli.app, ["--docker-watch"]) - assert result.exit_code == 0 - assert calls.get("run") is True diff --git a/tests/test_cli_landing.py b/tests/test_cli_landing.py deleted file mode 100644 index f9fd824..0000000 --- a/tests/test_cli_landing.py +++ /dev/null @@ -1,71 +0,0 @@ -import asyncio -from pathlib import Path - -from click.testing import CliRunner - -from webterm import cli - - -def test_cli_landing_manifest_runs(monkeypatch, tmp_path: Path): - 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): - 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_config.py b/tests/test_config.py deleted file mode 100644 index 748113e..0000000 --- a/tests/test_config.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Tests for configuration handling.""" - -from __future__ import annotations - -import pytest - -from 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_terminal_app_defaults(self) -> None: - """Test creating a terminal app configuration with defaults.""" - app = App( - name="My App", - slug="my-app", - command="bash", - ) - 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 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_terminal_only(self, tmp_path): - from webterm.config import load_config - - config_path = tmp_path / "config.toml" - config_path.write_text( - """ -[terminal.shell] -command = "bash" -""".lstrip() - ) - - config = load_config(config_path) - assert len(config.apps) == 1 - assert {a.name for a in config.apps} == {"shell"} - assert any(a.terminal for a in config.apps) - - def test_load_config_rejects_app_entries(self, tmp_path): - from webterm.config import load_config - - config_path = tmp_path / "config.toml" - config_path.write_text( - """ -[app."My App"] -command = "echo hi" -""".lstrip() - ) - with pytest.raises(ValueError): - load_config(config_path) - - def test_load_config_expands_vars(self, tmp_path, monkeypatch): - from 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 deleted file mode 100644 index 42a27da..0000000 --- a/tests/test_config_manifest.py +++ /dev/null @@ -1,47 +0,0 @@ -import tempfile -from pathlib import Path - -from 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 - webterm-theme: nord - 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 - svc1 = next(app for app in apps if app.slug == "svc1") - assert svc1.theme == "nord" diff --git a/tests/test_constants.py b/tests/test_constants.py deleted file mode 100644 index f4f63fe..0000000 --- a/tests/test_constants.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Tests for constants helpers.""" - -from __future__ import annotations - - -def test_get_environ_bool(monkeypatch): - from 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 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 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 webterm.constants import get_environ_int - - monkeypatch.setenv("INT", "42") - assert get_environ_int("INT", 7) == 42 diff --git a/tests/test_docker_exec_session.py b/tests/test_docker_exec_session.py deleted file mode 100644 index 214facc..0000000 --- a/tests/test_docker_exec_session.py +++ /dev/null @@ -1,274 +0,0 @@ -"""Tests for docker_exec_session module.""" - -from __future__ import annotations - -import asyncio -from collections import deque -from unittest.mock import AsyncMock, MagicMock, call, patch - -import pytest - -from webterm.docker_exec_session import ( - REPLAY_BUFFER_SIZE, - DockerExecSession, - DockerExecSpec, -) - - -class FakeSocket: - """Simple fake socket for unit tests.""" - - def __init__(self, recv_chunks: list[bytes] | None = None) -> None: - self._recv_chunks = deque(recv_chunks or []) - self.sent = b"" - self.connected_path: str | None = None - self.timeout: float | None = None - self.closed = False - - def settimeout(self, timeout: float | None) -> None: - self.timeout = timeout - - def connect(self, path: str) -> None: - self.connected_path = path - - def sendall(self, data: bytes) -> None: - self.sent += data - - def recv(self, size: int) -> bytes: - if not self._recv_chunks: - return b"" - chunk = self._recv_chunks.popleft() - if len(chunk) > size: - self._recv_chunks.appendleft(chunk[size:]) - return chunk[:size] - return chunk - - def close(self) -> None: - self.closed = True - - -class FakePoller: - """Minimal fake poller for unit tests.""" - - def __init__(self, queue: asyncio.Queue[bytes | None]) -> None: - self.queue = queue - self.added: int | None = None - self.removed: int | None = None - - def add_file(self, file_descriptor: int) -> asyncio.Queue[bytes | None]: - self.added = file_descriptor - return self.queue - - def remove_file(self, file_descriptor: int) -> None: - self.removed = file_descriptor - - -def build_response(status: int, body: bytes, headers: dict[str, str] | None = None) -> bytes: - header_map = {"Content-Length": str(len(body))} - if headers: - header_map.update(headers) - lines = [f"HTTP/1.1 {status} Status"] + [f"{k}: {v}" for k, v in header_map.items()] - return ("\r\n".join(lines) + "\r\n\r\n").encode("utf-8") + body - - -@pytest.fixture -def docker_exec_session(mock_poller) -> DockerExecSession: - return DockerExecSession( - mock_poller, - "sid", - DockerExecSpec(container="container", command=["/bin/sh"]), - socket_path="/tmp/docker.sock", - ) - - -def test_read_http_response_reads_full_body(docker_exec_session): - header = b"HTTP/1.1 200 OK\r\nContent-Length: 11\r\nX-Test: value\r\n\r\nhello " - fake_socket = FakeSocket([header, b"world"]) - status, headers, body = docker_exec_session._read_http_response(fake_socket) - assert status == 200 - assert headers["content-length"] == "11" - assert headers["x-test"] == "value" - assert body == b"hello world" - - -def test_read_http_response_incomplete_headers_returns_empty(docker_exec_session): - fake_socket = FakeSocket([b"HTTP/1.1 200 OK\r\n"]) - status, headers, body = docker_exec_session._read_http_response(fake_socket) - assert status == 0 - assert headers == {} - assert body == b"" - - -def test_request_json_success_sends_payload(docker_exec_session, monkeypatch): - response = build_response(200, b'{"ok": true}') - fake_socket = FakeSocket([response]) - - def socket_factory(*_args, **_kwargs): - return fake_socket - - monkeypatch.setattr("socket.socket", socket_factory) - - result = docker_exec_session._request_json("POST", "/test", {"alpha": "beta"}) - assert result == {"ok": True} - assert fake_socket.connected_path == "/tmp/docker.sock" - request = fake_socket.sent.decode("utf-8") - assert "POST /test HTTP/1.1" in request - assert "Content-Type: application/json" in request - payload = '{"alpha": "beta"}' - assert f"Content-Length: {len(payload)}" in request - assert request.endswith(payload) - assert fake_socket.closed is True - - -def test_request_json_error_raises(docker_exec_session, monkeypatch): - response = build_response(404, b"nope") - fake_socket = FakeSocket([response]) - - def socket_factory(*_args, **_kwargs): - return fake_socket - - monkeypatch.setattr("socket.socket", socket_factory) - - with pytest.raises(RuntimeError, match=r"Docker API request failed \(404\)"): - docker_exec_session._request_json("GET", "/missing", None) - assert fake_socket.closed is True - - -def test_start_exec_socket_error_status_closes_socket(docker_exec_session, monkeypatch): - response = build_response(500, b"boom") - fake_socket = FakeSocket([response]) - - def socket_factory(*_args, **_kwargs): - return fake_socket - - monkeypatch.setattr("socket.socket", socket_factory) - - with pytest.raises(RuntimeError, match=r"Docker API exec start failed"): - docker_exec_session._start_exec_socket("exec-id") - assert fake_socket.closed is True - - -def test_resize_exec_calls_request_json(docker_exec_session): - docker_exec_session._exec_id = "exec-id" - docker_exec_session._request_json = MagicMock() # type: ignore[method-assign] - - docker_exec_session._resize_exec(100, 40) - - docker_exec_session._request_json.assert_called_once_with( - "POST", - "/exec/exec-id/resize?h=40&w=100", - None, - ) - - -@pytest.mark.asyncio -async def test_update_screen_increments_change_counter(docker_exec_session): - initial_counter = docker_exec_session._change_counter - await docker_exec_session._update_screen(b"Hello\r\n") - assert docker_exec_session._change_counter > initial_counter - - -@pytest.mark.asyncio -async def test_update_screen_logs_on_exception(docker_exec_session): - with ( - patch.object(docker_exec_session._stream, "feed", side_effect=RuntimeError("boom")), - patch("webterm.docker_exec_session.log.warning") as warn, - ): - await docker_exec_session._update_screen(b"\xff") - assert warn.called - - -@pytest.mark.asyncio -async def test_update_screen_preserves_utf8_bytes_with_c1_values(docker_exec_session): - await docker_exec_session._update_screen("✓ ok\r\n".encode()) - lines = await docker_exec_session.get_screen_lines() - assert "✓ ok" in lines[0] - - -@pytest.mark.asyncio -async def test_add_to_replay_buffer_trims_old_data(docker_exec_session): - first_chunk = b"a" * (REPLAY_BUFFER_SIZE - 1) - second_chunk = b"b" * 10 - - await docker_exec_session._add_to_replay_buffer(first_chunk) - await docker_exec_session._add_to_replay_buffer(second_chunk) - - assert docker_exec_session._replay_buffer_size == len(second_chunk) - assert await docker_exec_session.get_replay_buffer() == second_chunk - - -@pytest.mark.asyncio -async def test_run_filters_da_responses(): - queue: asyncio.Queue[bytes | None] = asyncio.Queue() - await queue.put(b"hello\x1b[?1;10;0cworld") - await queue.put(b"done") - await queue.put(None) - - poller = FakePoller(queue) - session = DockerExecSession( - poller, - "sid", - DockerExecSpec(container="container", command=["/bin/sh"]), - socket_path="/tmp/docker.sock", - ) - session.master_fd = 10 - fake_socket = FakeSocket([]) - session._sock = fake_socket - - connector = MagicMock() - connector.on_data = AsyncMock() - connector.on_close = AsyncMock() - session._connector = connector - - with ( - patch.object(session, "_add_to_replay_buffer", new=AsyncMock()) as add_buffer, - patch.object(session, "_update_screen", new=AsyncMock()) as update_screen, - ): - await session.run() - - connector.on_data.assert_has_awaits([call(b"helloworld"), call(b"done")]) - add_buffer.assert_has_awaits([call(b"helloworld"), call(b"done")]) - update_screen.assert_has_awaits([call(b"helloworld"), call(b"done")]) - connector.on_close.assert_awaited_once() - assert poller.removed == 10 - assert fake_socket.closed is True - assert session.master_fd is None - assert session._escape_buffer == b"" - - -@pytest.mark.asyncio -async def test_run_handles_partial_da_sequences(): - queue: asyncio.Queue[bytes | None] = asyncio.Queue() - await queue.put(b"hi\x1b[?1") - await queue.put(b"0;0cbye") - await queue.put(None) - - poller = FakePoller(queue) - session = DockerExecSession( - poller, - "sid", - DockerExecSpec(container="container", command=["/bin/sh"]), - socket_path="/tmp/docker.sock", - ) - session.master_fd = 10 - fake_socket = FakeSocket([]) - session._sock = fake_socket - - connector = MagicMock() - connector.on_data = AsyncMock() - connector.on_close = AsyncMock() - session._connector = connector - - with ( - patch.object(session, "_add_to_replay_buffer", new=AsyncMock()) as add_buffer, - patch.object(session, "_update_screen", new=AsyncMock()) as update_screen, - ): - await session.run() - - connector.on_data.assert_has_awaits([call(b"hi"), call(b"bye")]) - add_buffer.assert_has_awaits([call(b"hi"), call(b"bye")]) - update_screen.assert_has_awaits([call(b"hi"), call(b"bye")]) - connector.on_close.assert_awaited_once() - assert poller.removed == 10 - assert fake_socket.closed is True - assert session._escape_buffer == b"" diff --git a/tests/test_docker_stats.py b/tests/test_docker_stats.py deleted file mode 100644 index a218a75..0000000 --- a/tests/test_docker_stats.py +++ /dev/null @@ -1,354 +0,0 @@ -"""Tests for docker_stats module.""" - -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from webterm.docker_stats import ( - DEFAULT_DOCKER_SOCKET, - STATS_HISTORY_SIZE, - DockerStatsCollector, - get_docker_socket_path, - render_sparkline_svg, -) - - -class TestRenderSparklineSvg: - """Tests for SVG sparkline rendering.""" - - def test_empty_values(self): - """Empty values produce empty SVG.""" - svg = render_sparkline_svg([]) - assert " None: - """Test that static path exists.""" - assert WEBTERM_STATIC_PATH is not None - assert WEBTERM_STATIC_PATH.exists() - - def test_static_path_has_required_files(self) -> None: - """Test that static path contains required assets.""" - assert WEBTERM_STATIC_PATH is not None - assert (WEBTERM_STATIC_PATH / "js" / "terminal.js").exists() - assert (WEBTERM_STATIC_PATH / "js" / "ghostty-vt.wasm").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 deleted file mode 100644 index d859251..0000000 --- a/tests/test_local_server_unit.py +++ /dev/null @@ -1,812 +0,0 @@ -"""Tests for local_server module - unit tests for helper functions.""" - -import asyncio -from unittest.mock import AsyncMock, MagicMock - -import pytest -from aiohttp import web - -from webterm.config import App, Config -from webterm.local_server import ( - LocalServer, -) - - -async def wait_for_asyncmock_call(mock: AsyncMock, timeout: float = 0.1) -> None: - async def _wait() -> None: - while mock.await_count == 0: - await asyncio.sleep(0) - - await asyncio.wait_for(_wait(), timeout=timeout) - - -class TestGetStaticPath: - """Tests for static path.""" - - def test_static_path_exists(self): - """Test that static path exists.""" - from webterm.local_server import WEBTERM_STATIC_PATH - - assert WEBTERM_STATIC_PATH is not None and WEBTERM_STATIC_PATH.exists() - - def test_static_path_has_js(self): - """Test that static path has JS directory.""" - from webterm.local_server import WEBTERM_STATIC_PATH - - assert WEBTERM_STATIC_PATH is not None - assert (WEBTERM_STATIC_PATH / "js").exists() - - def test_static_path_has_wasm(self): - """Test that static path has WASM file.""" - from webterm.local_server import WEBTERM_STATIC_PATH - - assert WEBTERM_STATIC_PATH is not None - assert (WEBTERM_STATIC_PATH / "js" / "ghostty-vt.wasm").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 a terminal app.""" - server.add_app("New Terminal", "bash", "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 webterm import local_server - - monkeypatch.setattr(local_server, "generate", lambda: "fixed-session") - - session = MagicMock() - session.get_screen_has_changes = AsyncMock(return_value=False) - 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_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, screen_buffer_factory, mock_session, mock_request - ): - request = mock_request - request.query = {"route_key": "rk"} - - screen_buffer = screen_buffer_factory(["hello", ""]) - mock_session.get_screen_snapshot = AsyncMock(return_value=(80, 2, screen_buffer, True)) - server.session_manager.apps_by_slug["rk"] = App( - name="Test", - slug="rk", - path="./", - command="echo test", - terminal=True, - theme="dracula", - ) - - monkeypatch.setattr( - server.session_manager, "get_session_by_route_key", lambda _rk: mock_session - ) - - response = await server._handle_screenshot(request) - assert response.content_type == "image/svg+xml" - assert "Known" in resp.text - - @pytest.mark.asyncio - async def test_cached_screenshot_etag_returns_304(self, server_with_no_apps): - request = MagicMock() - request.headers = {"If-None-Match": "abc"} - server_with_no_apps._screenshot_cache["rk"] = (0.0, "") - server_with_no_apps._screenshot_cache_etag["rk"] = "abc" - - with pytest.raises(web.HTTPNotModified): - server_with_no_apps._get_cached_screenshot_response(request, "rk") - - @pytest.mark.asyncio - async def test_cached_screenshot_etag_sets_headers(self, server_with_no_apps): - request = MagicMock() - request.headers = {} - server_with_no_apps._screenshot_cache["rk"] = (0.0, "") - server_with_no_apps._screenshot_cache_etag["rk"] = "abc" - - resp = server_with_no_apps._get_cached_screenshot_response(request, "rk") - assert resp is not None - assert resp.headers.get("ETag") == "abc" - - def test_screenshot_cache_ttl_backs_off(self, server_with_no_apps, monkeypatch): - server_with_no_apps._route_last_activity["rk"] = 99.0 - assert server_with_no_apps._get_screenshot_cache_ttl("rk", now=100.0) == 0.3 - - server_with_no_apps._route_last_activity["rk"] = 90.0 - assert server_with_no_apps._get_screenshot_cache_ttl("rk", now=100.0) == 2.0 - - server_with_no_apps._route_last_activity["rk"] = 40.0 - assert server_with_no_apps._get_screenshot_cache_ttl("rk", now=100.0) == 5.0 - - server_with_no_apps._route_last_activity["rk"] = -100.0 - assert server_with_no_apps._get_screenshot_cache_ttl("rk", now=100.0) == 20.0 - - def test_on_keyboard_interrupt_sets_event_when_already_shutting_down(self, server_with_no_apps): - server_with_no_apps._shutdown_started = True - assert not server_with_no_apps.exit_event.is_set() - server_with_no_apps.on_keyboard_interrupt() - assert server_with_no_apps.exit_event.is_set() - - @pytest.mark.asyncio - async def test_on_keyboard_interrupt_schedules_shutdown_in_running_loop( - self, server_with_no_apps - ): - called = {"shutdown": False} - - async def shutdown(): - called["shutdown"] = True - server_with_no_apps.exit_event.set() - - server_with_no_apps._shutdown = shutdown # type: ignore[method-assign] - server_with_no_apps.on_keyboard_interrupt() - - assert server_with_no_apps._shutdown_task is not None - await server_with_no_apps._shutdown_task - assert called["shutdown"] is True - - def test_on_keyboard_interrupt_uses_call_soon_threadsafe_when_loop_running( - self, server_with_no_apps, monkeypatch - ): - async def shutdown(): - return None - - server_with_no_apps._shutdown = shutdown # type: ignore[method-assign] - - fake_loop = MagicMock() - fake_loop.is_running = MagicMock(return_value=True) - server_with_no_apps._loop = fake_loop - - created = {"called": False} - - def fake_create_task(coro): - created["called"] = True - coro.close() - return MagicMock() - - monkeypatch.setattr("webterm.local_server.asyncio.create_task", fake_create_task) - - server_with_no_apps.on_keyboard_interrupt() - assert fake_loop.call_soon_threadsafe.called - - schedule = fake_loop.call_soon_threadsafe.call_args.args[0] - schedule() - assert created["called"] is True - - def test_build_routes_logs_error_when_static_path_missing( - self, server_with_no_apps, monkeypatch - ): - from unittest.mock import MagicMock - - from webterm import local_server - - # Create a mock path that returns False for exists() - fake_path = MagicMock() - fake_path.exists.return_value = False - - monkeypatch.setattr(local_server, "WEBTERM_STATIC_PATH", fake_path) - monkeypatch.setattr(local_server.log, "error", MagicMock()) - - server_with_no_apps._build_routes() - local_server.log.error.assert_called() - - @pytest.mark.asyncio - async def test_dispatch_ws_message_stdin_without_payload_sends_empty( - self, server_with_no_apps, monkeypatch - ): - session = MagicMock() - session.get_screen_has_changes = AsyncMock(return_value=False) - session.send_bytes = AsyncMock() - monkeypatch.setattr( - server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: session - ) - - ws = MagicMock() - created = await server_with_no_apps._dispatch_ws_message(["stdin"], "rk", ws, False) - assert created is False - # stdin writes are fire-and-forget; wait until send_bytes is awaited - await wait_for_asyncmock_call(session.send_bytes) - session.send_bytes.assert_awaited_once_with(b"") - - @pytest.mark.asyncio - @pytest.mark.asyncio - async def test_dispatch_ws_message_resize_existing_session_flag_false( - self, server_with_no_apps, monkeypatch - ): - session = MagicMock() - session.get_screen_has_changes = AsyncMock(return_value=False) - session.set_terminal_size = AsyncMock() - monkeypatch.setattr( - server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: session - ) - - ws = MagicMock() - created = await server_with_no_apps._dispatch_ws_message( - ["resize", {"width": 100, "height": 50}], "rk", ws, False - ) - assert created is False - session.set_terminal_size.assert_awaited_once_with(100, 50) - - async def test_dispatch_ws_message_resize_updates_existing_session( - self, server_with_no_apps, monkeypatch - ): - session = MagicMock() - session.get_screen_has_changes = AsyncMock(return_value=False) - session.set_terminal_size = AsyncMock() - monkeypatch.setattr( - server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: session - ) - - ws = MagicMock() - created = await server_with_no_apps._dispatch_ws_message( - ["resize", {"width": 100, "height": 50}], "rk", ws, True - ) - assert created is True - session.set_terminal_size.assert_awaited_once_with(100, 50) - - @pytest.mark.asyncio - async def test_dispatch_ws_message_resize_no_session_noop( - self, server_with_no_apps, monkeypatch - ): - monkeypatch.setattr( - server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: None - ) - - ws = MagicMock() - created = await server_with_no_apps._dispatch_ws_message( - ["resize", {"width": 100, "height": 50}], "rk", ws, True - ) - assert created is True - - @pytest.mark.asyncio - async def test_handle_screenshot_uses_cached_when_no_changes( - self, server_with_no_apps, monkeypatch, mock_request, mock_session - ): - mock_session.get_screen_snapshot = AsyncMock(return_value=(80, 24, [], False)) - monkeypatch.setattr( - server_with_no_apps.session_manager, - "get_session_by_route_key", - lambda _rk: mock_session, - ) - - request = mock_request - request.query = {"route_key": "rk"} - - # Seed cache - server_with_no_apps._screenshot_cache["rk"] = (0.0, "") - server_with_no_apps._screenshot_cache_etag["rk"] = "abc" - - resp = await server_with_no_apps._handle_screenshot(request) - assert resp.text == "" - - @pytest.mark.asyncio - async def test_handle_screenshot_uses_screen_state( - self, server_with_no_apps, monkeypatch, screen_buffer_factory, mock_request, mock_session - ): - """Test that screenshot uses get_screen_snapshot for rendering.""" - request = mock_request - request.query = {"route_key": "rk"} - - screen_buffer = screen_buffer_factory(["line1", "line2"]) - mock_session.get_screen_snapshot = AsyncMock(return_value=(80, 2, screen_buffer, True)) - monkeypatch.setattr( - server_with_no_apps.session_manager, - "get_session_by_route_key", - lambda _rk: mock_session, - ) - - server_with_no_apps._route_last_activity["rk"] = 1.0 - - resp = await server_with_no_apps._handle_screenshot(request) - assert resp.content_type == "image/svg+xml" - assert " 0.0 - assert await server_with_no_apps._ws_send_queues["rk"].get() == b"data" - - def test_mark_route_activity_triggers_notification(self, server_with_no_apps): - """Test that mark_route_activity triggers SSE notification.""" - import asyncio - - queue: asyncio.Queue[str] = asyncio.Queue(maxsize=10) - server_with_no_apps._sse_subscribers.append(queue) - - server_with_no_apps.mark_route_activity("my-route") - - assert not queue.empty() - assert queue.get_nowait() == "my-route" - - @pytest.mark.asyncio - async def test_handle_stdin_does_not_block_ws_loop( - self, server_with_no_apps, monkeypatch - ): - """Stdin writes should be fire-and-forget so the WS loop keeps processing.""" - send_started = asyncio.Event() - send_gate = asyncio.Event() - - async def slow_send(_data): - send_started.set() - await send_gate.wait() - return True - - session = MagicMock() - session.send_bytes = AsyncMock(side_effect=slow_send) - monkeypatch.setattr( - server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: session - ) - - ws = MagicMock() - # _dispatch_ws_message should return immediately even though send_bytes blocks - created = await server_with_no_apps._dispatch_ws_message( - ["stdin", "hello"], "rk", ws, False - ) - assert created is False - - # The background task should have been created but not finished - await send_started.wait() - assert not send_gate.is_set() - - # Unblock and let the task finish - send_gate.set() - await asyncio.sleep(0) - - @pytest.mark.asyncio - async def test_write_stdin_logs_timeout( - self, server_with_no_apps, monkeypatch, caplog - ): - """_write_stdin should log a warning and not raise on timeout.""" - async def hang_forever(_data): - await asyncio.sleep(999) - return True - - session = MagicMock() - session.send_bytes = AsyncMock(side_effect=hang_forever) - - import logging - - # Use a very short timeout for testing - monkeypatch.setattr("webterm.local_server.STDIN_WRITE_TIMEOUT", 0.01) - - with caplog.at_level(logging.WARNING, logger="webterm"): - await server_with_no_apps._write_stdin(session, "x", "rk") - - assert "Stdin write timeout" in caplog.text diff --git a/tests/test_local_server_websocket_integration.py b/tests/test_local_server_websocket_integration.py deleted file mode 100644 index 39ccca6..0000000 --- a/tests/test_local_server_websocket_integration.py +++ /dev/null @@ -1,197 +0,0 @@ -from __future__ import annotations - -import json -from contextlib import asynccontextmanager -from typing import TYPE_CHECKING -from unittest.mock import AsyncMock - -import pytest -from aiohttp import WSMsgType, web -from aiohttp.test_utils import TestClient, TestServer - -from webterm.config import App, Config -from webterm.local_server import LocalServer -from webterm.types import RouteKey, SessionID - -if TYPE_CHECKING: - from collections.abc import AsyncIterator - - -async def _make_client(server: LocalServer) -> TestClient: - app = web.Application() - app.add_routes(server._build_routes()) - test_server = TestServer(app) - client = TestClient(test_server) - await client.start_server() - return client - - -@pytest.fixture -def server_factory(tmp_path): - counter = {"i": 0} - - def _make(apps: list[App] | None = None) -> LocalServer: - counter["i"] += 1 - config = Config( - apps=apps - or [App(name="Test", slug="test", path=".", command="echo test", terminal=True)] - ) - config_file = tmp_path / f"config-{counter['i']}.toml" - config_file.write_text("") - return LocalServer(config_path=str(config_file), config=config) - - return _make - - -@pytest.fixture -def client_factory(): - @asynccontextmanager - async def _factory(server: LocalServer) -> AsyncIterator[TestClient]: - client = await _make_client(server) - try: - yield client - finally: - await client.close() - - return _factory - - -@pytest.mark.asyncio -async def test_websocket_creates_session_on_resize(tmp_path): - config = Config( - apps=[App(name="Test", slug="test", path=".", command="echo test", terminal=True)] - ) - config_file = tmp_path / "config.toml" - config_file.write_text("") - server = LocalServer(config_path=str(config_file), config=config) - - # Avoid spawning any real processes. - created = {"args": None} - - async def fake_create(route_key: str, width: int, height: int) -> None: - created["args"] = (route_key, width, height) - - server._create_terminal_session = fake_create # type: ignore[method-assign] - - client = await _make_client(server) - try: - ws = await client.ws_connect("/ws/test") - await ws.send_str(json.dumps(["resize", {"width": 90, "height": 25}])) - await ws.close() - finally: - await client.close() - - assert created["args"] == ("test", 90, 25) - # Reconnect to an existing session should reuse it and send replay buffer - - class DummySession: - def is_running(self): - return True - - server.session_manager.routes["test"] = "sid" - server.session_manager.sessions["sid"] = DummySession() - - # Replay buffer should be sent on reconnect - replay_session = server.session_manager.sessions["sid"] - replay_session.get_replay_buffer = AsyncMock(return_value=b"replay") - - client = await _make_client(server) - try: - ws = await client.ws_connect("/ws/test") - msg = await ws.receive(timeout=1) - assert msg.type == WSMsgType.BINARY - assert msg.data == b"replay" - await ws.close() - finally: - await client.close() - - -@pytest.mark.asyncio -async def test_websocket_ping_pong(tmp_path): - config = Config( - apps=[App(name="Test", slug="test", path=".", command="echo test", terminal=True)] - ) - config_file = tmp_path / "config.toml" - config_file.write_text("") - server = LocalServer(config_path=str(config_file), config=config) - - client = await _make_client(server) - try: - ws = await client.ws_connect("/ws/test") - await ws.send_str(json.dumps(["ping", "123"])) - - msg = await ws.receive(timeout=1) - assert msg.type == WSMsgType.TEXT - assert json.loads(msg.data) == ["pong", "123"] - - await ws.close() - finally: - await client.close() - - -@pytest.mark.asyncio -async def test_websocket_ignores_invalid_envelopes(tmp_path): - config = Config( - apps=[App(name="Test", slug="test", path=".", command="echo test", terminal=True)] - ) - config_file = tmp_path / "config.toml" - config_file.write_text("") - server = LocalServer(config_path=str(config_file), config=config) - - client = await _make_client(server) - try: - ws = await client.ws_connect("/ws/test") - await ws.send_str("not json") - await ws.send_str(json.dumps({"not": "a list"})) - await ws.send_str(json.dumps([])) - await ws.close() - finally: - await client.close() - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - ("payload", "is_binary"), - [ - ("not json", False), - (json.dumps({"not": "a list"}), False), - (json.dumps([]), False), - (b"\x00\x01\x02", True), - ], -) -async def test_websocket_invalid_payloads_keep_connection( - server_factory, client_factory, payload, is_binary -): - server = server_factory() - async with client_factory(server) as client: - ws = await client.ws_connect("/ws/test") - if is_binary: - await ws.send_bytes(payload) - else: - await ws.send_str(payload) - await ws.send_str(json.dumps(["ping", "ok"])) - - msg = await ws.receive(timeout=1) - assert msg.type == WSMsgType.TEXT - assert json.loads(msg.data) == ["pong", "ok"] - await ws.close() - - -@pytest.mark.asyncio -async def test_websocket_clears_stale_session(server_factory, client_factory): - server = server_factory() - - class DummySession: - def is_running(self): - return False - - session_id = SessionID("sid") - route_key = RouteKey("test") - server.session_manager.routes[route_key] = session_id - server.session_manager.sessions[session_id] = DummySession() - - async with client_factory(server) as client: - ws = await client.ws_connect("/ws/test") - assert server.session_manager.get_session(session_id) is None - assert server.session_manager.routes.get(route_key) is None - await ws.close() diff --git a/tests/test_modules.py b/tests/test_modules.py deleted file mode 100644 index 0db860b..0000000 --- a/tests/test_modules.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Tests for constants module.""" - - -class TestConstants: - """Tests for constants module.""" - - def test_import(self): - """Test module can be imported.""" - from webterm import constants - - assert constants is not None - - def test_debug_exists(self, monkeypatch): - """Test DEBUG constant exists and respects env var.""" - import importlib - - from webterm import constants - - assert hasattr(constants, "DEBUG") - assert isinstance(constants.DEBUG, bool) - - monkeypatch.setenv("DEBUG", "1") - reloaded = importlib.reload(constants) - assert reloaded.DEBUG is True - - monkeypatch.setenv("DEBUG", "0") - reloaded = importlib.reload(constants) - assert reloaded.DEBUG is False - - -class TestExitPoller: - """Tests for exit_poller module.""" - - def test_import(self): - """Test module can be imported.""" - from webterm.exit_poller import ExitPoller - - assert ExitPoller is not None - - async def test_exits_when_idle(self, monkeypatch): - """ExitPoller should call force_exit after idle_wait seconds with no sessions.""" - import asyncio - - from webterm import exit_poller - from webterm.exit_poller import ExitPoller - - # Speed up the poll loop for the unit test. - monkeypatch.setattr(exit_poller, "EXIT_POLL_RATE", 0.01) - - class FakeServer: - def __init__(self): - class SM: - def __init__(self): - self.sessions = {} - - self.session_manager = SM() - self.exited = False - - def force_exit(self): - self.exited = True - - server = FakeServer() - poller = ExitPoller(server, idle_wait=0.02) - poller.start() - await asyncio.sleep(0.1) - poller.stop() - assert server.exited is True diff --git a/tests/test_poller.py b/tests/test_poller.py deleted file mode 100644 index 81e67e3..0000000 --- a/tests/test_poller.py +++ /dev/null @@ -1,171 +0,0 @@ -"""Tests for poller module.""" - -import asyncio -import contextlib -from unittest.mock import MagicMock, patch - -import pytest - -from webterm.poller import Poller, Write - - -class TestWrite: - """Tests for Write dataclass.""" - - def test_create_write(self): - """Test creating a Write object.""" - write = Write(data=b"test") - assert write.data == b"test" - assert write.position == 0 - assert write.done_event is not None - - def test_write_with_position(self): - """Test Write with custom position.""" - write = Write(data=b"test", position=5) - assert write.position == 5 - - -class TestPoller: - """Tests for Poller class.""" - - def test_init(self): - """Test Poller initialization.""" - poller = Poller() - assert poller._loop is None - assert poller._read_queues == {} - assert poller._write_queues == {} - assert not poller._exit_event.is_set() - - def test_set_loop(self): - """Test setting the asyncio loop.""" - poller = Poller() - mock_loop = MagicMock() - poller.set_loop(mock_loop) - assert poller._loop == mock_loop - - def test_add_file(self): - """Test adding a file descriptor.""" - poller = Poller() - # Use a mock file descriptor - with patch.object(poller._selector, "register"): - queue = poller.add_file(42) - assert 42 in poller._read_queues - assert isinstance(queue, asyncio.Queue) - - def test_remove_file(self): - """Test removing a file descriptor.""" - poller = Poller() - # Add first - with patch.object(poller._selector, "register"): - poller.add_file(42) - - # Remove - with patch.object(poller._selector, "unregister"): - poller.remove_file(42) - assert 42 not in poller._read_queues - - def test_remove_nonexistent_file(self): - """Test removing a non-existent file descriptor.""" - poller = Poller() - with patch.object(poller._selector, "unregister"): - # Should not raise - poller.remove_file(999) - - @pytest.mark.asyncio - async def test_write_handles_removed_fd(self): - poller = Poller() - poller._loop = asyncio.get_event_loop() - - with patch.object(poller._selector, "register"): - poller.add_file(42) - - with patch.object(poller._selector, "modify", side_effect=KeyError()): - await poller.write(42, b"test") - - @pytest.mark.asyncio - async def test_write_creates_queue(self): - """Test that write creates a write queue if needed.""" - poller = Poller() - poller._loop = asyncio.get_event_loop() - - # Mock selector - with patch.object(poller._selector, "register"): - poller.add_file(42) - - with patch.object(poller._selector, "modify"): - # Start write in background (won't complete without poller running) - task = asyncio.create_task(poller.write(42, b"test")) - - # Give it time to set up - await asyncio.sleep(0.01) - - assert 42 in poller._write_queues - assert len(poller._write_queues[42]) == 1 - - # Cancel to clean up - task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await task - - def test_exit_sets_event(self): - """Test that exit sets the exit event.""" - poller = Poller() - poller._exit_event.clear() - - # Mock join to avoid blocking - with patch.object(poller, "join"): - poller.exit() - - assert poller._exit_event.is_set() - assert poller._read_queues == {} - assert poller._write_queues == {} - - def test_exit_puts_none_in_queues(self): - """Test that exit puts None in all read queues.""" - poller = Poller() - - # Add some queues - with patch.object(poller._selector, "register"): - q1 = poller.add_file(1) - q2 = poller.add_file(2) - - # Mock join - with patch.object(poller, "join"): - poller.exit() - - # Queues should have None - assert q1.get_nowait() is None - assert q2.get_nowait() is None - - @pytest.mark.asyncio - async def test_write_with_timeout_returns_true_on_success(self): - """write_with_timeout returns True when write completes.""" - poller = Poller() - poller._loop = asyncio.get_event_loop() - - with patch.object(poller._selector, "register"): - poller.add_file(42) - - async def instant_write(fd, data): - # Simulate immediate completion - pass - - with patch.object(poller, "write", side_effect=instant_write): - result = await poller.write_with_timeout(42, b"test", timeout=1.0) - assert result is True - - @pytest.mark.asyncio - async def test_write_with_timeout_returns_false_on_timeout(self): - """write_with_timeout returns False when write times out.""" - poller = Poller() - poller._loop = asyncio.get_event_loop() - - with patch.object(poller._selector, "register"): - poller.add_file(42) - - async def slow_write(fd, data): - await asyncio.sleep(999) - - with patch.object(poller, "write", side_effect=slow_write): - result = await poller.write_with_timeout(42, b"test", timeout=0.01) - assert result is False diff --git a/tests/test_session.py b/tests/test_session.py deleted file mode 100644 index 7a36c65..0000000 --- a/tests/test_session.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Tests for session management.""" - -from __future__ import annotations - -import pytest - -from webterm.session import Session, SessionConnector -from webterm.types import RouteKey, SessionID - - -class TestSessionConnector: - """Tests for SessionConnector base class.""" - - @pytest.mark.asyncio - async def test_on_data_noop(self) -> None: - """Default on_data does nothing.""" - connector = SessionConnector() - await connector.on_data(b"test") # Should not raise - - @pytest.mark.asyncio - async def test_on_meta_noop(self) -> None: - """Default on_meta does nothing.""" - connector = SessionConnector() - await connector.on_meta({"key": "value"}) # Should not raise - - @pytest.mark.asyncio - async def test_on_close_noop(self) -> None: - """Default on_close does nothing.""" - connector = SessionConnector() - await connector.on_close() # Should not raise - - @pytest.mark.asyncio - async def test_on_binary_encoded_message_noop(self) -> None: - """Default on_binary_encoded_message does nothing.""" - connector = SessionConnector() - await connector.on_binary_encoded_message(b"\x00\x01") # Should not raise - - -class TestSessionBase: - """Tests for Session base class.""" - - def test_is_running_default(self) -> None: - """Default is_running returns False.""" - # Session is abstract, but is_running has a default impl - # We can test it via any concrete implementation, or check the code - # For now just verify the base returns False - assert Session.is_running(Session.__new__(Session)) is False - - -class TestTypes: - """Tests for type definitions.""" - - def test_session_id_is_string(self) -> 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 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 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 deleted file mode 100644 index 47e3661..0000000 --- a/tests/test_session_manager.py +++ /dev/null @@ -1,341 +0,0 @@ -"""Tests for session_manager module.""" - -import platform -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from webterm.config import App -from webterm.docker_watcher import AUTO_COMMAND_SENTINEL -from webterm.session_manager import SessionManager -from 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_idempotent(self, mock_poller, mock_path, sample_apps): - """Test session end cleanup is idempotent.""" - manager = SessionManager(mock_poller, mock_path, sample_apps) - - session_id = SessionID("test-session") - route_key = RouteKey("test-route") - manager.sessions[session_id] = MagicMock() - manager.routes[route_key] = session_id - - manager.on_session_end(session_id) - 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 removes it from tracking.""" - manager = SessionManager(mock_poller, mock_path, sample_apps) - - mock_session = MagicMock() - mock_session.close = AsyncMock() - session_id = SessionID("test-session") - route_key = RouteKey("test-route") - manager.sessions[session_id] = mock_session - manager.routes[route_key] = session_id - - await manager.close_session(session_id) - - mock_session.close.assert_called_once() - assert session_id not in manager.sessions - assert route_key not in manager.routes - - @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 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 - @pytest.mark.skipif(platform.system() == "Windows", reason="Terminal not supported on Windows") - async def test_new_docker_exec_session(self, mock_poller, mock_path): - from webterm.docker_exec_session import DockerExecSession - - app = App( - name="my-container", - slug="my-container", - path="./", - command=AUTO_COMMAND_SENTINEL, - terminal=True, - ) - manager = SessionManager(mock_poller, mock_path, [app]) - - with patch.object(DockerExecSession, "open", new_callable=AsyncMock): - result = await manager.new_session( - "my-container", - SessionID("test-session"), - RouteKey("test-route"), - ) - - assert result is not None - assert isinstance(result, DockerExecSession) - assert result.exec_spec.user is None - - async def test_new_docker_exec_session_with_user(self, mock_poller, mock_path, monkeypatch): - from webterm.docker_exec_session import DockerExecSession - - monkeypatch.setenv("WEBTERM_DOCKER_USERNAME", "testuser") - - app = App( - name="my-container", - slug="my-container", - path="./", - command=AUTO_COMMAND_SENTINEL, - terminal=True, - ) - manager = SessionManager(mock_poller, mock_path, [app]) - - with patch.object(DockerExecSession, "open", new_callable=AsyncMock): - result = await manager.new_session( - "my-container", - SessionID("test-session"), - RouteKey("test-route"), - ) - - assert result is not None - assert isinstance(result, DockerExecSession) - assert result.exec_spec.user == "testuser" - - @pytest.mark.asyncio - @pytest.mark.skipif(platform.system() == "Windows", reason="Terminal not supported on Windows") - async def test_new_docker_exec_session_container_placeholder( - self, mock_poller, mock_path, monkeypatch - ): - """Test that {container} placeholder in auto command is replaced with container name.""" - from webterm.docker_exec_session import DockerExecSession - - monkeypatch.setenv("WEBTERM_DOCKER_AUTO_COMMAND", "tmux new-session -ADs {container}") - - app = App( - name="my-webapp", - slug="my-webapp", - path="./", - command=AUTO_COMMAND_SENTINEL, - terminal=True, - ) - manager = SessionManager(mock_poller, mock_path, [app]) - - with patch.object(DockerExecSession, "open", new_callable=AsyncMock): - result = await manager.new_session( - "my-webapp", - SessionID("test-session"), - RouteKey("test-route"), - ) - - assert result is not None - assert isinstance(result, DockerExecSession) - # Verify the container name was substituted into the command - assert result.exec_spec.command == ["tmux", "new-session", "-ADs", "my-webapp"] - - -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 deleted file mode 100644 index fa8655b..0000000 --- a/tests/test_slugify.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Tests for slugify module.""" - -from 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 - - def test_allow_unicode_preserves_unicode(self): - """Test that allow_unicode=True preserves unicode characters.""" - # With allow_unicode=True, unicode chars are normalized but preserved - result = slugify("héllo wörld", allow_unicode=True) - assert result == "héllo-wörld" - # Without allow_unicode (default), non-ASCII is transliterated - result_ascii = slugify("héllo wörld", allow_unicode=False) - assert result_ascii == "hello-world" diff --git a/tests/test_svg_exporter.py b/tests/test_svg_exporter.py deleted file mode 100644 index 26a209e..0000000 --- a/tests/test_svg_exporter.py +++ /dev/null @@ -1,645 +0,0 @@ -"""Extensive tests for the custom SVG exporter.""" - -from __future__ import annotations - -import pytest - -from webterm.svg_exporter import ( - ANSI_COLORS, - DEFAULT_BG, - DEFAULT_FG, - CharData, - _color_to_hex, - _escape_xml, - render_terminal_svg, -) - - -class TestColorToHex: - """Tests for _color_to_hex function.""" - - @pytest.mark.parametrize( - ("color", "is_foreground", "expected"), - [ - ("default", True, DEFAULT_FG), - ("default", False, DEFAULT_BG), - ("#ff0000", True, "#ff0000"), - ("#123456", True, "#123456"), - ("#AABBCC", True, "#AABBCC"), - ("ff0000", True, "#ff0000"), - ("123456", True, "#123456"), - ("AABBCC", True, "#AABBCC"), - ("ff8700", True, "#ff8700"), - ("red", True, ANSI_COLORS["red"]), - ("green", True, ANSI_COLORS["green"]), - ("blue", True, ANSI_COLORS["blue"]), - ("white", True, ANSI_COLORS["white"]), - ("black", True, ANSI_COLORS["black"]), - ("brightred", True, ANSI_COLORS["brightred"]), - ("brightgreen", True, ANSI_COLORS["brightgreen"]), - ("brightblue", True, ANSI_COLORS["brightblue"]), - ("RED", True, ANSI_COLORS["red"]), - ("Green", True, ANSI_COLORS["green"]), - ("BRIGHTBLUE", True, ANSI_COLORS["brightblue"]), - ("unknowncolor", True, DEFAULT_FG), - ("unknowncolor", False, DEFAULT_BG), - ("rgb(255,0,0)", True, DEFAULT_FG), - ("rgb(0,255,0)", False, DEFAULT_BG), - ("gray", True, ANSI_COLORS["gray"]), - ("grey", True, ANSI_COLORS["grey"]), - ("lightgray", True, ANSI_COLORS["lightgray"]), - ("lightgrey", True, ANSI_COLORS["lightgrey"]), - ], - ) - def test_color_to_hex(self, color: str, is_foreground: bool, expected: str) -> None: - """Color conversion covers named/hex/default cases.""" - assert _color_to_hex(color, is_foreground=is_foreground) == expected - - def test_color_to_hex_uses_palette_defaults(self) -> None: - palette = {"red": "#123456"} - assert ( - _color_to_hex( - "default", - is_foreground=True, - palette=palette, - default_fg="#111111", - default_bg="#222222", - ) - == "#111111" - ) - assert ( - _color_to_hex( - "default", - is_foreground=False, - palette=palette, - default_fg="#111111", - default_bg="#222222", - ) - == "#222222" - ) - assert ( - _color_to_hex( - "red", - is_foreground=True, - palette=palette, - default_fg="#111111", - default_bg="#222222", - ) - == "#123456" - ) - - -class TestEscapeXml: - """Tests for XML escaping.""" - - @pytest.mark.parametrize( - ("input_str", "expected"), - [ - ("hello world", "hello world"), - ("<", "<"), - ("a < b", "a < b"), - (">", ">"), - ("a > b", "a > b"), - ("&", "&"), - ("a & b", "a & b"), - ('"', """), - ("'", "'"), - ('', "<script>"alert"</script>"), - ("你好世界", "你好世界"), - ("🎉🚀", "🎉🚀"), - ], - ) - def test_escape_xml(self, input_str: str, expected: str) -> None: - """Escape XML special chars and preserve unicode.""" - assert _escape_xml(input_str) == expected - - -class TestRenderTerminalSvg: - """Tests for render_terminal_svg function.""" - - def _char( - self, - data: str, - fg: str = "default", - bg: str = "default", - bold: bool = False, - italics: bool = False, - underscore: bool = False, - reverse: bool = False, - ) -> CharData: - """Helper to create CharData.""" - return { - "data": data, - "fg": fg, - "bg": bg, - "bold": bold, - "italics": italics, - "underscore": underscore, - "reverse": reverse, - } - - def _make_buffer(self, rows: list[str]) -> list[list[CharData]]: - """Create simple buffer from strings.""" - return [[self._char(c) for c in row] for row in rows] - - def test_empty_buffer(self) -> None: - """Empty buffer produces valid SVG.""" - svg = render_terminal_svg([], width=80, height=24) - assert svg.startswith("") - assert 'xmlns="http://www.w3.org/2000/svg"' in svg - - def test_css_properties(self) -> None: - """SVG includes essential CSS properties for proper rendering.""" - svg = render_terminal_svg([], width=80, height=24) - # Check for legibility optimization - assert "text-rendering: optimizeLegibility" in svg - # Check for monospace font - assert "font-family:" in svg - assert "monospace" in svg - # Check for pre whitespace handling - assert "white-space: pre" in svg - - def test_buffer_with_empty_rows(self) -> None: - """Buffer with rows containing only empty cells produces valid SVG.""" - # Row with only empty placeholder cells (no actual characters) - buffer = [ - [self._char("") for _ in range(10)], # Empty row - [self._char("A")], # Normal row - [self._char("") for _ in range(10)], # Another empty row - ] - svg = render_terminal_svg(buffer, width=10, height=3) - assert svg.startswith(" None: - """Buffer with truly empty row (empty list) is handled.""" - buffer = [ - [], # Truly empty row (no cells at all) - [self._char("B")], # Normal row - ] - svg = render_terminal_svg(buffer, width=10, height=2) - assert svg.startswith("B" in svg - - def test_basic_text_output(self) -> None: - """Basic text is included in SVG (each char with explicit x position).""" - buffer = self._make_buffer(["Hello, World!"]) - svg = render_terminal_svg(buffer, width=80, height=24) - # Each character is rendered individually with explicit x - assert ">H" in svg - assert ">e" in svg - assert ">!" in svg - - def test_multiline_output(self) -> None: - """Multiple lines render correctly.""" - buffer = self._make_buffer(["Line 1", "Line 2", "Line 3"]) - svg = render_terminal_svg(buffer, width=80, height=24) - # Check for characters from each line - assert ">L" in svg - assert ">1" in svg - assert ">2" in svg - assert ">3" in svg - # Should have 3 text elements - assert svg.count("&test"]) - svg = render_terminal_svg(buffer, width=80, height=24) - assert "<" in svg # < escaped - assert ">" in svg # > escaped - assert "&" in svg # & escaped - assert "