remotedesktop 2.1.0__py3-none-any.whl → 2.2.0__py3-none-any.whl

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/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """Remote desktop client/server for Windows computers on the same LAN."""
2
2
 
3
- __version__ = "2.1.0"
3
+ __version__ = "2.2.0"
remotedesktop/app.py CHANGED
@@ -15,7 +15,9 @@ import logging
15
15
  import re
16
16
  import sqlite3
17
17
  import sys
18
+ import threading
18
19
  import time
20
+ from collections.abc import Callable
19
21
  from pathlib import Path
20
22
 
21
23
  from PySide6.QtCore import QObject, QProcess, QStandardPaths, Qt, QTimer, Signal
@@ -45,10 +47,15 @@ from PySide6.QtWidgets import (
45
47
  from remotedesktop import __version__, compat, db, icon, logs, window_state
46
48
  from remotedesktop.about import AboutDialog
47
49
  from remotedesktop.autostart import Autostart, installed_launcher
48
- from remotedesktop.client import DiscoveryPanel, ServerSession
50
+ from remotedesktop.client import DiscoveryPanel, ServerSession, _broadcast_hosts
49
51
  from remotedesktop.clipboard import ClipboardSync
50
52
  from remotedesktop.config import KnownServers, Settings, default_db_path, load_client_identity
51
- from remotedesktop.discovery import DEFAULT_CONNECT_PORT, DISCOVERY_PORT, ServerInfo
53
+ from remotedesktop.discovery import (
54
+ DEFAULT_CONNECT_PORT,
55
+ DISCOVERY_PORT,
56
+ ServerInfo,
57
+ discover_servers,
58
+ )
52
59
  from remotedesktop.inventory import ConnectionInventory, InventoryTab
53
60
  from remotedesktop.logs import PeerLogDialog, read_log_tail
54
61
  from remotedesktop.modal_loop import HTCLOSE, HTMAXBUTTON, HTMINBUTTON, ModalLoopPump
@@ -73,6 +80,14 @@ _log = logging.getLogger("remotedesktop.app")
73
80
  # Auto-reconnect backoff never waits longer than this between attempts.
74
81
  _RECONNECT_CAP_SECONDS = 30.0
75
82
 
83
+ # Once this many direct reconnect attempts at the last known address have
84
+ # failed, each further failure also starts a discovery scan: the server may
85
+ # have come back on a different address (DHCP after a reboot), and sessions,
86
+ # tokens, and pins are all keyed by host:port. A re-discovered server is
87
+ # matched by certificate fingerprint (name as the fallback) and the session
88
+ # migrates to the new address — token included, so no re-approval.
89
+ _REDISCOVER_AFTER_ATTEMPTS = 2
90
+
76
91
 
77
92
  class _SessionsTable(ViewersTable):
78
93
  """The client-side twin of the viewers table: one row per connected
@@ -121,6 +136,9 @@ class _ConnectedServersSource(QObject):
121
136
 
122
137
 
123
138
  class MainWindow(QMainWindow):
139
+ # A re-discovery scan finished on its worker thread: (session, servers).
140
+ _rediscoveryFinished = Signal(object, list)
141
+
124
142
  def __init__(
125
143
  self,
126
144
  *,
@@ -132,10 +150,19 @@ class MainWindow(QMainWindow):
132
150
  connect_port: int = DEFAULT_CONNECT_PORT,
133
151
  tray_available: bool | None = None,
134
152
  reconnect_base_seconds: float = 2.0,
153
+ rediscover: Callable[[], list[ServerInfo]] | None = None,
135
154
  effects_reducer: VisualEffectsReducer | None = None,
136
155
  ) -> None:
137
156
  super().__init__()
138
157
  self._reconnect_base = reconnect_base_seconds
158
+ # The blocking scan auto-reconnect uses to find a moved server; tests
159
+ # inject a fake so window tests never broadcast on the LAN.
160
+ self._rediscover = (
161
+ rediscover
162
+ if rediscover is not None
163
+ else lambda: discover_servers(broadcast_hosts=_broadcast_hosts())
164
+ )
165
+ self._rediscoveryFinished.connect(self._on_rediscovery_finished)
139
166
  self._auto_scan = auto_scan
140
167
  self.setWindowIcon(icon.app_icon("app"))
141
168
  # Tests inject a connection to a temp database; the app uses the default.
@@ -1370,6 +1397,10 @@ class MainWindow(QMainWindow):
1370
1397
  session.viewer.clear(message)
1371
1398
  self._set_session_status(session, f"{session.name}: {message}")
1372
1399
  self.log(f"[{session.name}] {message}")
1400
+ # The direct address keeps failing — the server may be back under a
1401
+ # different one. Scan alongside the backoff timer, not instead of it.
1402
+ if session.reconnect_attempts >= _REDISCOVER_AFTER_ATTEMPTS:
1403
+ self._start_rediscovery(session)
1373
1404
 
1374
1405
  def _attempt_reconnect(self, session: ServerSession) -> None:
1375
1406
  if (
@@ -1392,6 +1423,104 @@ class MainWindow(QMainWindow):
1392
1423
  session.reconnect_timer.stop()
1393
1424
  session.reconnect_attempts = 0
1394
1425
 
1426
+ # -------------------------------------------- reconnect re-discovery
1427
+
1428
+ def _start_rediscovery(self, session: ServerSession) -> None:
1429
+ """Scan the LAN for a server that moved to a new address.
1430
+
1431
+ Runs the blocking scan on a worker thread (the DiscoveryPanel
1432
+ pattern) and delivers the result through a queued signal. This is
1433
+ not background polling: it only runs while a session is actively
1434
+ failing to reconnect, and stops the moment it succeeds.
1435
+ """
1436
+ if session.rediscovering:
1437
+ return
1438
+ session.rediscovering = True
1439
+ _log.debug("Re-discovery scan for %s (%s)", session.name, session.key)
1440
+ scan = self._rediscover
1441
+
1442
+ def run() -> None:
1443
+ try:
1444
+ servers = scan()
1445
+ except OSError as error:
1446
+ _log.warning("Re-discovery scan failed: %s", error)
1447
+ servers = []
1448
+ try:
1449
+ self._rediscoveryFinished.emit(session, servers)
1450
+ except RuntimeError:
1451
+ pass # the window was torn down while the scan ran
1452
+
1453
+ threading.Thread(target=run, name="reconnect-rediscovery", daemon=True).start()
1454
+
1455
+ def _on_rediscovery_finished(self, session: ServerSession, servers: list) -> None:
1456
+ session.rediscovering = False
1457
+ if (
1458
+ session not in self._sessions
1459
+ or session.connected
1460
+ or session.denied
1461
+ or not session.auto_reconnect
1462
+ or self._quitting
1463
+ ):
1464
+ return
1465
+ match = self._match_rediscovered(session, servers)
1466
+ if match is None:
1467
+ _log.debug("Re-discovery: %s not found among %d server(s)", session.name, len(servers))
1468
+ return
1469
+ if f"{match.host}:{match.port}" == session.key:
1470
+ return # still advertised at the failing address; keep direct retries
1471
+ self._migrate_session(session, match)
1472
+
1473
+ def _match_rediscovered(self, session: ServerSession, servers: list) -> ServerInfo | None:
1474
+ """The discovered server that is `session`'s peer, if any.
1475
+
1476
+ The pinned certificate fingerprint is the identity; the display name
1477
+ is the fallback for servers too old to advertise one (soft matching,
1478
+ like the soft cert pin — robust connections over strict identity).
1479
+ """
1480
+ record = self._known_servers.get(session.key)
1481
+ pinned = (record or {}).get("fingerprint") or ""
1482
+ candidates = [s for s in servers if not self._is_own_server(s)]
1483
+ if pinned:
1484
+ for server in candidates:
1485
+ if server.fingerprint == pinned:
1486
+ return server
1487
+ for server in candidates:
1488
+ if server.name == session.name:
1489
+ return server
1490
+ return None
1491
+
1492
+ def _migrate_session(self, session: ServerSession, server: ServerInfo) -> None:
1493
+ """Move a session (and its stored pairing) to the server's new
1494
+ address, then reconnect immediately."""
1495
+ old_key, new_key = session.key, f"{server.host}:{server.port}"
1496
+ if self._session_for_key(new_key) is not None:
1497
+ return # the user already opened a session at the new address
1498
+ record = self._known_servers.get(old_key)
1499
+ if record is not None:
1500
+ # Re-key the pairing so the stored token still applies — the
1501
+ # whole point: no fresh approval just because DHCP moved the
1502
+ # server.
1503
+ self._known_servers.remember(
1504
+ new_key, record.get("fingerprint") or "", record["token"]
1505
+ )
1506
+ self._known_servers.forget(old_key)
1507
+ session.key = new_key
1508
+ session.host, session.port = server.host, server.port
1509
+ # The peer's inventory row moves with it; the stale address would
1510
+ # otherwise linger as a permanently-unreachable duplicate.
1511
+ self.client_inventory.remove(old_key)
1512
+ self.client_inventory.record(
1513
+ new_key, "attempt", name=session.name, address=new_key, detail=new_key
1514
+ )
1515
+ self._persist_sessions()
1516
+ message = f"Found {session.name} at new address {new_key} (was {old_key}) — reconnecting"
1517
+ self._set_session_status(session, message)
1518
+ self.log(f"[{session.name}] {message}")
1519
+ # A new address deserves a fresh backoff; the connect failure path
1520
+ # re-arms it (and further re-discovery) if this address fails too.
1521
+ self._cancel_reconnect(session)
1522
+ session.client.connect_to(server.host, server.port)
1523
+
1395
1524
  def _request_server_log(self) -> None:
1396
1525
  session = self._session_for_page(self._tabs.currentWidget())
1397
1526
  if session is None:
remotedesktop/client.py CHANGED
@@ -233,5 +233,8 @@ class ServerSession:
233
233
  self.auto_reconnect = False
234
234
  self.reconnect_attempts = 0
235
235
  self.reconnect_timer: QTimer | None = None
236
+ # True while a re-discovery scan for this session runs on a worker
237
+ # thread (the server may have come back on a different address).
238
+ self.rediscovering = False
236
239
  # What the status bar shows while this session's tab is current.
237
240
  self.status_text = ""
@@ -28,6 +28,10 @@ class ServerInfo:
28
28
  name: str
29
29
  host: str
30
30
  port: int
31
+ # The server's TLS certificate fingerprint, when it advertises one —
32
+ # a stable identity that survives address changes (DHCP gave the server
33
+ # a new IP after a reboot). Empty from servers too old to send it.
34
+ fingerprint: str = ""
31
35
 
32
36
 
33
37
  def _parse(data: bytes, expected_type: str) -> dict | None:
@@ -58,10 +62,14 @@ class DiscoveryResponder:
58
62
  name: str,
59
63
  connect_port: int,
60
64
  *,
65
+ fingerprint: str = "",
61
66
  discovery_port: int = DISCOVERY_PORT,
62
67
  bind_host: str = "",
63
68
  ) -> None:
64
- self._reply = _encode("reply", name=name, port=connect_port)
69
+ # The fingerprint (`fp`) is optional in the reply: clients that
70
+ # predate it simply ignore the extra field.
71
+ extra = {"fp": fingerprint} if fingerprint else {}
72
+ self._reply = _encode("reply", name=name, port=connect_port, **extra)
65
73
  self._discovery_port = discovery_port
66
74
  self._bind_host = bind_host
67
75
  self._socket: socket.socket | None = None
@@ -181,6 +189,12 @@ def discover_servers(
181
189
  if not isinstance(name, str) or not isinstance(port, int):
182
190
  _log.debug("Ignoring malformed reply from %s: %r", host, message)
183
191
  continue
184
- found.setdefault((host, port), ServerInfo(name=name, host=host, port=port))
192
+ fingerprint = message.get("fp")
193
+ if not isinstance(fingerprint, str):
194
+ fingerprint = ""
195
+ found.setdefault(
196
+ (host, port),
197
+ ServerInfo(name=name, host=host, port=port, fingerprint=fingerprint),
198
+ )
185
199
  _log.debug("Discovery scan finished: %d server(s) found", len(found))
186
200
  return list(found.values())
remotedesktop/server.py CHANGED
@@ -295,8 +295,14 @@ class SharingTab(QWidget):
295
295
  self._listening = server.listen(self._connect_port)
296
296
  self._discoverable = False
297
297
  if self._listening:
298
+ # Advertising the cert fingerprint lets clients recognize this
299
+ # server by identity when its IP changes (auto-reconnect
300
+ # re-discovery), not just by address.
298
301
  responder = DiscoveryResponder(
299
- self._name, server.port, discovery_port=self._discovery_port
302
+ self._name,
303
+ server.port,
304
+ fingerprint=tls.certificate_fingerprint(self._credentials[0]),
305
+ discovery_port=self._discovery_port,
300
306
  )
301
307
  try:
302
308
  responder.start()
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: remotedesktop
3
- Version: 2.1.0
3
+ Version: 2.2.0
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
@@ -84,10 +84,10 @@ one-click *Forget* / *Revoke*, and the live connection log.
84
84
  - 🔒 **TLS + approve-once pairing** — every connection is encrypted; the server user approves a new client once, after which it reconnects with a stored token and no prompt.
85
85
  - 🔐 **Honest lock-screen behavior** — a locked server tells viewers so with a clear on-screen notice instead of a frozen frame, and streaming resumes by itself once someone signs in at the machine (see [The Windows lock screen](#the-windows-lock-screen)).
86
86
  - 📊 **Built-in performance monitoring** — live bandwidth and round-trip-time graphs with window statistics (mean/min/max/p99/jitter), plus a per-viewer table on the server.
87
- - 🔁 **Robust connections** — dead connections are detected within seconds, and dropped sessions reconnect automatically with backoff; a server restart heals by itself, no clicks needed. Connections that were open when the app closed are restored on the next start.
87
+ - 🔁 **Robust connections** — dead connections are detected within seconds, and dropped sessions reconnect automatically with backoff; a server restart heals by itself, no clicks needed. Even a server that comes back on a **different IP address** (a new DHCP lease after a reboot) is found again: reconnection re-scans the LAN, recognizes the server by its certificate identity, and follows it to the new address — pairing intact, so still no prompt. Connections that were open when the app closed are restored on the next start.
88
88
  - 🚀 **Hands-off operation** — start-at-login (per-user, no admin rights) with a choice of minimized (the default and recommended — sharing resumes after a reboot with no clicks), normal, maximized, or not starting at all; close-to-tray while sharing (the screen stays available with the window closed); and a *Restart app* button usable from the remote session itself, so you can update the software without visiting the machine.
89
89
  - 🗃️ **Persistent peer inventory** — a SQLite-backed history of every peer seen on the LAN, with one-click *Revoke* / *Forget*.
90
- - 🧭 **Desktop-app niceties** — a real menu bar with standard shortcuts, a status-bar sharing indicator, a confirmation before quitting with viewers connected, and window/panel layout that persists across restarts.
90
+ - 🧭 **Desktop-app niceties** — a real menu bar with standard shortcuts (Preferences on *File ▸ Preferences*, Ctrl+,; About on the Help menu), a status-bar sharing indicator, a confirmation before quitting with viewers connected, and window/panel layout that persists across restarts.
91
91
 
92
92
  In scope: screen, keyboard, mouse, and clipboard. Out of scope: shared
93
93
  drives, devices, and audio — and smooth playback of fast-changing
@@ -119,8 +119,8 @@ it prepares the environment on first use and launches the app.
119
119
  ## Quick start
120
120
 
121
121
  1. Run `remotedesktop` on both computers.
122
- 2. On the computer to share, open *Preferences* and set *Server (sharing)*
123
- to one of the *Shared* modes.
122
+ 2. On the computer to share, open *File ▸ Preferences* (Ctrl+,) and set
123
+ *Server (sharing)* to one of the *Shared* modes.
124
124
  3. On the viewing computer, the shared computer appears in the panel on
125
125
  the left — double-click it (or select it and click *Connect*).
126
126
  4. Approve the connection in the dialog that pops up on the shared
@@ -228,7 +228,8 @@ certificate the server generates and keeps, then simple length-prefixed
228
228
  messages on top — JSON for control (hello/welcome, input, clipboard,
229
229
  ping/pong for the round-trip-time graphs, log exchange) and binary
230
230
  payloads for frames and deltas. Discovery is a UDP broadcast probe that
231
- every server answers with its name and port (see below).
231
+ every server answers with its name, port, and certificate fingerprint
232
+ (see below).
232
233
 
233
234
  ## Versioning
234
235
 
@@ -248,7 +249,12 @@ not guaranteed. Keep both computers on the same version for best results.
248
249
  ## How discovery works
249
250
 
250
251
  The client broadcasts a small JSON probe over UDP (port 48653); each server
251
- on the LAN replies with its hostname and connection port. Windows Firewall
252
+ on the LAN replies with its hostname, connection port, and TLS certificate
253
+ fingerprint. The fingerprint is the server's stable identity: when a
254
+ reconnecting client can no longer reach a server at its last known address
255
+ (typically a new DHCP lease after a reboot), it re-scans and matches the
256
+ fingerprint to follow the server to its new address, keeping the stored
257
+ pairing token so no fresh approval is needed. Windows Firewall
252
258
  must allow Python to receive inbound UDP on that port for a server to be
253
259
  discoverable from other machines.
254
260
 
@@ -1,15 +1,15 @@
1
- remotedesktop/__init__.py,sha256=-MNcc0-pZ-lLU-bwVVZcMtLi1J_fIvmbTWQHdxpV5CM,100
1
+ remotedesktop/__init__.py,sha256=u2ClDvV49VmCDrIduAONdKUtfp5pewVgOStW3k8-2eU,100
2
2
  remotedesktop/__main__.py,sha256=m4gvhT01Wd3_kNl8lNQihy7eG2O3mYBbYP__AwBnyos,158
3
3
  remotedesktop/about.py,sha256=gofXchMgQ2Sikv1kFBIl42M4L-LY4bFKMARd5HeEyio,3701
4
- remotedesktop/app.py,sha256=o8RmCx7IUQKRBNjN7E5ppUqYTTQntsaf_Tg6h7-uqRE,68708
4
+ remotedesktop/app.py,sha256=7_TakYMXX0ZRD74MRQWK6ni2fpAtlANGfmB_1cj7kTM,74790
5
5
  remotedesktop/autostart.py,sha256=pxpKpm_4IvH73ZbAbzM1TOQVKyRnm7DxtcqRDWvpw2Q,4539
6
- remotedesktop/client.py,sha256=wZHQqClWBhXLyA1lQKF1jmsbGnyl62PI3_GTSxmaJM4,9900
6
+ remotedesktop/client.py,sha256=PgmGDHgrWWeiyrT8OZQr9E7hFQV_NoS7mfK1YsnXlkM,10086
7
7
  remotedesktop/clipboard.py,sha256=x2OBCn8xtLFyvAjvLflJYr85aB0tiqWN30ZhlMHc6QU,14013
8
8
  remotedesktop/compat.py,sha256=i9_iobDCluK-7QXm-ZkXBCU3Qt0GTb0LobZ8XuyK9uk,1694
9
9
  remotedesktop/config.py,sha256=biU9FQHxB9oXFivIAhZVM2eeL6lUEpbB7cEcCGfWt_8,5022
10
10
  remotedesktop/cursor_shape.py,sha256=kgcAeM-4CebkmtF3c_quhZ9ZIq3sn7sw_YvydzW6LBU,3369
11
11
  remotedesktop/db.py,sha256=ppV366kZjEpqNXTuRrrBIE976cSoUddx3WSroygXeow,1732
12
- remotedesktop/discovery.py,sha256=yHpxxkID4Dc8InaPw9ho3qL93dX7lzYea53JiC23yug,6857
12
+ remotedesktop/discovery.py,sha256=CzLOUo6L1xwmEmgIBs1WKFRZAyCMg6iqZNliwQdbo0g,7540
13
13
  remotedesktop/dxgi.py,sha256=-2fFsBcmbE3ps3_HBdP42D0LUKpZyqBjCZqdEgWFkAU,13573
14
14
  remotedesktop/frames.py,sha256=aUdOoWG7HoozKfD4Z1pVNBnmNoP7DAGGQEYgokSL0rM,4617
15
15
  remotedesktop/icon.py,sha256=6Gfqj2liZIj7wC_vdv-gMHr2uFupRYbQMntHXXjTBgU,2476
@@ -20,7 +20,7 @@ remotedesktop/modal_loop.py,sha256=eb7S5mGffaYYVOOrSrQulqCUPP7t3mINBj7R-hv5C9A,7
20
20
  remotedesktop/performance.py,sha256=2DvGYP39URSm3fgzYWnLJAdnmWc3NkCAHr2636nZguk,25337
21
21
  remotedesktop/preferences.py,sha256=mVKnR8fgWliyM5b5V7FXVBQH7jW15V4W2L93xwA9LMw,16716
22
22
  remotedesktop/protocol.py,sha256=Q-5n-s0YmZB7yzlICHzyc73JLvax-SO__5c7Bjg5xGw,3975
23
- remotedesktop/server.py,sha256=fJ1O5nYDSUSVQ_BIXSEKcEBFJCf0tCM2ubcQ2zvPyPY,17457
23
+ remotedesktop/server.py,sha256=wlE-sQtuveW97ErcB-tl0TfSNqtyLxzLQnqvRzeflT4,17769
24
24
  remotedesktop/session_lock.py,sha256=W5_MGYVgn3fnWT7Nzgxtt-aqJsnvyrOyfG2aCZTFcnM,1881
25
25
  remotedesktop/sharing.py,sha256=HDFCWnONftVbp7s_R9-_DUzj_q1Q7PTKRYGSJPt2u0c,49282
26
26
  remotedesktop/single_instance.py,sha256=G14dXIKctGFv0CvyCjdbeqkXTdW2y88MS5FEgxZNFz8,3002
@@ -28,8 +28,8 @@ remotedesktop/tls.py,sha256=AGrBpDq1gdjQm7TZ74y2W5-RxyEcbsFDPlNdRvadaAk,3934
28
28
  remotedesktop/viewer.py,sha256=arrIpQIazqhbWlElAbXyqeHaHZ0JqxlNbPwavpPixyo,14545
29
29
  remotedesktop/visual_effects.py,sha256=NQPb6qpwlW6SUycYbw-7mZOOmlPzC5YsjolwcxNukGI,7468
30
30
  remotedesktop/window_state.py,sha256=LvTGg5ZFSuDmISxYU0xotrGZbL89oVqKo8LI7xQGKok,1579
31
- remotedesktop-2.1.0.dist-info/METADATA,sha256=y_q3BHcYL5_9bH8X3bQVATpZiCiysAsDw98TqAJrTTc,17032
32
- remotedesktop-2.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
33
- remotedesktop-2.1.0.dist-info/entry_points.txt,sha256=t079osY5GlKUJP9JU5JVXlKrSrplLalw2koAslXWQWc,53
34
- remotedesktop-2.1.0.dist-info/licenses/LICENSE,sha256=6rWgMfDohGLDuJ0UFOyLphM28Nn0fuN26MczIQldMkc,1067
35
- remotedesktop-2.1.0.dist-info/RECORD,,
31
+ remotedesktop-2.2.0.dist-info/METADATA,sha256=I7vDfRLUDtj-9KflaEYJHTYMdJc9KvlkNaJ4WCNSym8,17766
32
+ remotedesktop-2.2.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
33
+ remotedesktop-2.2.0.dist-info/entry_points.txt,sha256=t079osY5GlKUJP9JU5JVXlKrSrplLalw2koAslXWQWc,53
34
+ remotedesktop-2.2.0.dist-info/licenses/LICENSE,sha256=6rWgMfDohGLDuJ0UFOyLphM28Nn0fuN26MczIQldMkc,1067
35
+ remotedesktop-2.2.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.31.0
2
+ Generator: hatchling 1.32.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any