remotedesktop 2.4.0__tar.gz → 2.5.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/CLAUDE.md +2 -2
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/PKG-INFO +1 -1
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/__init__.py +1 -1
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/app.py +22 -2
- remotedesktop-2.5.1/src/remotedesktop/console_window.py +95 -0
- remotedesktop-2.5.1/src/remotedesktop/crash_report.py +100 -0
- remotedesktop-2.5.1/tests/test_console_window.py +163 -0
- remotedesktop-2.5.1/tests/test_crash_report.py +113 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_performance.py +1 -1
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/uv.lock +48 -48
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/.github/dependabot.yml +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/.github/workflows/ci.yml +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/.github/workflows/release.yml +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/.gitignore +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/LICENSE +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/docs/media/client-demo.gif +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/docs/media/server-demo.gif +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/icon/remotedesktop.ico +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/pyproject.toml +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/readme.md +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/run.bat +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/run_claude.bat +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/scripts/make_coverage_badge.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/scripts/make_installer.bat +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/scripts/make_venv.bat +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/__main__.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/about.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/autostart.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/backlog.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/client.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/clipboard.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/compat.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/config.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/cursor_shape.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/db.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/discovery.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/dxgi.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/frames.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/icon.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/input_injection.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/inventory.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/logs.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/modal_loop.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/performance.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/preferences.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/protocol.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/server.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/session_lock.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/sharing.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/single_instance.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/tls.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/viewer.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/visual_effects.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/window_state.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/conftest.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_about.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_autostart.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_backlog.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_clipboard.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_compat.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_config.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_cursor_shape.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_discovery.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_dxgi.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_frames.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_icon.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_input.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_input_injection.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_inventory.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_logs.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_main_window.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_modal_loop.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_preferences.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_protocol.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_session_lock.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_sharing.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_sharing_tab.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_single_instance.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_smoke.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_tls.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_visual_effects.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_window_state.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tools/make_demo_gifs.py +0 -0
- {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tools/make_icon.py +0 -0
|
@@ -67,7 +67,7 @@ Managed with `uv` (hatchling build backend, src layout):
|
|
|
67
67
|
- `uv sync` — create/update the venv with the project and dev dependencies
|
|
68
68
|
- `uv run pytest` — run all tests
|
|
69
69
|
- `uv run pytest tests/test_smoke.py::test_version` — run a single test
|
|
70
|
-
- `uv run remotedesktop` — launch the app. It's a `gui-script
|
|
70
|
+
- `uv run remotedesktop` — launch the app. It's a `gui-script` (runs under `pythonw.exe`; if a broken venv — uv 0.11 made `Scripts\pythonw.exe` a console copy — still gives it a console, `console_window.hide_unwanted_console()` in `main()` hides it, or minimizes it under Windows Terminal, which ignores hide; rebuilding the venv is the real fix, and `console_window.broken_pythonw()` — a PE-header subsystem check — makes `main()` say so in the Connection log), so it runs detached with no console output; use `uv run python -m remotedesktop` when you need stdout/tracebacks. `--minimized` starts hidden in the tray when sharing is on, otherwise minimized to the taskbar (the default autostart registration passes it); `--maximized` starts maximized.
|
|
71
71
|
- `uv build` — build sdist and wheel into `dist/`
|
|
72
72
|
- `uv publish` — publish to PyPI
|
|
73
73
|
- `scripts\make_installer.bat` — build the **signed Windows installer** with pyship
|
|
@@ -96,7 +96,7 @@ Managed with `uv` (hatchling build backend, src layout):
|
|
|
96
96
|
- **Screen sharing** (`sharing.py` + `protocol.py` + `frames.py`) runs on the Qt event loop — no locks, and one deliberate exception to "no threads": **PNG encoding runs on a single worker thread** (`frames.FrameEncoder`, a one-worker `ThreadPoolExecutor`; PySide6 releases the GIL in `QImage.save`, verified). The GUI thread still captures and diffs (cheap: DXGI ~10 ms, memcmp diff ~3.5 ms for 4K), submits at most one job, and gets an `EncodeResult` back via a queued signal; **a tick that lands while an encode is in flight skips the capture** (`_ticks_skipped`) so DXGI accumulates and the next diff catches everything — a slow encode lowers the frame rate, never stacks latency. `FrameEncoder.close()` waits for the in-flight job, so no result lands after teardown. **The band diff is a C `memcmp` per band** (`frames.changed_bands`, via `PyObject_GetBuffer` to borrow the read-only `constBits()` address; `_changed_bands_python` is the fallback and the test reference) — the memoryview slice compare it replaced cost 43 ms per changed 4K tick, more than the whole 33 ms frame budget. **Flow control is per viewer and adaptive** (`backlog.py` `BacklogGovernor`): the cap on a viewer's unsent bytes is its measured socket drain rate × `QUEUE_TARGET_SECONDS` (50 ms), starting from a 100 Mbit/s assumption (the app assumes a modern LAN, usually wired: ≥100 Mbit/s, typically 1 Gbit/s), halved while the in-band RTT (from the performance monitor, when attached) exceeds its drifting floor by more than the target, clamped to `CAP_MIN`..`CAP_MAX`. **A withheld viewer no longer costs a keyframe**: the bands it missed accumulate in `_dirty[stream]` (`frames.merge_bands`) and ship as one merged delta when it drains; each distinct owed band set is encoded once per tick. "Not keeping up" is reported only after `_BACKLOG_REPORT_SECONDS` (1 s) continuously behind; `_send_cap(stream)` is the test seam (tests monkeypatch it on the instance, replacing the old `_MAX_SEND_BACKLOG` constant). A debug-log summary every 10 s (`_log_stats`) reports encode timings, skipped ticks, withheld frames, and each viewer's throughput estimate and cap. `MessageStream` frames messages over a QTcpSocket (4-byte length + kind byte; JSON control messages, full frames, or inter-frame deltas; malformed input aborts the socket). `ShareServer` owns one QTimer that grabs the primary screen at full resolution via `_capture()` (a seam tests override to drive deterministic frame content) and fans out to all clients, skipping clients whose send buffer is backlogged; the timer only runs while clients are connected. Capture is DXGI desktop duplication (`dxgi.py`, hand-rolled ctypes COM — ~10 ms per changed 4K frame, ~0 when idle, and "unchanged" is signalled by returning the same QImage object so the diff is skipped) with automatic fallback to `QScreen.grabWindow` (~96 ms) whenever duplication is unavailable or lost (secure desktop, RDP, display-mode change, CI); unit tests fake the duplication (`FakeDuplication` in `tests/test_dxgi.py`) and the real-hardware test skips where DXGI is unavailable. **Frame transfer is lossless and inter-frame compressed** (`frames.py`): consecutive captures are diffed in 64-row bands (memoryview compares), and only changed bands ship as PNG in a delta payload the client patches onto its previous frame; an unchanged screen sends nothing. Full PNG keyframes go to clients that just joined, just recovered from a send backlog, or requested one (a `{"type": "keyframe"}` message — sent when a delta fails to apply, so desync self-heals). Every client is assumed delta-capable (no negotiation). Each variant (keyframe PNG / delta) is encoded at most once per tick and shared across clients. `ShareClient` decodes/patches to QImage for the viewer, which displays it scaled with a cached `SmoothTransformation` copy (full resolution is always delivered; scaling happens client-side only).
|
|
97
97
|
- **All persistence is one SQLite database** (`db.py`): `db.connect(path)` opens the DB and ensures every table (`settings`, `paired_clients`, `known_servers`, and the inventory tables). The file lives at `config.default_db_path()` — `platformdirs.user_data_dir("remotedesktop")` (`%LOCALAPPDATA%\remotedesktop\remotedesktop.db` on Windows). **Use `platformdirs` for any new data location; don't hardcode `%APPDATA%`.** Every store (`Settings`, `PairedClients`, `KnownServers`, `ConnectionInventory`) takes a `sqlite3.Connection`; the app opens one connection and shares it. **Boolean settings go through `Settings.get_bool`/`set_bool`** (the `tobool` package's `to_bool_strict`; on-disk form stays the historical `"1"`/`"0"`, junk falls back to the caller's default) — don't hand-roll `!= "0"` comparisons. This applies to binary values stored as strings/ints; ctypes Win32 `BOOL`s keep plain `bool()` (TRUE is "any nonzero", which strict parsing correctly refuses). `MainWindow` takes a `connection=` keyword so tests inject a temp-file DB and never touch the real one (see `tests/test_main_window.py` / `test_sharing_tab.py`). Tests pass `db.connect(tmp_path/"x.db")` (or omit for in-memory), and use **separate DB files per simulated machine**.
|
|
98
98
|
- **Trust model plumbing** (`config.py`): the client has a stable UUID identity (`load_client_identity`, in the `settings` table) and the server maps each approved client-id to a token (`PairedClients`). `ShareServer` takes an `approve_client(client_id, name) -> bool` callback; `SharingTab` implements it as a modal stay-on-top QMessageBox. Tests inject stores backed by a temp DB and explicit identities so they never touch real data or prompt.
|
|
99
|
-
- **Status/debug logging is a feature**: `ShareServer`, `ShareClient`, `SharingTab`, and `DiscoveryPanel` emit human-readable `status`/`statusMessage` signals for every connection phase, shown in the window's timestamped "Connection log" pane. **Byte counts in any user-facing string go through `humanize.naturalsize(n, binary=True)`** ("2.0 KiB", "1.3 MiB") — never hand-rolled `// 1024` math (the `humanize` package is a dependency; `performance.format_rate` wraps it for rates). When adding connection behavior, emit a status message for each new phase or failure path — there's a test asserting the server's phase messages. Everything the pane shows is also written to a rotating debug log file (`logs.py`; `logs.init_logging("remotedesktop")` is called only by `app.main()`, under `platformdirs.user_log_dir("remotedesktop")` — `%LOCALAPPDATA%\remotedesktop\Logs\remotedesktop.log` on Windows; pre-1.0 `client.log`/`server.log` files just age out). Lower-level detail (socket state changes, protocol aborts, frame-drop/backlog, discovery datagram drops) goes straight to `logging.getLogger("remotedesktop.*")` at debug/warning level — silent failure paths should log there even when a status message would be too noisy. Library code and tests never call `init_logging`; without a handler those loggers are no-ops.
|
|
99
|
+
- **Status/debug logging is a feature**: `ShareServer`, `ShareClient`, `SharingTab`, and `DiscoveryPanel` emit human-readable `status`/`statusMessage` signals for every connection phase, shown in the window's timestamped "Connection log" pane. **Byte counts in any user-facing string go through `humanize.naturalsize(n, binary=True)`** ("2.0 KiB", "1.3 MiB") — never hand-rolled `// 1024` math (the `humanize` package is a dependency; `performance.format_rate` wraps it for rates). When adding connection behavior, emit a status message for each new phase or failure path — there's a test asserting the server's phase messages. Everything the pane shows is also written to a rotating debug log file (`logs.py`; `logs.init_logging("remotedesktop")` is called only by `app.main()`, under `platformdirs.user_log_dir("remotedesktop")` — `%LOCALAPPDATA%\remotedesktop\Logs\remotedesktop.log` on Windows; pre-1.0 `client.log`/`server.log` files just age out). Lower-level detail (socket state changes, protocol aborts, frame-drop/backlog, discovery datagram drops) goes straight to `logging.getLogger("remotedesktop.*")` at debug/warning level — silent failure paths should log there even when a status message would be too noisy. Library code and tests never call `init_logging`; without a handler those loggers are no-ops. **Unhandled exceptions** go through `crash_report.CrashReporter` (installed by `main()` as `sys.excepthook`/`threading.excepthook`, since a pythonw start has no stderr): always logged with traceback; before `app.exec()` and after it returns they are fatal and also get a native message box pointing at the log; inside the event loop (slot errors — Qt keeps running) and on worker threads they are log-only. Tests inject `alert=`/`chain=` so they never pop a box.
|
|
100
100
|
- **Connection inventory** (`inventory.py`): the Connections tab has two grouped tables ("Client history" for the sharing role, "Server history" for the viewing role), each backed by its own `ConnectionInventory` — one `PeerRecord` per peer with attempts count, current state, first/last seen. The sharing side is driven by `ShareServer.peerEvent` (a structured `{key, event, name, address, detail}` signal emitted alongside the status strings at each phase — keep both in sync when adding phases), forwarded through `SharingTab.peerEvent`. The viewing side is driven by `MainWindow`'s own handlers (discovered servers from `DiscoveryPanel.serversFound`, plus attempt/connected/denied/disconnected). Server peers key by client-id; client peers key by `host:port`. **Persisted in SQLite** and reloaded on startup, so it survives restarts.
|
|
101
101
|
- **Revoking access**: `InventoryTab` takes an optional `(action_label, action_callback)`; each row gets its own button labeled `action_label` (in the trailing stretch column) whose callback receives that row's peer key. "Client history" wires "Revoke" → `SharingTab.revoke_client(client_id)` (via `ShareServer.revoke_client` while sharing — removes the token via `PairedClients.revoke`, disconnects any live stream with a `denied: access revoked`, and `_drop` emits a "revoked" peer event; a `_revoked` set distinguishes it from a plain disconnect — or directly via `PairedClients.revoke` while not sharing). "Server history" wires "Forget" → `KnownServers.forget(key)` + disconnect + `ConnectionInventory.remove(key)`. **Both delete the peer's row from the table and the DB outright** (`MainWindow._record_server_peer` turns a "revoked" event into a `remove`); a rescan or new connection attempt records the peer afresh. After either, the next connection needs a fresh approval. The roles use *separate tables* (`server_peers` / `client_peers`, selected via the `table=` arg) so they don't commingle; `peers` is the default table used by tests. `ConnectionInventory` never lets a DB error break connectivity (load/save are wrapped).
|
|
102
102
|
- Sharing tests drive real sockets on the GUI thread by pumping `qapp.processEvents()` until a condition holds (see `pump()` in `tests/test_sharing.py`). Use the `make_server`/`make_client` helpers there and the session-scoped `credentials` fixture (one generated cert reused across tests — generating per test is ~100ms each). Tests exercise the real pairing flow (auto-approve), so there is no "pre-approve" shortcut anymore.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: remotedesktop
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.5.1
|
|
4
4
|
Summary: Remote desktop app for Windows computers on the same LAN, with autodiscovery. View other computers and optionally share this one — screen, keyboard, mouse, and clipboard, without RDP or Microsoft authentication.
|
|
5
5
|
Project-URL: Homepage, https://github.com/jamesabel/remotedesktop
|
|
6
6
|
Project-URL: Repository, https://github.com/jamesabel/remotedesktop
|
|
@@ -44,11 +44,12 @@ from PySide6.QtWidgets import (
|
|
|
44
44
|
QWidget,
|
|
45
45
|
)
|
|
46
46
|
|
|
47
|
-
from remotedesktop import __version__, compat, db, icon, logs, window_state
|
|
47
|
+
from remotedesktop import __version__, compat, console_window, db, icon, logs, window_state
|
|
48
48
|
from remotedesktop.about import AboutDialog
|
|
49
49
|
from remotedesktop.autostart import Autostart, installed_launcher
|
|
50
50
|
from remotedesktop.client import DiscoveryPanel, ServerSession, _broadcast_hosts
|
|
51
51
|
from remotedesktop.clipboard import ClipboardSync
|
|
52
|
+
from remotedesktop.crash_report import CrashReporter
|
|
52
53
|
from remotedesktop.config import KnownServers, Settings, default_db_path, load_client_identity
|
|
53
54
|
from remotedesktop.discovery import (
|
|
54
55
|
DEFAULT_CONNECT_PORT,
|
|
@@ -1606,6 +1607,11 @@ def main() -> None: # pragma: no cover - runs the Qt event loop
|
|
|
1606
1607
|
minimized = "--minimized" in sys.argv[1:]
|
|
1607
1608
|
maximized = "--maximized" in sys.argv[1:]
|
|
1608
1609
|
log_path = logs.init_logging("remotedesktop")
|
|
1610
|
+
# A windowless (pythonw) start has no console: log unhandled exceptions,
|
|
1611
|
+
# and say so in a message box when one stops the app.
|
|
1612
|
+
crashes = CrashReporter(log_path)
|
|
1613
|
+
crashes.install()
|
|
1614
|
+
hid_console = console_window.hide_unwanted_console()
|
|
1609
1615
|
icon.set_windows_app_id("remotedesktop")
|
|
1610
1616
|
app = QApplication(sys.argv)
|
|
1611
1617
|
app.setWindowIcon(icon.app_icon("app"))
|
|
@@ -1619,6 +1625,17 @@ def main() -> None: # pragma: no cover - runs the Qt event loop
|
|
|
1619
1625
|
window = MainWindow()
|
|
1620
1626
|
guard.activateRequested.connect(window.bring_to_front)
|
|
1621
1627
|
window.log(f"Detailed log: {log_path}")
|
|
1628
|
+
# A broken venv (uv 0.11) opens a console window at every windowless
|
|
1629
|
+
# launch, login autostart included — say so where the user will see it.
|
|
1630
|
+
if hid_console:
|
|
1631
|
+
window.log("Started as pythonw but had a console window; hid it")
|
|
1632
|
+
pythonw = console_window.broken_pythonw()
|
|
1633
|
+
if pythonw is not None:
|
|
1634
|
+
window.log(
|
|
1635
|
+
f"{pythonw} is a console program, so a console window opens whenever "
|
|
1636
|
+
"the app starts (including at login): quit the app, delete .venv and "
|
|
1637
|
+
"run uv sync"
|
|
1638
|
+
)
|
|
1622
1639
|
if minimized and window.sharing_tab.serving and window._tray is not None:
|
|
1623
1640
|
# Login-started while sharing: live in the tray until summoned.
|
|
1624
1641
|
window._tray_notified = True # no balloon for a start nobody clicked
|
|
@@ -1629,7 +1646,10 @@ def main() -> None: # pragma: no cover - runs the Qt event loop
|
|
|
1629
1646
|
window.showMaximized()
|
|
1630
1647
|
else:
|
|
1631
1648
|
window.show()
|
|
1632
|
-
|
|
1649
|
+
crashes.fatal = False # in the event loop, a slot error doesn't stop the app
|
|
1650
|
+
exit_code = app.exec()
|
|
1651
|
+
crashes.fatal = True
|
|
1652
|
+
raise SystemExit(exit_code)
|
|
1633
1653
|
|
|
1634
1654
|
|
|
1635
1655
|
if __name__ == "__main__": # pragma: no cover
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Hide a console window the app was never meant to have.
|
|
2
|
+
|
|
3
|
+
The app is started windowless — the `remotedesktop` gui-script, autostart,
|
|
4
|
+
and restart all run it under `pythonw.exe`. A broken venv can still hand
|
|
5
|
+
it a console: uv 0.11 built venvs whose `Scripts\\pythonw.exe` was a copy of
|
|
6
|
+
the console `python.exe` launcher, so every login opened a stray console
|
|
7
|
+
window next to the app. Rebuilding the venv is the real fix
|
|
8
|
+
(`broken_pythonw` detects the fault so the app can say so); hiding the
|
|
9
|
+
window is the safety net.
|
|
10
|
+
|
|
11
|
+
The rule is explicit: running as `pythonw.exe` means "no console wanted",
|
|
12
|
+
so any console window present is hidden (or, under Windows Terminal, which
|
|
13
|
+
won't hide, minimized). `python -m remotedesktop` from a
|
|
14
|
+
terminal (the way to see stdout/tracebacks) is left alone.
|
|
15
|
+
|
|
16
|
+
On non-Windows platforms this is an inert stub.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import ctypes
|
|
20
|
+
import struct
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path, PureWindowsPath
|
|
23
|
+
|
|
24
|
+
_IS_WINDOWS = sys.platform == "win32"
|
|
25
|
+
|
|
26
|
+
# winuser.h ShowWindow commands
|
|
27
|
+
_SW_HIDE = 0
|
|
28
|
+
_SW_MINIMIZE = 6
|
|
29
|
+
|
|
30
|
+
_IMAGE_SUBSYSTEM_WINDOWS_CUI = 3 # PE optional-header subsystem: a console program
|
|
31
|
+
|
|
32
|
+
if _IS_WINDOWS:
|
|
33
|
+
_kernel32 = ctypes.windll.kernel32
|
|
34
|
+
_kernel32.GetConsoleWindow.restype = ctypes.c_void_p
|
|
35
|
+
_kernel32.GetConsoleWindow.argtypes = ()
|
|
36
|
+
_user32 = ctypes.windll.user32
|
|
37
|
+
_user32.ShowWindow.argtypes = (ctypes.c_void_p, ctypes.c_int)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def wants_no_console(executable: str) -> bool:
|
|
41
|
+
"""True when the app runs under an interpreter meant to be windowless."""
|
|
42
|
+
# PureWindowsPath: a Windows path parses the same on any OS (Path would not split
|
|
43
|
+
# on backslashes off Windows)
|
|
44
|
+
return PureWindowsPath(executable).name.lower() == "pythonw.exe"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def is_console_program(exe: Path) -> bool:
|
|
48
|
+
"""True when `exe`'s PE header says it is a console program.
|
|
49
|
+
|
|
50
|
+
False when the file can't be read or isn't a PE image.
|
|
51
|
+
"""
|
|
52
|
+
try:
|
|
53
|
+
with exe.open("rb") as file:
|
|
54
|
+
header = file.read(4096)
|
|
55
|
+
except OSError:
|
|
56
|
+
return False
|
|
57
|
+
if len(header) < 0x40 or header[:2] != b"MZ":
|
|
58
|
+
return False
|
|
59
|
+
pe_offset = struct.unpack_from("<I", header, 0x3C)[0]
|
|
60
|
+
# PE signature (4) + COFF header (20), then the optional header's
|
|
61
|
+
# Subsystem field at offset 68.
|
|
62
|
+
subsystem_offset = pe_offset + 24 + 68
|
|
63
|
+
if header[pe_offset : pe_offset + 4] != b"PE\0\0" or len(header) < subsystem_offset + 2:
|
|
64
|
+
return False
|
|
65
|
+
(subsystem,) = struct.unpack_from("<H", header, subsystem_offset)
|
|
66
|
+
return subsystem == _IMAGE_SUBSYSTEM_WINDOWS_CUI
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def broken_pythonw(executable: str | None = None) -> Path | None:
|
|
70
|
+
"""The `pythonw.exe` beside this interpreter, if it is really a console
|
|
71
|
+
program (the uv 0.11 fault) — every windowless launch from this
|
|
72
|
+
environment then opens a console. None when it is fine or absent.
|
|
73
|
+
"""
|
|
74
|
+
pythonw = Path(sys.executable if executable is None else executable).with_name("pythonw.exe")
|
|
75
|
+
return pythonw if is_console_program(pythonw) else None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def hide_unwanted_console(executable: str | None = None) -> bool:
|
|
79
|
+
"""Hide this process's console window if it runs as pythonw.
|
|
80
|
+
|
|
81
|
+
Returns True when a console window was found and hidden/minimized.
|
|
82
|
+
"""
|
|
83
|
+
if not _IS_WINDOWS:
|
|
84
|
+
return False
|
|
85
|
+
if not wants_no_console(sys.executable if executable is None else executable):
|
|
86
|
+
return False
|
|
87
|
+
hwnd = _kernel32.GetConsoleWindow()
|
|
88
|
+
if not hwnd:
|
|
89
|
+
return False # the normal case: pythonw has no console
|
|
90
|
+
# Minimize first, then hide: the classic console host (conhost) honors
|
|
91
|
+
# both and ends up hidden; Windows Terminal ignores hide requests on its
|
|
92
|
+
# pseudo-console window but honors minimize, so it ends up minimized.
|
|
93
|
+
_user32.ShowWindow(hwnd, _SW_MINIMIZE)
|
|
94
|
+
_user32.ShowWindow(hwnd, _SW_HIDE)
|
|
95
|
+
return True
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Report unhandled exceptions from a windowless app.
|
|
2
|
+
|
|
3
|
+
Under `pythonw.exe` (the gui-script, autostart at login, restart) there is
|
|
4
|
+
no console, so an unhandled exception — the default hook prints it to a
|
|
5
|
+
stderr that goes nowhere — would make the app silently fail to appear.
|
|
6
|
+
`CrashReporter` replaces `sys.excepthook` and `threading.excepthook`:
|
|
7
|
+
|
|
8
|
+
- every unhandled exception is written to the debug log with its traceback;
|
|
9
|
+
- while the app is still starting (before the event loop runs) or after it
|
|
10
|
+
has stopped, the exception is fatal, so a message box also says what
|
|
11
|
+
happened and where the log is;
|
|
12
|
+
- inside the event loop, an exception escaping a Qt slot doesn't stop the
|
|
13
|
+
app (Qt carries on), so it is only logged — a failing timer slot must not
|
|
14
|
+
stack up modal boxes. Worker-thread exceptions are likewise log-only.
|
|
15
|
+
|
|
16
|
+
The default hook still runs afterwards, so `python -m remotedesktop` from a
|
|
17
|
+
terminal prints tracebacks as before.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
import sys
|
|
22
|
+
import threading
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from types import TracebackType
|
|
26
|
+
|
|
27
|
+
_log = logging.getLogger("remotedesktop.crash")
|
|
28
|
+
|
|
29
|
+
_MB_ICONERROR = 0x10
|
|
30
|
+
_MB_SETFOREGROUND = 0x10000
|
|
31
|
+
_MB_TOPMOST = 0x40000
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def show_error(message: str) -> None:
|
|
35
|
+
"""A native message box — works even if Qt itself failed to start."""
|
|
36
|
+
if sys.platform == "win32":
|
|
37
|
+
import ctypes
|
|
38
|
+
|
|
39
|
+
ctypes.windll.user32.MessageBoxW(
|
|
40
|
+
None,
|
|
41
|
+
message,
|
|
42
|
+
"Remote Desktop",
|
|
43
|
+
_MB_ICONERROR | _MB_SETFOREGROUND | _MB_TOPMOST,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CrashReporter:
|
|
48
|
+
"""Log unhandled exceptions; alert on the fatal ones.
|
|
49
|
+
|
|
50
|
+
`fatal` is True until the host sets it False as the event loop starts,
|
|
51
|
+
and back to True once it returns. `alert` and `chain` are injectable so
|
|
52
|
+
tests never open a message box or print tracebacks.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
log_path: Path,
|
|
58
|
+
*,
|
|
59
|
+
alert: Callable[[str], None] = show_error,
|
|
60
|
+
chain: Callable[
|
|
61
|
+
[type[BaseException], BaseException, TracebackType | None], object
|
|
62
|
+
] = sys.__excepthook__,
|
|
63
|
+
) -> None:
|
|
64
|
+
self.log_path = log_path
|
|
65
|
+
self.fatal = True
|
|
66
|
+
self._alert = alert
|
|
67
|
+
self._chain = chain
|
|
68
|
+
|
|
69
|
+
def install(self) -> None:
|
|
70
|
+
sys.excepthook = self.excepthook
|
|
71
|
+
threading.excepthook = self.thread_excepthook
|
|
72
|
+
|
|
73
|
+
def excepthook(
|
|
74
|
+
self,
|
|
75
|
+
exc_type: type[BaseException],
|
|
76
|
+
exc: BaseException,
|
|
77
|
+
tb: TracebackType | None,
|
|
78
|
+
) -> None:
|
|
79
|
+
if self.fatal:
|
|
80
|
+
_log.critical("Unhandled exception; the app cannot continue", exc_info=(exc_type, exc, tb))
|
|
81
|
+
self._alert(
|
|
82
|
+
"Remote Desktop stopped because of an unexpected error:\n\n"
|
|
83
|
+
f"{exc_type.__name__}: {exc}\n\n"
|
|
84
|
+
f"Details are in the log:\n{self.log_path}"
|
|
85
|
+
)
|
|
86
|
+
else:
|
|
87
|
+
_log.error("Unhandled exception in an event handler", exc_info=(exc_type, exc, tb))
|
|
88
|
+
self._chain(exc_type, exc, tb)
|
|
89
|
+
|
|
90
|
+
def thread_excepthook(self, args: threading.ExceptHookArgs) -> None:
|
|
91
|
+
if args.exc_type is SystemExit:
|
|
92
|
+
return # a thread exiting on purpose, as threading's own hook treats it
|
|
93
|
+
name = args.thread.name if args.thread is not None else "unknown"
|
|
94
|
+
exc = args.exc_value if args.exc_value is not None else args.exc_type()
|
|
95
|
+
_log.error(
|
|
96
|
+
"Unhandled exception in thread %s",
|
|
97
|
+
name,
|
|
98
|
+
exc_info=(args.exc_type, exc, args.exc_traceback),
|
|
99
|
+
)
|
|
100
|
+
self._chain(args.exc_type, exc, args.exc_traceback)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Tests for hiding an unwanted console window.
|
|
2
|
+
|
|
3
|
+
All tests here are AI-authored. The Win32 calls are faked (the `win32`
|
|
4
|
+
fixture), so no test can hide or minimize the terminal running pytest.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import struct
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path, PurePosixPath
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from remotedesktop import console_window
|
|
14
|
+
from remotedesktop.console_window import (
|
|
15
|
+
broken_pythonw,
|
|
16
|
+
hide_unwanted_console,
|
|
17
|
+
is_console_program,
|
|
18
|
+
wants_no_console,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _pe_image(subsystem: int, pe_offset: int = 0x80) -> bytes:
|
|
23
|
+
"""A minimal PE header: just enough for the subsystem field to be read."""
|
|
24
|
+
image = bytearray(pe_offset + 24 + 68 + 2)
|
|
25
|
+
image[:2] = b"MZ"
|
|
26
|
+
struct.pack_into("<I", image, 0x3C, pe_offset)
|
|
27
|
+
image[pe_offset : pe_offset + 4] = b"PE\0\0"
|
|
28
|
+
struct.pack_into("<H", image, pe_offset + 24 + 68, subsystem)
|
|
29
|
+
return bytes(image)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _FakeKernel32:
|
|
33
|
+
def __init__(self, hwnd):
|
|
34
|
+
self.hwnd = hwnd
|
|
35
|
+
|
|
36
|
+
def GetConsoleWindow(self): # noqa: N802 - Win32 name
|
|
37
|
+
return self.hwnd
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _FakeUser32:
|
|
41
|
+
def __init__(self):
|
|
42
|
+
self.calls = []
|
|
43
|
+
|
|
44
|
+
def ShowWindow(self, hwnd, command): # noqa: N802 - Win32 name
|
|
45
|
+
self.calls.append((hwnd, command))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# AI-GENERATED TEST (Claude Code) - delete this line to make this test human-owned.
|
|
49
|
+
@pytest.fixture
|
|
50
|
+
def win32(monkeypatch):
|
|
51
|
+
"""Fake Windows console APIs; set `.kernel32.hwnd` to simulate a console."""
|
|
52
|
+
kernel32, user32 = _FakeKernel32(hwnd=1234), _FakeUser32()
|
|
53
|
+
monkeypatch.setattr(console_window, "_IS_WINDOWS", True)
|
|
54
|
+
monkeypatch.setattr(console_window, "_kernel32", kernel32, raising=False)
|
|
55
|
+
monkeypatch.setattr(console_window, "_user32", user32, raising=False)
|
|
56
|
+
return kernel32, user32
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# AI-GENERATED TEST (Claude Code) - delete this line to make this test human-owned.
|
|
60
|
+
@pytest.mark.parametrize(
|
|
61
|
+
("executable", "expected"),
|
|
62
|
+
[
|
|
63
|
+
(r"C:\proj\.venv\Scripts\pythonw.exe", True),
|
|
64
|
+
(r"C:\proj\.venv\Scripts\PYTHONW.EXE", True),
|
|
65
|
+
(r"C:\proj\.venv\Scripts\python.exe", False),
|
|
66
|
+
(r"C:\apps\remotedesktop_2.4.0\python.exe", False),
|
|
67
|
+
],
|
|
68
|
+
)
|
|
69
|
+
def test_wants_no_console_only_for_pythonw(executable, expected):
|
|
70
|
+
assert wants_no_console(executable) is expected
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# AI-GENERATED TEST (Claude Code) - delete this line to make this test human-owned.
|
|
74
|
+
def test_wants_no_console_parses_windows_paths_off_windows(monkeypatch):
|
|
75
|
+
# A POSIX Path doesn't split on backslashes, so its name would be the whole
|
|
76
|
+
# string; the check must not depend on the host's Path flavour.
|
|
77
|
+
monkeypatch.setattr(console_window, "Path", PurePosixPath)
|
|
78
|
+
assert wants_no_console(r"C:\proj\.venv\Scripts\pythonw.exe") is True
|
|
79
|
+
assert wants_no_console(r"C:\proj\.venv\Scripts\python.exe") is False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# AI-GENERATED TEST (Claude Code) - delete this line to make this test human-owned.
|
|
83
|
+
def test_pythonw_with_console_minimizes_then_hides(win32):
|
|
84
|
+
_kernel32, user32 = win32
|
|
85
|
+
assert hide_unwanted_console(r"C:\venv\Scripts\pythonw.exe") is True
|
|
86
|
+
# Minimize before hide: conhost ends hidden, Windows Terminal minimized.
|
|
87
|
+
assert user32.calls == [
|
|
88
|
+
(1234, console_window._SW_MINIMIZE),
|
|
89
|
+
(1234, console_window._SW_HIDE),
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# AI-GENERATED TEST (Claude Code) - delete this line to make this test human-owned.
|
|
94
|
+
def test_python_exe_console_is_left_alone(win32):
|
|
95
|
+
_kernel32, user32 = win32
|
|
96
|
+
assert hide_unwanted_console(r"C:\venv\Scripts\python.exe") is False
|
|
97
|
+
assert user32.calls == []
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# AI-GENERATED TEST (Claude Code) - delete this line to make this test human-owned.
|
|
101
|
+
def test_pythonw_without_console_does_nothing(win32):
|
|
102
|
+
kernel32, user32 = win32
|
|
103
|
+
kernel32.hwnd = None
|
|
104
|
+
assert hide_unwanted_console(r"C:\venv\Scripts\pythonw.exe") is False
|
|
105
|
+
assert user32.calls == []
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# AI-GENERATED TEST (Claude Code) - delete this line to make this test human-owned.
|
|
109
|
+
def test_inert_off_windows(win32, monkeypatch):
|
|
110
|
+
_kernel32, user32 = win32
|
|
111
|
+
monkeypatch.setattr(console_window, "_IS_WINDOWS", False)
|
|
112
|
+
assert hide_unwanted_console(r"C:\venv\Scripts\pythonw.exe") is False
|
|
113
|
+
assert user32.calls == []
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# AI-GENERATED TEST (Claude Code) - delete this line to make this test human-owned.
|
|
117
|
+
@pytest.mark.parametrize(("subsystem", "expected"), [(3, True), (2, False)])
|
|
118
|
+
def test_is_console_program_reads_the_pe_subsystem(tmp_path, subsystem, expected):
|
|
119
|
+
exe = tmp_path / "pythonw.exe"
|
|
120
|
+
exe.write_bytes(_pe_image(subsystem))
|
|
121
|
+
assert is_console_program(exe) is expected
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# AI-GENERATED TEST (Claude Code) - delete this line to make this test human-owned.
|
|
125
|
+
@pytest.mark.parametrize(
|
|
126
|
+
"content",
|
|
127
|
+
[
|
|
128
|
+
b"", # empty
|
|
129
|
+
b"not an executable" * 10, # no MZ
|
|
130
|
+
_pe_image(3)[:0x90], # truncated before the subsystem field
|
|
131
|
+
_pe_image(3).replace(b"PE\0\0", b"XX\0\0"), # bad PE signature
|
|
132
|
+
],
|
|
133
|
+
)
|
|
134
|
+
def test_is_console_program_is_false_for_non_pe_files(tmp_path, content):
|
|
135
|
+
exe = tmp_path / "pythonw.exe"
|
|
136
|
+
exe.write_bytes(content)
|
|
137
|
+
assert is_console_program(exe) is False
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# AI-GENERATED TEST (Claude Code) - delete this line to make this test human-owned.
|
|
141
|
+
def test_is_console_program_is_false_for_a_missing_file(tmp_path):
|
|
142
|
+
assert is_console_program(tmp_path / "missing.exe") is False
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# AI-GENERATED TEST (Claude Code) - delete this line to make this test human-owned.
|
|
146
|
+
@pytest.mark.skipif(sys.platform != "win32", reason="real Windows interpreters")
|
|
147
|
+
def test_is_console_program_on_the_real_interpreters():
|
|
148
|
+
base = Path(sys.base_prefix) # the real CPython install, not a venv shim
|
|
149
|
+
assert is_console_program(base / "python.exe") is True
|
|
150
|
+
assert is_console_program(base / "pythonw.exe") is False
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# AI-GENERATED TEST (Claude Code) - delete this line to make this test human-owned.
|
|
154
|
+
@pytest.mark.parametrize(("subsystem", "broken"), [(3, True), (2, False)])
|
|
155
|
+
def test_broken_pythonw_checks_the_sibling_pythonw(tmp_path, subsystem, broken):
|
|
156
|
+
(tmp_path / "pythonw.exe").write_bytes(_pe_image(subsystem))
|
|
157
|
+
expected = tmp_path / "pythonw.exe" if broken else None
|
|
158
|
+
assert broken_pythonw(str(tmp_path / "python.exe")) == expected
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# AI-GENERATED TEST (Claude Code) - delete this line to make this test human-owned.
|
|
162
|
+
def test_broken_pythonw_is_none_without_a_pythonw(tmp_path):
|
|
163
|
+
assert broken_pythonw(str(tmp_path / "python.exe")) is None
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Tests for crash reporting.
|
|
2
|
+
|
|
3
|
+
All tests here are AI-authored. `alert` and `chain` are always injected, so
|
|
4
|
+
no test opens a message box or prints a traceback; hooks are never installed
|
|
5
|
+
globally except in the install test, which restores them.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import sys
|
|
10
|
+
import threading
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from remotedesktop.crash_report import CrashReporter
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _raised(exc: BaseException):
|
|
18
|
+
try:
|
|
19
|
+
raise exc
|
|
20
|
+
except type(exc) as caught:
|
|
21
|
+
return type(caught), caught, caught.__traceback__
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# AI-GENERATED TEST (Claude Code) - delete this line to make this test human-owned.
|
|
25
|
+
@pytest.fixture
|
|
26
|
+
def reporter(tmp_path):
|
|
27
|
+
alerts: list[str] = []
|
|
28
|
+
chained: list[type[BaseException]] = []
|
|
29
|
+
crash = CrashReporter(
|
|
30
|
+
tmp_path / "remotedesktop.log",
|
|
31
|
+
alert=alerts.append,
|
|
32
|
+
chain=lambda exc_type, exc, tb: chained.append(exc_type),
|
|
33
|
+
)
|
|
34
|
+
return crash, alerts, chained
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# AI-GENERATED TEST (Claude Code) - delete this line to make this test human-owned.
|
|
38
|
+
def test_fatal_exception_is_logged_alerted_and_chained(reporter, caplog, tmp_path):
|
|
39
|
+
crash, alerts, chained = reporter
|
|
40
|
+
with caplog.at_level(logging.DEBUG, logger="remotedesktop.crash"):
|
|
41
|
+
crash.excepthook(*_raised(ValueError("bad config")))
|
|
42
|
+
assert len(alerts) == 1
|
|
43
|
+
assert "ValueError: bad config" in alerts[0]
|
|
44
|
+
assert str(tmp_path / "remotedesktop.log") in alerts[0]
|
|
45
|
+
assert chained == [ValueError]
|
|
46
|
+
[record] = caplog.records
|
|
47
|
+
assert record.levelno == logging.CRITICAL
|
|
48
|
+
assert record.exc_info[1].args == ("bad config",)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# AI-GENERATED TEST (Claude Code) - delete this line to make this test human-owned.
|
|
52
|
+
def test_event_loop_exception_is_logged_only(reporter, caplog):
|
|
53
|
+
crash, alerts, chained = reporter
|
|
54
|
+
crash.fatal = False
|
|
55
|
+
with caplog.at_level(logging.DEBUG, logger="remotedesktop.crash"):
|
|
56
|
+
crash.excepthook(*_raised(KeyError("slot")))
|
|
57
|
+
assert alerts == []
|
|
58
|
+
assert chained == [KeyError]
|
|
59
|
+
[record] = caplog.records
|
|
60
|
+
assert record.levelno == logging.ERROR
|
|
61
|
+
assert record.exc_info[0] is KeyError
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# AI-GENERATED TEST (Claude Code) - delete this line to make this test human-owned.
|
|
65
|
+
def test_worker_thread_exception_is_logged_not_alerted(reporter, caplog):
|
|
66
|
+
crash, alerts, chained = reporter
|
|
67
|
+
|
|
68
|
+
def worker():
|
|
69
|
+
raise RuntimeError("scan failed")
|
|
70
|
+
|
|
71
|
+
thread = threading.Thread(target=worker, name="discovery-scan")
|
|
72
|
+
previous = threading.excepthook
|
|
73
|
+
threading.excepthook = crash.thread_excepthook
|
|
74
|
+
try:
|
|
75
|
+
with caplog.at_level(logging.DEBUG, logger="remotedesktop.crash"):
|
|
76
|
+
thread.start()
|
|
77
|
+
thread.join()
|
|
78
|
+
finally:
|
|
79
|
+
threading.excepthook = previous
|
|
80
|
+
assert alerts == []
|
|
81
|
+
assert chained == [RuntimeError]
|
|
82
|
+
[record] = caplog.records
|
|
83
|
+
assert "discovery-scan" in record.getMessage()
|
|
84
|
+
assert record.exc_info[0] is RuntimeError
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# AI-GENERATED TEST (Claude Code) - delete this line to make this test human-owned.
|
|
88
|
+
def test_thread_system_exit_is_ignored(reporter, caplog):
|
|
89
|
+
crash, alerts, chained = reporter
|
|
90
|
+
|
|
91
|
+
def worker():
|
|
92
|
+
raise SystemExit
|
|
93
|
+
|
|
94
|
+
thread = threading.Thread(target=worker)
|
|
95
|
+
previous = threading.excepthook
|
|
96
|
+
threading.excepthook = crash.thread_excepthook
|
|
97
|
+
try:
|
|
98
|
+
with caplog.at_level(logging.DEBUG, logger="remotedesktop.crash"):
|
|
99
|
+
thread.start()
|
|
100
|
+
thread.join()
|
|
101
|
+
finally:
|
|
102
|
+
threading.excepthook = previous
|
|
103
|
+
assert (alerts, chained, caplog.records) == ([], [], [])
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# AI-GENERATED TEST (Claude Code) - delete this line to make this test human-owned.
|
|
107
|
+
def test_install_replaces_both_hooks(reporter, monkeypatch):
|
|
108
|
+
crash, _alerts, _chained = reporter
|
|
109
|
+
monkeypatch.setattr(sys, "excepthook", sys.excepthook)
|
|
110
|
+
monkeypatch.setattr(threading, "excepthook", threading.excepthook)
|
|
111
|
+
crash.install()
|
|
112
|
+
assert sys.excepthook == crash.excepthook
|
|
113
|
+
assert threading.excepthook == crash.thread_excepthook
|
|
@@ -331,7 +331,7 @@ def test_old_peer_leaves_rtt_series_empty(qapp, credentials, tmp_path):
|
|
|
331
331
|
client.frameReceived.connect(frames.append)
|
|
332
332
|
client.connect_to("127.0.0.1", server.port)
|
|
333
333
|
try:
|
|
334
|
-
pump(qapp, lambda: len(client_perf.recv_bps.samples()) >= 2)
|
|
334
|
+
pump(qapp, lambda: len(client_perf.recv_bps.samples()) >= 2 and frames)
|
|
335
335
|
assert client_perf.rtt_ms.latest() is None
|
|
336
336
|
assert client_perf.peer_rtt_ms.latest() is None
|
|
337
337
|
assert frames # unanswered pings don't harm the connection
|
|
@@ -530,11 +530,11 @@ wheels = [
|
|
|
530
530
|
|
|
531
531
|
[[package]]
|
|
532
532
|
name = "platformdirs"
|
|
533
|
-
version = "4.11.
|
|
533
|
+
version = "4.11.10"
|
|
534
534
|
source = { registry = "https://pypi.org/simple" }
|
|
535
|
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
|
535
|
+
sdist = { url = "https://files.pythonhosted.org/packages/89/24/92d90bebedf197eb15b144367ce6fd4ad2de571927cd09dde190a36db8fc/platformdirs-4.11.10.tar.gz", hash = "sha256:9cd351c078ccf7dda1fdc5f8ccb9d8f5258984c63990e6df3627dde0b70b51d0", size = 39389, upload-time = "2026-09-18T01:45:15.107Z" }
|
|
536
536
|
wheels = [
|
|
537
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
537
|
+
{ url = "https://files.pythonhosted.org/packages/67/45/cae23ec98a49825d58de75c38cdb714605adeb6e6ab72a7ad2b28322892c/platformdirs-4.11.10-py3-none-any.whl", hash = "sha256:973c4c082b964958c71588448488d2b14fb253893dfbdb82325a14c84e540e8f", size = 24763, upload-time = "2026-09-18T01:45:13.878Z" },
|
|
538
538
|
]
|
|
539
539
|
|
|
540
540
|
[[package]]
|
|
@@ -772,27 +772,27 @@ wheels = [
|
|
|
772
772
|
|
|
773
773
|
[[package]]
|
|
774
774
|
name = "ruff"
|
|
775
|
-
version = "0.16.
|
|
776
|
-
source = { registry = "https://pypi.org/simple" }
|
|
777
|
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
|
778
|
-
wheels = [
|
|
779
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
780
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
781
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
782
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
783
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
784
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
785
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
786
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
787
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
788
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
789
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
790
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
791
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
792
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
793
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
794
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
795
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
775
|
+
version = "0.16.8"
|
|
776
|
+
source = { registry = "https://pypi.org/simple" }
|
|
777
|
+
sdist = { url = "https://files.pythonhosted.org/packages/ba/78/449cb84790bd5cc3823b2652ee405a4558856e5c4195aee3a16bf7b3eb5d/ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b", size = 4938814, upload-time = "2026-09-16T15:54:46.688Z" }
|
|
778
|
+
wheels = [
|
|
779
|
+
{ url = "https://files.pythonhosted.org/packages/ac/25/6071aabc530e9be7e2c195e8fe3f7aea2735405b6cf447212832d7811831/ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38", size = 10048966, upload-time = "2026-09-16T15:53:57.605Z" },
|
|
780
|
+
{ url = "https://files.pythonhosted.org/packages/54/98/07f90ecbc74dd5fb5764f11f2bc774d6a7cffef92d2ff5f5b4e9e23c754e/ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e", size = 10165498, upload-time = "2026-09-16T15:54:01.14Z" },
|
|
781
|
+
{ url = "https://files.pythonhosted.org/packages/fe/1f/e6a712e3b47cad4a40600134105ed193cb773f618a42eb7ba323cb812cc0/ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170", size = 9830004, upload-time = "2026-09-16T15:54:03.998Z" },
|
|
782
|
+
{ url = "https://files.pythonhosted.org/packages/23/f2/311a08776d75d81c7676e20b6b020ae63cbe881fcdc7a8dd64e6e18bdd93/ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659", size = 9986558, upload-time = "2026-09-16T15:54:06.804Z" },
|
|
783
|
+
{ url = "https://files.pythonhosted.org/packages/f3/ed/37b6cb3d3ba8c73e68ae3eb1d502383beb5aa05a582bb7bb3a922f929f54/ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2", size = 9877332, upload-time = "2026-09-16T15:54:09.552Z" },
|
|
784
|
+
{ url = "https://files.pythonhosted.org/packages/22/cc/40873a8f36ad084cc540d55fcca7077264d5b13b24659e9180c176fb2b08/ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812", size = 10507125, upload-time = "2026-09-16T15:54:12.152Z" },
|
|
785
|
+
{ url = "https://files.pythonhosted.org/packages/c3/e4/fc91a642b78ccbab6b9477720f3644ae7a10a9bcce69a934679cd64f62bc/ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b", size = 11336694, upload-time = "2026-09-16T15:54:15.489Z" },
|
|
786
|
+
{ url = "https://files.pythonhosted.org/packages/c2/3d/bbd2a9a600a4e73dc3e7548a249c8d1671273464b55822c6fae50f602dff/ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f", size = 10774448, upload-time = "2026-09-16T15:54:18.16Z" },
|
|
787
|
+
{ url = "https://files.pythonhosted.org/packages/1a/41/d83af9879a7b6e8bf5fe16b1da0b134049d2f5d3afac12defb0897cb84bd/ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df", size = 10323796, upload-time = "2026-09-16T15:54:20.743Z" },
|
|
788
|
+
{ url = "https://files.pythonhosted.org/packages/f5/2c/cefd07bfe914b84943ea769ade8d607bd22750b965d3228eefd7cebd15d0/ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e", size = 10514115, upload-time = "2026-09-16T15:54:23.497Z" },
|
|
789
|
+
{ url = "https://files.pythonhosted.org/packages/f3/9d/76a2e26c79a23be6e6e3664c57bec9e9fc8de155cfb9e4b67ea91b64f9d7/ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f", size = 10072582, upload-time = "2026-09-16T15:54:26.185Z" },
|
|
790
|
+
{ url = "https://files.pythonhosted.org/packages/2e/d4/f42edddb39668af1a559ceafa3823aedd65633a48dc9768e775485faa2c1/ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2", size = 9879644, upload-time = "2026-09-16T15:54:29.278Z" },
|
|
791
|
+
{ url = "https://files.pythonhosted.org/packages/f8/d4/913e3195d95e0378786c6656945c865f534a3560e29139da4882aff630d1/ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9", size = 10231569, upload-time = "2026-09-16T15:54:32.036Z" },
|
|
792
|
+
{ url = "https://files.pythonhosted.org/packages/2b/c4/8aa6ea0bdcedbd1bf87397e2fc4ed8406448ea5842f8660bc6e5f163039d/ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773", size = 10663666, upload-time = "2026-09-16T15:54:34.838Z" },
|
|
793
|
+
{ url = "https://files.pythonhosted.org/packages/3d/02/7f10ef4700bc223c30a3fdd10631a29830c45524b810a3c7ed947af64591/ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f", size = 10093472, upload-time = "2026-09-16T15:54:37.47Z" },
|
|
794
|
+
{ url = "https://files.pythonhosted.org/packages/1e/5d/a509c07d714b6da88f2c518b4637cf6f1d46b074be8f0f1e5fb9ff5126fe/ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa", size = 10586899, upload-time = "2026-09-16T15:54:40.488Z" },
|
|
795
|
+
{ url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" },
|
|
796
796
|
]
|
|
797
797
|
|
|
798
798
|
[[package]]
|
|
@@ -809,11 +809,11 @@ wheels = [
|
|
|
809
809
|
|
|
810
810
|
[[package]]
|
|
811
811
|
name = "semver"
|
|
812
|
-
version = "3.0
|
|
812
|
+
version = "3.1.0"
|
|
813
813
|
source = { registry = "https://pypi.org/simple" }
|
|
814
|
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
|
814
|
+
sdist = { url = "https://files.pythonhosted.org/packages/92/f5/e1dfe8e1d91c54ce212fd93916eb01fd1c590f413be0a0978c39a97aa1bb/semver-3.1.0.tar.gz", hash = "sha256:14bc073439513d7773662a338f4db9829cf16c12b74b9568e2d2689975fbd7fc", size = 304162, upload-time = "2026-09-12T18:38:14.045Z" }
|
|
815
815
|
wheels = [
|
|
816
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
816
|
+
{ url = "https://files.pythonhosted.org/packages/be/02/bce7a1d70e9d0f6ea8623c16386138c251b5855af6cd3a11433d3fba4e66/semver-3.1.0-py3-none-any.whl", hash = "sha256:6e4998f8b4762acdfea85f9bfd5b4032e0bb50038dc04bf82011b42d5d1145c5", size = 18783, upload-time = "2026-09-12T18:38:12.457Z" },
|
|
817
817
|
]
|
|
818
818
|
|
|
819
819
|
[[package]]
|
|
@@ -880,27 +880,27 @@ wheels = [
|
|
|
880
880
|
|
|
881
881
|
[[package]]
|
|
882
882
|
name = "ty"
|
|
883
|
-
version = "0.0.
|
|
884
|
-
source = { registry = "https://pypi.org/simple" }
|
|
885
|
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
|
886
|
-
wheels = [
|
|
887
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
888
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
889
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
890
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
891
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
892
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
893
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
894
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
895
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
896
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
897
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
898
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
899
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
900
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
901
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
902
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
903
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
883
|
+
version = "0.0.82"
|
|
884
|
+
source = { registry = "https://pypi.org/simple" }
|
|
885
|
+
sdist = { url = "https://files.pythonhosted.org/packages/f6/df/b9f35d0b8860f1bbfcb852fbef363301b5ca4b5590cadc6f2a810646d579/ty-0.0.82.tar.gz", hash = "sha256:586e3bf784cece42113929bb64b4761bed5c2127ae6cea294bb82da670066356", size = 7325958, upload-time = "2026-09-17T19:42:12.243Z" }
|
|
886
|
+
wheels = [
|
|
887
|
+
{ url = "https://files.pythonhosted.org/packages/31/0e/259760638b7b1a875743f1f3975253392932ff74397c8e174df7bfb143c3/ty-0.0.82-py3-none-linux_armv6l.whl", hash = "sha256:be58e089e317e1ada7a655216828e76d369386dc958f7a3b3d455ab6dc7f02b6", size = 13833734, upload-time = "2026-09-17T19:41:35.134Z" },
|
|
888
|
+
{ url = "https://files.pythonhosted.org/packages/70/f6/c8a1f70989cf44cf2cada5527e268b4216015a7f28f1d8817e4ef1e84a52/ty-0.0.82-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:c080ba9a816cfbb3e34c0d3a0071829c602d14bb992bc799d885d4b30008b4ab", size = 13449689, upload-time = "2026-09-17T19:41:37.65Z" },
|
|
889
|
+
{ url = "https://files.pythonhosted.org/packages/d5/eb/31fc4bdecb2ff0053443d4ec1e6719a3f3171636ad6da7a8a642c879a4f4/ty-0.0.82-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4f08bd5b751c3b2c46eabe8aee890e7fa54e3bcd97ffa4b8338b0a2508d928c2", size = 13410140, upload-time = "2026-09-17T19:41:40.024Z" },
|
|
890
|
+
{ url = "https://files.pythonhosted.org/packages/e7/14/e2af8c64004cd891691915b1522f105a5d601dd8d76a635fec112911f251/ty-0.0.82-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f5ef0401743c2eeac8039ef9ff1bc5dd612bd17ed0259b9b0794e19696548a6", size = 13435552, upload-time = "2026-09-17T19:41:42.084Z" },
|
|
891
|
+
{ url = "https://files.pythonhosted.org/packages/52/92/b9519a7854189aa8da4309ecb9c6ff5ab8ac91a433e201322b15c98f9388/ty-0.0.82-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c899643f2a4c1782c06a4bf4959909d1aa2d9d2cac7c1ac3de3161dfcb6909f8", size = 13601427, upload-time = "2026-09-17T19:41:44.242Z" },
|
|
892
|
+
{ url = "https://files.pythonhosted.org/packages/ce/be/0c7a39e277a25bca21bbd2dcf640eb4d80cf98f318ef611092cda1222399/ty-0.0.82-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7aa6e4e157c6e0b770c327a5d966371b2a7bc7ad4f22d662ac068623cba00eb", size = 14457370, upload-time = "2026-09-17T19:41:46.405Z" },
|
|
893
|
+
{ url = "https://files.pythonhosted.org/packages/6f/33/86ff8f1e15c71096838eb0fdfe0f5b578dc717575de6cba591d96baf3b06/ty-0.0.82-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:28c141ab481748f1b889eb572e803172ea452af621e514c30a2f30884753b020", size = 14888674, upload-time = "2026-09-17T19:41:48.6Z" },
|
|
894
|
+
{ url = "https://files.pythonhosted.org/packages/0a/d2/a1c6315907afd080b4e98d2972441b4803a70c9663a8c7a7f6fd554c7361/ty-0.0.82-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:16fff2729fbaa3998ba1cab536dc7d176aef28927b09200588e50982394c56df", size = 14670369, upload-time = "2026-09-17T19:41:50.963Z" },
|
|
895
|
+
{ url = "https://files.pythonhosted.org/packages/ae/cd/956ceae4dbe0b116ebc9ecb75dcbe9a0294c50146f76c4c59004e6eb2d77/ty-0.0.82-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c2024a33209b37525ffa85a29036985e9ef53a013c0ff321231bd8b23987cf71", size = 14107030, upload-time = "2026-09-17T19:41:53.343Z" },
|
|
896
|
+
{ url = "https://files.pythonhosted.org/packages/b7/f8/6025627cec4d6370fb34a2c5cdcf36d89dd4e4ac75b023bda68231e17dfc/ty-0.0.82-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14a0ae8591e08aca5911b545d100e51c9b796350a85b2b36d82219820552d662", size = 14485892, upload-time = "2026-09-17T19:41:55.745Z" },
|
|
897
|
+
{ url = "https://files.pythonhosted.org/packages/3a/b6/016f9b36096a9ccd0492ee000380bd75fb51dc4ec7bfef4d6d93f6b3ec2e/ty-0.0.82-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2964202b09108c72232760119c2680cefc33191c428b09e6f620b6c0ab9c6b0f", size = 13383907, upload-time = "2026-09-17T19:41:57.937Z" },
|
|
898
|
+
{ url = "https://files.pythonhosted.org/packages/b4/19/1ccc2429399d6ef3d98180340c7abc6793bd90c5039ad41a9003cb8de6df/ty-0.0.82-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f4ed8fc714e35dc11ef4209832e73ad461cf29c9161099eb6c2c541c6b9a3e0e", size = 13617005, upload-time = "2026-09-17T19:42:00.018Z" },
|
|
899
|
+
{ url = "https://files.pythonhosted.org/packages/d7/9d/a4a487db72e78413bba2ee9cd49f79b8f461be93e7f6c4dd790febae1cd7/ty-0.0.82-py3-none-musllinux_1_2_i686.whl", hash = "sha256:32a3fe7b1a50f20679fe76c85c0087280419365d900300898a7b95cbcfe510fc", size = 13906545, upload-time = "2026-09-17T19:42:01.942Z" },
|
|
900
|
+
{ url = "https://files.pythonhosted.org/packages/0c/4e/a58d530c0f5abe7b846e2d05af9e3f1fb44a1b620ae0abe3892cd3f9596c/ty-0.0.82-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f751044ae63ea9e73ce2eae4c23a839efa4a04d6487d6ea411a590fbe43360b6", size = 14248518, upload-time = "2026-09-17T19:42:04.164Z" },
|
|
901
|
+
{ url = "https://files.pythonhosted.org/packages/a6/db/5505229835171d19adcaaeec3a15e0b533b578d24de1329db5da9306c4c6/ty-0.0.82-py3-none-win32.whl", hash = "sha256:0eb66c5ce58434a48d2cae2780d25285c7ca19e07a0d38b1fe39d1a0f75d6fa3", size = 13059890, upload-time = "2026-09-17T19:42:06.161Z" },
|
|
902
|
+
{ url = "https://files.pythonhosted.org/packages/13/22/faa427b65baf39915f52a6fa079ece4253e831f3364c567bf91bc1eef65a/ty-0.0.82-py3-none-win_amd64.whl", hash = "sha256:1e4a6c1ca2dc1a6929517d73a47cd626989bb8bd67811425fbe022d06eb2bf73", size = 14003730, upload-time = "2026-09-17T19:42:08.268Z" },
|
|
903
|
+
{ url = "https://files.pythonhosted.org/packages/8b/fc/3b95100993d28acb2f3bcf3a49846985f5b2aafab6562627d018f6d23788/ty-0.0.82-py3-none-win_arm64.whl", hash = "sha256:a6f0e8a1f961518746edbccc5986945c01a56a917ebe37117aa028a038d54ae3", size = 13567713, upload-time = "2026-09-17T19:42:10.248Z" },
|
|
904
904
|
]
|
|
905
905
|
|
|
906
906
|
[[package]]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|