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.
Files changed (84) hide show
  1. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/CLAUDE.md +2 -2
  2. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/PKG-INFO +1 -1
  3. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/__init__.py +1 -1
  4. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/app.py +22 -2
  5. remotedesktop-2.5.1/src/remotedesktop/console_window.py +95 -0
  6. remotedesktop-2.5.1/src/remotedesktop/crash_report.py +100 -0
  7. remotedesktop-2.5.1/tests/test_console_window.py +163 -0
  8. remotedesktop-2.5.1/tests/test_crash_report.py +113 -0
  9. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_performance.py +1 -1
  10. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/uv.lock +48 -48
  11. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/.github/dependabot.yml +0 -0
  12. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/.github/workflows/ci.yml +0 -0
  13. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/.github/workflows/release.yml +0 -0
  14. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/.gitignore +0 -0
  15. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/LICENSE +0 -0
  16. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/docs/media/client-demo.gif +0 -0
  17. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/docs/media/server-demo.gif +0 -0
  18. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/icon/remotedesktop.ico +0 -0
  19. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/pyproject.toml +0 -0
  20. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/readme.md +0 -0
  21. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/run.bat +0 -0
  22. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/run_claude.bat +0 -0
  23. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/scripts/make_coverage_badge.py +0 -0
  24. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/scripts/make_installer.bat +0 -0
  25. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/scripts/make_venv.bat +0 -0
  26. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/__main__.py +0 -0
  27. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/about.py +0 -0
  28. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/autostart.py +0 -0
  29. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/backlog.py +0 -0
  30. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/client.py +0 -0
  31. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/clipboard.py +0 -0
  32. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/compat.py +0 -0
  33. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/config.py +0 -0
  34. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/cursor_shape.py +0 -0
  35. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/db.py +0 -0
  36. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/discovery.py +0 -0
  37. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/dxgi.py +0 -0
  38. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/frames.py +0 -0
  39. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/icon.py +0 -0
  40. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/input_injection.py +0 -0
  41. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/inventory.py +0 -0
  42. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/logs.py +0 -0
  43. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/modal_loop.py +0 -0
  44. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/performance.py +0 -0
  45. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/preferences.py +0 -0
  46. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/protocol.py +0 -0
  47. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/server.py +0 -0
  48. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/session_lock.py +0 -0
  49. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/sharing.py +0 -0
  50. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/single_instance.py +0 -0
  51. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/tls.py +0 -0
  52. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/viewer.py +0 -0
  53. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/visual_effects.py +0 -0
  54. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/src/remotedesktop/window_state.py +0 -0
  55. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/conftest.py +0 -0
  56. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_about.py +0 -0
  57. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_autostart.py +0 -0
  58. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_backlog.py +0 -0
  59. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_clipboard.py +0 -0
  60. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_compat.py +0 -0
  61. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_config.py +0 -0
  62. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_cursor_shape.py +0 -0
  63. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_discovery.py +0 -0
  64. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_dxgi.py +0 -0
  65. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_frames.py +0 -0
  66. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_icon.py +0 -0
  67. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_input.py +0 -0
  68. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_input_injection.py +0 -0
  69. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_inventory.py +0 -0
  70. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_logs.py +0 -0
  71. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_main_window.py +0 -0
  72. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_modal_loop.py +0 -0
  73. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_preferences.py +0 -0
  74. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_protocol.py +0 -0
  75. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_session_lock.py +0 -0
  76. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_sharing.py +0 -0
  77. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_sharing_tab.py +0 -0
  78. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_single_instance.py +0 -0
  79. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_smoke.py +0 -0
  80. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_tls.py +0 -0
  81. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_visual_effects.py +0 -0
  82. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tests/test_window_state.py +0 -0
  83. {remotedesktop-2.4.0 → remotedesktop-2.5.1}/tools/make_demo_gifs.py +0 -0
  84. {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`, 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.
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.4.0
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
@@ -1,3 +1,3 @@
1
1
  """Remote desktop client/server for Windows computers on the same LAN."""
2
2
 
3
- __version__ = "2.4.0"
3
+ __version__ = "2.5.1"
@@ -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
- raise SystemExit(app.exec())
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.7"
533
+ version = "4.11.10"
534
534
  source = { registry = "https://pypi.org/simple" }
535
- sdist = { url = "https://files.pythonhosted.org/packages/69/b7/802a56eca9f2fac455b8bab5375a2647b0f0e14a2cd63ef077de3c4a7658/platformdirs-4.11.7.tar.gz", hash = "sha256:4f41487eeeeeb07f3a6625e61d9bc0ae6809f92d3386dbd74392fbb76108104d", size = 35127, upload-time = "2026-09-01T13:35:10.502Z" }
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/27/6e/80993e10a0482f630cef528635789233224f36b1ffd11592aa15d13ff9ce/platformdirs-4.11.7-py3-none-any.whl", hash = "sha256:8a02cb259042c79d1cd0450facc2fe6dc9d303ae7901afbe33bf8ea0b188cef6", size = 23938, upload-time = "2026-09-01T13:35:09.02Z" },
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.7"
776
- source = { registry = "https://pypi.org/simple" }
777
- sdist = { url = "https://files.pythonhosted.org/packages/82/bb/5a449b9162e49b139d72f61672bd3ac1d790221f796d3304e2241fff4c58/ruff-0.16.7.tar.gz", hash = "sha256:5f71d004ac1263b22fa39462ac5ae618a4b77d58981af2cc79bf79a29c12b1a6", size = 4924184, upload-time = "2026-09-10T18:04:06.336Z" }
778
- wheels = [
779
- { url = "https://files.pythonhosted.org/packages/e3/b2/c80aeeb7f9e469c0d63a85d2f1ab6e1ebfbe10ea7a8d2438b7e09e3ff09e/ruff-0.16.7-py3-none-linux_armv6l.whl", hash = "sha256:727307773e7c7f9181d3ed3a2484186e56c1fa1874255911c74585eb2c7c19f9", size = 10048917, upload-time = "2026-09-10T18:03:30.28Z" },
780
- { url = "https://files.pythonhosted.org/packages/7b/96/20bb7bcae008004df52afcb7ac83432d4a467f2c17b672fe46d26be231c5/ruff-0.16.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d61c258deabf58f34c67bd4bb4d939c7f2e6b5f0e59c1cdd1cf771b11cde929", size = 10242929, upload-time = "2026-09-10T18:03:32.706Z" },
781
- { url = "https://files.pythonhosted.org/packages/90/b2/f184b0d5abec02db69cfd7e49b688ae0237554528ca777136c613bf36bee/ruff-0.16.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ab81118df8945e0193d0240712aa4496573595b75185c3636ed825592a0f728", size = 9847245, upload-time = "2026-09-10T18:03:34.509Z" },
782
- { url = "https://files.pythonhosted.org/packages/eb/2d/db1633a641866ed801e34cc6b60ef236c5e16f9b2124ab1d49cc24a5fe4f/ruff-0.16.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c196c968874fc8019da8e7163de7a1a370f111e2309b4b7dfea0fce950198d0", size = 9961780, upload-time = "2026-09-10T18:03:36.618Z" },
783
- { url = "https://files.pythonhosted.org/packages/4d/98/edea21e1a3e38dbbc3bf6bb068b863b3b06184cf8533a4c7dbbe208a89d5/ruff-0.16.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac8c3bd0a7e10ad31e6ce51e7a99f3cb772e69aecdd6b9ea7e99b362f62a62c0", size = 9866337, upload-time = "2026-09-10T18:03:38.805Z" },
784
- { url = "https://files.pythonhosted.org/packages/0b/11/a15e60d4c87b214646f116ca9d204475bf993ee1047459bc9a360fd4d6d1/ruff-0.16.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d3988edde000b5c75dc1b3f584708da9bc990de069c18909142580fec1af9", size = 10562512, upload-time = "2026-09-10T18:03:40.71Z" },
785
- { url = "https://files.pythonhosted.org/packages/29/42/eaff4c9b6d0c7cdf56df313a17e89ae854f5bbc0b0c8f9cce19be0ab7a8f/ruff-0.16.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce05b62b770a8217c4646a9c4139fca00efe8fe5d71f87df2b243ff20d4584d1", size = 11302938, upload-time = "2026-09-10T18:03:42.607Z" },
786
- { url = "https://files.pythonhosted.org/packages/5d/43/c75aa59a4ec181fe2ec06cab30e198c1c6d107229a9f008ae3a7c16cabd8/ruff-0.16.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1b576fddb9d9ef2ececfb5fadcd6a624b25070ed85e3cfcfe449fc3ff6a7b9", size = 10840857, upload-time = "2026-09-10T18:03:44.604Z" },
787
- { url = "https://files.pythonhosted.org/packages/21/33/81f3da371942ea031105ba679d8d6e28ec1660ccd690a45f42d381161356/ruff-0.16.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce7f8f22df67c93ed96c717f9128eadb797144ac2bad475cf536f31d6100c55", size = 10370001, upload-time = "2026-09-10T18:03:46.706Z" },
788
- { url = "https://files.pythonhosted.org/packages/fa/0b/6345fb4dbf6dd0ed1cfe5d18391dc9c3f59cc81622a7b0a65b84b3e730ba/ruff-0.16.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:06d0e93d04f392996435ebd600c153f65b47d73fbec2415aa99c5ee5756b3a5f", size = 10548735, upload-time = "2026-09-10T18:03:48.658Z" },
789
- { url = "https://files.pythonhosted.org/packages/3f/4d/c5576adf511f92a328e5569dda190ecdd430da51f1a649f3a4a2fd73e21e/ruff-0.16.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:142151a5e7b93c1b11111337142f89dd2fbfee92161225c99a97222f22e32656", size = 10108496, upload-time = "2026-09-10T18:03:50.563Z" },
790
- { url = "https://files.pythonhosted.org/packages/ff/8c/667d83c16199a17a56adc6b0bd4c3beb5b767a2babcd16a56f76f9be7fd6/ruff-0.16.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6651f97a342d8b35d54d8991544ca22169b86dc54111cb604666940c431b750", size = 9860136, upload-time = "2026-09-10T18:03:52.621Z" },
791
- { url = "https://files.pythonhosted.org/packages/99/75/78d401106731999a1dd20cc5a6961e37e1eb9397a3b589f73f3a5ce146a3/ruff-0.16.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ef140c6eb935fa9a84c9c607dfb2cb1b85843c192e79265b0c54f35f557ea8e5", size = 10286290, upload-time = "2026-09-10T18:03:55.207Z" },
792
- { url = "https://files.pythonhosted.org/packages/68/49/56f9c3a8b755df93a0ad318b2147bf4ef5dae9a7e5ec61c460109c67957f/ruff-0.16.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:53e39506a730fadeee0d998ed5946f30671f0db240c6c7c73bdabbe33604bb6f", size = 10745048, upload-time = "2026-09-10T18:03:57.299Z" },
793
- { url = "https://files.pythonhosted.org/packages/5f/ea/7f9b938a63ece4bec677ad7f9f7fa02df3383db1949ed93a382441c09a87/ruff-0.16.7-py3-none-win32.whl", hash = "sha256:2ea3470fcebcbc5df2fb0c6f3b90333fa9084c534e0111c038fa4a6ab9f1c4b7", size = 10059082, upload-time = "2026-09-10T18:03:59.632Z" },
794
- { url = "https://files.pythonhosted.org/packages/39/11/480a6973a927aa653e1cead6a6416008640e03a99d05b34c0434b8c6c366/ruff-0.16.7-py3-none-win_amd64.whl", hash = "sha256:7ac26aca826e9e21d0f1cb25b54ac660760a9fdd094d3e4df9848232be98cfc6", size = 10593368, upload-time = "2026-09-10T18:04:01.999Z" },
795
- { url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" },
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.4"
812
+ version = "3.1.0"
813
813
  source = { registry = "https://pypi.org/simple" }
814
- sdist = { url = "https://files.pythonhosted.org/packages/72/d1/d3159231aec234a59dd7d601e9dd9fe96f3afff15efd33c1070019b26132/semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602", size = 269730, upload-time = "2025-01-24T13:19:27.617Z" }
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/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746", size = 17912, upload-time = "2025-01-24T13:19:24.949Z" },
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.80"
884
- source = { registry = "https://pypi.org/simple" }
885
- sdist = { url = "https://files.pythonhosted.org/packages/54/b0/6d1b10e0d422736a3c439e487c950ae785f401d71ff879d5be68bccb6d90/ty-0.0.80.tar.gz", hash = "sha256:fe86bc91327e45ff5e3593b7e306e7f57a44bc0608f38d647b8d97bd99c96013", size = 7183998, upload-time = "2026-09-09T21:18:47.547Z" }
886
- wheels = [
887
- { url = "https://files.pythonhosted.org/packages/29/c8/3c93195eca282936ebb574d0ba98843e4b147ee324006f88464777012a92/ty-0.0.80-py3-none-linux_armv6l.whl", hash = "sha256:738d1cfca466c577aea24547c348629c00c63548cf7da2c46b497b3840f85dee", size = 13606509, upload-time = "2026-09-09T21:18:10.476Z" },
888
- { url = "https://files.pythonhosted.org/packages/e7/48/bbb47f7001c97262a5109bcd92a45bcc8a2fbb21e994c214a9897630bfb7/ty-0.0.80-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:56060164bb8ee43770fa367524fbdba2611fc90f8923a02cc91f80eb37d5a1b0", size = 13203812, upload-time = "2026-09-09T21:18:12.796Z" },
889
- { url = "https://files.pythonhosted.org/packages/db/8d/fd141160567047b742e466be8ed09a4c4ed80fa045d37f946eb4f4532fee/ty-0.0.80-py3-none-macosx_11_0_arm64.whl", hash = "sha256:da4062e0fbf3923d9b71688157c5753394f243348f00732e9edbb27498397169", size = 13021896, upload-time = "2026-09-09T21:18:15.186Z" },
890
- { url = "https://files.pythonhosted.org/packages/1e/10/b4177faf9e71bc37bc4d08f512042269660a1ce0ec457c48f96e51fc91d7/ty-0.0.80-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9eb94b2659f506a3a07a9ea1415d5c18aaa1e554adc1612614d617a0ed320e1", size = 13083881, upload-time = "2026-09-09T21:18:17.616Z" },
891
- { url = "https://files.pythonhosted.org/packages/7f/c2/192d48b9a9acbbe030fc9a4bd73af75671a5566e731949f77e912a68ce68/ty-0.0.80-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2f5e39a87da48c1af1a3b3135f02be7a83a93da30b0d6a5f5d27806e7c747ce", size = 13354874, upload-time = "2026-09-09T21:18:19.744Z" },
892
- { url = "https://files.pythonhosted.org/packages/0a/5b/736dfd31efd98bed9dbe018ff91676e5ea83485b1e24afb635fd0247728f/ty-0.0.80-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e00149e7779c6b98f3ba12e10a631a861bd7ca1a42bbf064e2677ada7d2c0c2", size = 14204805, upload-time = "2026-09-09T21:18:21.999Z" },
893
- { url = "https://files.pythonhosted.org/packages/fd/6f/557c726cb53401998e3589bb632c963a6887a70240e0a4386024f3731c81/ty-0.0.80-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a377268f359fb6e7a2cc9026a09223c764f58d020dec8e6baeaba9caba6ff829", size = 14630014, upload-time = "2026-09-09T21:18:24Z" },
894
- { url = "https://files.pythonhosted.org/packages/e9/19/7a4b18fe27f6b6bd4ffa56b67c8b09ecb76bb4312d1c64b73f65417b4dbd/ty-0.0.80-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4511dbf1b266b9ce5b73e9b28d94468096c2b6da00e2fe205168c32d3b352ca7", size = 14326946, upload-time = "2026-09-09T21:18:26.679Z" },
895
- { url = "https://files.pythonhosted.org/packages/f8/95/16dd90805fc7e53ec54ae9fdb1a8b74753fb159a55a170f60f49b9417824/ty-0.0.80-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe95feffa7156800c6f804195acb9fb5846a39671c7192c30fff23351aaf7c31", size = 13705988, upload-time = "2026-09-09T21:18:28.681Z" },
896
- { url = "https://files.pythonhosted.org/packages/b1/09/4e87992c23ab8a742c7d4914ac5fe66fb9301c8464a69fd2ecbc0be3fbcd/ty-0.0.80-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ba26b39f06bc8c3c2c5acd3a147239482b36620cdbca1b9d02e915294854f2cc", size = 14233729, upload-time = "2026-09-09T21:18:30.961Z" },
897
- { url = "https://files.pythonhosted.org/packages/36/e9/b8c11fda8e66a1d1cc1a7160ed719a33f4dc0107b1cef6a14e2673965763/ty-0.0.80-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2e6167320888c115a6fbe69893b63fd1c848f9121a454e75e5f612674528e3f3", size = 13173523, upload-time = "2026-09-09T21:18:33.266Z" },
898
- { url = "https://files.pythonhosted.org/packages/73/a7/512083fa540c5be1ac8642658f03bfb5bfa6fd168ea4f55e3c0aabe04166/ty-0.0.80-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7aecb62b4de70b479eab07d1779ea66da26dd8b068f50a9330867157312f103c", size = 13373014, upload-time = "2026-09-09T21:18:35.339Z" },
899
- { url = "https://files.pythonhosted.org/packages/5e/63/cd2f0ca81fd9b8cafef93bbcf022bf636bc4de8ee7465c6aafeec1d4d52b/ty-0.0.80-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4fed06adacd16b7e2d722f37449021b1419598d0440769701c0f4827faddde63", size = 13667827, upload-time = "2026-09-09T21:18:37.241Z" },
900
- { url = "https://files.pythonhosted.org/packages/0d/5d/ebdfdcb2dc099ae74dc4b75511eef0dd398b2fa27006c543543974b4567f/ty-0.0.80-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:09329af6303ce611ec2cfffc995dc1ddf76c9f47194fc1a4d64a984759c25f5b", size = 13963866, upload-time = "2026-09-09T21:18:39.386Z" },
901
- { url = "https://files.pythonhosted.org/packages/c4/f6/c286d5e1b560cfd16d6e91fdab683718aacf09fc88938ca06dcc9d1c8502/ty-0.0.80-py3-none-win32.whl", hash = "sha256:a81b3b512f7b4c68e42fbd1835633de658809666e9aafd5defa52bbc5197698d", size = 12881806, upload-time = "2026-09-09T21:18:41.507Z" },
902
- { url = "https://files.pythonhosted.org/packages/fd/c8/3b3a8ac16d47a23ff34bf57c0d99a491860748aea96e0e886b4bfed54f9b/ty-0.0.80-py3-none-win_amd64.whl", hash = "sha256:8043f99878a2ae434781cb881c8a3840dff70c4a5b60bdc5ae84251f35ee063f", size = 13528883, upload-time = "2026-09-09T21:18:43.687Z" },
903
- { url = "https://files.pythonhosted.org/packages/2e/71/a6c697930fca76596d7d17f8853120f41a298fd9ea632c901cfca8ca869b/ty-0.0.80-py3-none-win_arm64.whl", hash = "sha256:e277ef034331da5319efc839c064968d24f35715b71c70369cf97d36fe4dcbdb", size = 13370135, upload-time = "2026-09-09T21:18:45.758Z" },
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