pendify 0.1.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.
pendify/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Pairs this PC with a phone account by a QR on a loopback page, standard library only."""
pendify/__main__.py ADDED
@@ -0,0 +1,265 @@
1
+ """The commands.
2
+
3
+ python -m <package> the pairing page and the watcher of the game client, together
4
+ python -m <package> --dry the same, but the watcher never accepts: it alerts at the queue pop
5
+ python -m <package> --quiet the same, started by the system at logon: no browser, and no message box
6
+ unless the start fails
7
+ python -m <package> ping <kind> one alert with the stored pair, one fixed line per answer
8
+
9
+ The page and the watcher stop together on Ctrl+C, on Ctrl+Break and on the page's quit button, which
10
+ removes the run file and exits 0. One instance per config folder: a second start opens the running page
11
+ and exits 0, and one made while that program is still closing after its quit waits for it to end and starts.
12
+ Where no console is attached (a pythonw start), the line a start ends on is also shown in a
13
+ message box. `--data-dir`, `--worker` and `--client-lockfile` are the
14
+ test overrides, loopback only, so a test run reads neither the profile, the Worker nor the client's files.
15
+ """
16
+ import argparse
17
+ import os
18
+ import signal
19
+ import sys
20
+ import threading
21
+ import time
22
+ import webbrowser
23
+ from pathlib import Path
24
+
25
+ from . import alert, client, config, page, pairing, runfile, watcher, worker
26
+ from .autostart import Autostart
27
+
28
+ TICK_SECONDS = 1.0
29
+ STOP_SECONDS = 5.0
30
+ # How long a start waits for a copy that is closing to let its run file go: one tick and the two STOP_SECONDS waits
31
+ # of its stop, with room.
32
+ CLOSING_WAIT_SECONDS = 15.0
33
+ REFUSED_LINE = "refused: the pairing of this PC was not accepted; link it again from the page"
34
+ NOT_LINKED_LINE = "not linked: start the program without arguments and link this PC first"
35
+ ALREADY_RUNNING_LINE = "already running: opening the page of the program that runs"
36
+ # A quiet second start opens nothing, so its line promises nothing.
37
+ QUIET_RUNNING_LINE = "already running"
38
+ PAGE_NOT_KNOWN_LINE = ("already running: the page of the program that runs is not known yet; start it again in a "
39
+ "moment to open it")
40
+ CLAIM_FAILED_LINE = "cannot start: the run file cannot be replaced: {path}"
41
+ FOLDER_FAILED_LINE = "cannot start: the config folder cannot be written: {path}"
42
+ _MB_ICONINFORMATION = 0x40
43
+ _MB_SETFOREGROUND = 0x10000
44
+
45
+
46
+ def _data_dir(value):
47
+ if not value.strip():
48
+ raise argparse.ArgumentTypeError("the data directory is empty")
49
+ return Path(value)
50
+
51
+
52
+ def _client_lockfile(value):
53
+ if not value.strip():
54
+ raise argparse.ArgumentTypeError("the lockfile path is empty")
55
+ return Path(value)
56
+
57
+
58
+ def _worker_address(value):
59
+ try:
60
+ return worker.loopback_base(value)
61
+ except ValueError as refused:
62
+ raise argparse.ArgumentTypeError(str(refused)) from None
63
+
64
+
65
+ def _arguments(argv):
66
+ # The options are accepted before and after the command; SUPPRESS keeps one side from erasing the other.
67
+ common = argparse.ArgumentParser(add_help=False)
68
+ common.add_argument("--data-dir", type=_data_dir, default=argparse.SUPPRESS,
69
+ help="a directory used instead of %%APPDATA%%, for test runs")
70
+ common.add_argument("--worker", type=_worker_address, default=argparse.SUPPRESS,
71
+ help="http://127.0.0.1:<port> or http://localhost:<port> instead of the Worker, for test runs")
72
+ common.add_argument("--client-lockfile", type=_client_lockfile, default=argparse.SUPPRESS,
73
+ help="a lockfile read instead of the client's, its client reached over plain http on "
74
+ "127.0.0.1, for test runs")
75
+ parser = argparse.ArgumentParser(prog=f"python -m {__package__}", parents=[common],
76
+ description="Pairs this PC by a QR on a loopback page, watches the game "
77
+ "client and sends alerts.")
78
+ parser.add_argument("--dry", action="store_true", help="watch and alert, but never accept")
79
+ parser.add_argument("--quiet", action="store_true",
80
+ help="a start by the system: no browser, and no message box for a start that ends well")
81
+ commands = parser.add_subparsers(dest="command")
82
+ ping = commands.add_parser("ping", parents=[common], help="send one alert with the stored pair")
83
+ ping.add_argument("kind", choices=worker.KINDS)
84
+ return parser.parse_args(argv)
85
+
86
+
87
+ def _ping_line(result):
88
+ if isinstance(result, worker.Sent):
89
+ return "sent"
90
+ if isinstance(result, worker.Refused):
91
+ return REFUSED_LINE
92
+ if isinstance(result, worker.NotDelivered):
93
+ return f"not delivered (HTTP {result.status})"
94
+ return f"failed: {result.reason}"
95
+
96
+
97
+ def _ping(store, base, kind, timeout):
98
+ pair = store.read() # never a first load: a ping mints nothing
99
+ if pair is None or pair.link_id is None:
100
+ print(NOT_LINKED_LINE)
101
+ return 2
102
+ result = worker.ping(pair.link_id, pair.secret, kind, base=base, timeout=timeout)
103
+ print(_ping_line(result))
104
+ return 0 if isinstance(result, worker.Sent) else 1
105
+
106
+
107
+ def _watcher(args, store, state, base, timeout, stop, delay, beep):
108
+ """The watcher and its alert. The test override reads its one lockfile, with no process read, and
109
+ reaches its client over plain http on 127.0.0.1."""
110
+ lockfile = getattr(args, "client_lockfile", None)
111
+ if lockfile is None:
112
+ credentials, addresses = client.ClientCredentials(), client.real_addresses
113
+ else:
114
+ credentials, addresses = client.ClientCredentials((str(lockfile),), run=None), client.loopback_addresses
115
+ alerter = alert.Alerter(store, state, lambda link_id, secret, kind: worker.ping(
116
+ link_id, secret, kind, base=base, timeout=timeout), beep=beep)
117
+ return watcher.Watcher(credentials, alerter, accept=not args.dry, addresses=addresses, delay=delay,
118
+ stop=stop), alerter
119
+
120
+
121
+ def _console_attached():
122
+ """False only on Windows with no terminal on stdout and no console window: a pythonw start, where a
123
+ printed line reaches nobody."""
124
+ if os.name != "nt":
125
+ return True
126
+ if sys.stdout is not None and sys.stdout.isatty():
127
+ return True
128
+ import ctypes
129
+ from ctypes import wintypes
130
+
131
+ kernel32 = ctypes.WinDLL("kernel32")
132
+ kernel32.GetConsoleWindow.restype = wintypes.HWND
133
+ return bool(kernel32.GetConsoleWindow())
134
+
135
+
136
+ def _message_box(line):
137
+ """The line in a Windows message box, the one place a start with no console can show it."""
138
+ import ctypes
139
+ from ctypes import wintypes
140
+
141
+ user32 = ctypes.WinDLL("user32")
142
+ user32.MessageBoxW.argtypes = (wintypes.HWND, wintypes.LPCWSTR, wintypes.LPCWSTR, wintypes.UINT)
143
+ user32.MessageBoxW(None, line, __package__, _MB_ICONINFORMATION | _MB_SETFOREGROUND)
144
+
145
+
146
+ def _ends(line, code, show):
147
+ """The line a start ends on: printed as ever, and shown where no console is attached."""
148
+ print(line, flush=True)
149
+ show(line)
150
+ return code
151
+
152
+
153
+ def _serve(args, store, base, timeout, opener, stop, delay, beep, show, clock, sleep, autostart):
154
+ # A quiet start is the system's, at logon: it never opens the browser, and a start that ends well shows no box.
155
+ calm = (lambda line: None) if args.quiet else show
156
+ run = runfile.RunFile(store.path.parent, clock=clock, sleep=sleep)
157
+ try:
158
+ holder = run.claim()
159
+ if holder is not None and holder["closing"] and run.wait_released(holder, CLOSING_WAIT_SECONDS):
160
+ holder = run.claim() # the closing copy let its file go: this start claims it as a first one does
161
+ except runfile.FolderNotWritable as refused:
162
+ return _ends(FOLDER_FAILED_LINE.format(path=refused.filename), 1, show)
163
+ except OSError:
164
+ return _ends(CLAIM_FAILED_LINE.format(path=run.path), 1, show)
165
+ if holder is not None:
166
+ if holder["closing"]: # still closing at the bound: its page is going, so it is never opened
167
+ return _ends(QUIET_RUNNING_LINE if args.quiet else PAGE_NOT_KNOWN_LINE, 0, calm)
168
+ port = holder["port"] if holder["port"] is not None else run.holder_port(holder)
169
+ if port is None: # a winner with no page yet: nothing is opened; a quiet start promises no page either
170
+ return _ends(QUIET_RUNNING_LINE if args.quiet else PAGE_NOT_KNOWN_LINE, 0, calm)
171
+ if args.quiet: # the system's start at logon: nothing opened, nothing shown, a line of its own
172
+ print(QUIET_RUNNING_LINE, flush=True)
173
+ return 0
174
+ print(ALREADY_RUNNING_LINE, flush=True)
175
+ opener(f"http://{page.ADDRESS}:{port}/")
176
+ show(ALREADY_RUNNING_LINE)
177
+ return 0
178
+ try:
179
+ state = pairing.PairingState(store, lambda secret: worker.check(secret, base=base, timeout=timeout))
180
+ watch, alerter = _watcher(args, store, state, base, timeout, stop, delay, beep)
181
+
182
+ def quit_page():
183
+ # The page's quit marks the record closing at once, since a tick can be held in a slow check before
184
+ # the finally runs, then sets the same stop event as Ctrl+C: the watcher, the page and the run file
185
+ # end below.
186
+ try:
187
+ run.mark_closing()
188
+ except OSError:
189
+ pass # unmarked, the finally marks it again; the stop goes on
190
+ stop.set()
191
+
192
+ pairing_page = page.PairingPage(state, watch=watch.snapshot, on_quit=quit_page, on_pause=watch.pause,
193
+ on_resume=watch.resume, autostart=autostart, events=watch.events)
194
+ url = pairing_page.start()
195
+ watching = threading.Thread(target=watch.run, daemon=True)
196
+ try:
197
+ run.publish(pairing_page.port)
198
+ print(f"page: {url}", flush=True)
199
+ if not args.quiet and not opener(url): # a start by a person opens the page, linked or not
200
+ print("open the address above in a browser", flush=True)
201
+ watching.start()
202
+ while True:
203
+ state.tick()
204
+ if stop.wait(TICK_SECONDS):
205
+ break
206
+ except KeyboardInterrupt:
207
+ pass
208
+ finally:
209
+ stop.set()
210
+ # First, so a start made while this one stops waits for it instead of opening a closing page: Ctrl+C
211
+ # and Ctrl+Break are marked only here, and the page's quit, marked already, is marked again.
212
+ try:
213
+ run.mark_closing()
214
+ except OSError:
215
+ pass # unmarked, a start meanwhile opens this page as before; the stop goes on
216
+ if watching.is_alive():
217
+ watching.join(STOP_SECONDS)
218
+ alerter.flush(STOP_SECONDS)
219
+ pairing_page.close()
220
+ finally:
221
+ run.release()
222
+ return 0
223
+
224
+
225
+ def _break_as_interrupt():
226
+ """Ctrl+Break stops the program as Ctrl+C does; only on Windows and only on the main thread."""
227
+ if not hasattr(signal, "SIGBREAK") or threading.current_thread() is not threading.main_thread():
228
+ return None
229
+ return signal.signal(signal.SIGBREAK, signal.default_int_handler)
230
+
231
+
232
+ def main(argv=None, *, opener=webbrowser.open, stop=None, timeout=worker.TIMEOUT_SECONDS, delay=None,
233
+ beep=alert.beep, box=_message_box, autostart=None, console=_console_attached, clock=time.monotonic,
234
+ sleep=time.sleep):
235
+ """`beep`, `box` and `autostart` are the seams of the sound, of the message box and of the start with Windows
236
+ (the per-user Run value when None): a test run passes silent ones and a fake. `console` says whether a printed
237
+ line reaches anybody; `clock` and `sleep` time the run file's re-reads."""
238
+ args = _arguments(sys.argv[1:] if argv is None else argv)
239
+ store = config.ConfigStore(getattr(args, "data_dir", None) or config.default_base_dir())
240
+ base = getattr(args, "worker", worker.BASE_URL)
241
+
242
+ def show(line): # a start's last line, also in a message box where no console is attached
243
+ if not console():
244
+ box(line)
245
+
246
+ try:
247
+ if args.command == "ping":
248
+ return _ping(store, base, args.kind, timeout)
249
+ previous = _break_as_interrupt()
250
+ try:
251
+ return _serve(args, store, base, timeout, opener, stop or threading.Event(),
252
+ delay or watcher.accept_delay, beep, show, clock, sleep,
253
+ autostart if autostart is not None else Autostart())
254
+ finally:
255
+ if previous is not None:
256
+ signal.signal(signal.SIGBREAK, previous)
257
+ except config.ConfigError as failure: # at start or mid-run: its one sentence, never a traceback
258
+ print(failure)
259
+ if args.command != "ping": # a start ends on it
260
+ show(str(failure))
261
+ return 1
262
+
263
+
264
+ if __name__ == "__main__":
265
+ sys.exit(main())
pendify/alert.py ADDED
@@ -0,0 +1,132 @@
1
+ """An alert: the beep of S:161-169 and, with a stored link id, the ping of its kind off the watcher's thread.
2
+
3
+ Every alert beeps and names a kind (S:336-355). The ping's result goes to the pairing state through record_ping,
4
+ as lane lnk5a's ping command answers it, so three refusals in a row offer the relink, and its name and time stay
5
+ for the page as the last ping. With no link id nothing is sent and nothing is queued. sound() is the beep
6
+ alone, with no kind and no ping. The console gets fixed lines only. listen() names the one callback told each ping's
7
+ result name and time right after they are recorded (the watcher's log, lane pclog).
8
+ """
9
+ import threading
10
+ import time
11
+
12
+ from . import worker
13
+
14
+ try:
15
+ import winsound
16
+ except ImportError: # not Windows: the beep is silent, as in the script
17
+ winsound = None
18
+
19
+ BEEP_NOTES = (988, 1319) # S:167
20
+ BEEP_ROUNDS = 3 # S:166
21
+ BEEP_MILLISECONDS = 160 # S:168
22
+ PING_LINES = {"sent": "alert: sent to the phone", "refused": "alert: refused, link this PC again from the page",
23
+ "not_delivered": "alert: not delivered", "failed": "alert: failed"}
24
+ CONFIG_UNREADABLE_LINE = "alert: the config file could not be read, nothing was sent"
25
+ BEEP_FAILED_LINE = "alert: the sound device refused the beep"
26
+
27
+
28
+ def _print(line):
29
+ print(line, flush=True)
30
+
31
+
32
+ def _start_daemon(target):
33
+ thread = threading.Thread(target=target, daemon=True)
34
+ thread.start()
35
+ return thread
36
+
37
+
38
+ def beep(sound=winsound, start=_start_daemon):
39
+ """The script's siren, three rounds of two notes, on a thread of its own; None without the module."""
40
+ if sound is None:
41
+ return None
42
+
43
+ def play():
44
+ try:
45
+ for _ in range(BEEP_ROUNDS):
46
+ for frequency in BEEP_NOTES:
47
+ sound.Beep(frequency, BEEP_MILLISECONDS)
48
+ except RuntimeError: # winsound's answer when the system cannot play it
49
+ _print(BEEP_FAILED_LINE)
50
+
51
+ return start(play)
52
+
53
+
54
+ def _result_name(result):
55
+ if isinstance(result, worker.Sent):
56
+ return "sent"
57
+ if isinstance(result, worker.Refused):
58
+ return "refused"
59
+ if isinstance(result, worker.NotDelivered):
60
+ return "not_delivered"
61
+ return "failed"
62
+
63
+
64
+ class Alerter:
65
+ def __init__(self, store, state, ping, *, beep=beep, start=None, log=None, wall=time.time):
66
+ self._store, self._state, self._ping, self._beep = store, state, ping, beep
67
+ self._start = start or _start_daemon
68
+ self._log = log or _print
69
+ self._wall = wall
70
+ self._lock = threading.Lock()
71
+ self._pending = []
72
+ self._last_ping = (None, None)
73
+ self._listener = None
74
+
75
+ def __call__(self, kind):
76
+ """Beeps; with a stored link id, sends the ping of `kind` on a thread of its own."""
77
+ self._beep()
78
+ try:
79
+ pair = self._store.read() # read at each alert: a link made on the page counts at once
80
+ except OSError:
81
+ self._log(CONFIG_UNREADABLE_LINE)
82
+ return
83
+ if pair is None or pair.link_id is None:
84
+ return
85
+ done = threading.Event()
86
+ with self._lock:
87
+ self._pending = [event for event in self._pending if not event.is_set()] + [done]
88
+ self._start(lambda: self._send(pair, kind, done))
89
+
90
+ def listen(self, callback):
91
+ """`callback(name, at)` is told each ping's result name and wall time on the ping's thread, right after the
92
+ last ping records them; a later call replaces it. A callback that raises is swallowed: the record stands and
93
+ the ping's line is said."""
94
+ with self._lock:
95
+ self._listener = callback
96
+
97
+ def sound(self):
98
+ """Beeps and nothing else: no ping, nothing queued, the last ping as it was."""
99
+ self._beep()
100
+
101
+ def _send(self, pair, kind, done):
102
+ try:
103
+ try:
104
+ result = self._ping(pair.link_id, pair.secret, kind)
105
+ except Exception as failure: # a refused input raises a fixed sentence; it still ends as failed
106
+ result = worker.Failed(type(failure).__name__)
107
+ self._state.record_ping(result)
108
+ name, at = _result_name(result), self._wall()
109
+ with self._lock:
110
+ self._last_ping = (name, at)
111
+ listener = self._listener
112
+ if listener is not None:
113
+ try:
114
+ listener(name, at)
115
+ except Exception: # the log's listener never costs the ping its record or its line
116
+ pass
117
+ self._log(PING_LINES[name])
118
+ finally:
119
+ done.set()
120
+
121
+ def last_ping(self):
122
+ """The last ping's result name (sent, refused, not_delivered or failed) and its wall time; (None, None)
123
+ before any ping was made."""
124
+ with self._lock:
125
+ return self._last_ping
126
+
127
+ def flush(self, timeout):
128
+ """Waits up to `timeout` seconds for the pings in flight; True when none is left."""
129
+ deadline = time.monotonic() + timeout
130
+ with self._lock:
131
+ pending = list(self._pending)
132
+ return all(event.wait(max(0.0, deadline - time.monotonic())) for event in pending)
pendify/autostart.py ADDED
@@ -0,0 +1,105 @@
1
+ """The start with Windows: the per-user Run value, no administrator.
2
+
3
+ The value `PendiFy` under HKEY_CURRENT_USER's Run key holds the start line of install.ps1 plus --quiet, so the
4
+ program starts at logon with no browser. Only the page writes it, on its person's ask; a start never writes it,
5
+ and install.ps1 only rewrites one that exists. The registry is a seam of three calls on one value under one key
6
+ path; the real one imports winreg inside itself and takes the key path as an argument, so a test points it at a
7
+ scratch key. Where winreg cannot be imported the start with Windows is not available, and the page shows and
8
+ routes nothing of it. A registry failure is one sentence on stderr, by its type only, and changes nothing.
9
+ """
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run"
14
+ VALUE_NAME = "PendiFy"
15
+ START_ARGS = "-m pendify --quiet"
16
+ FAILED_LINE = "autostart: the start with Windows could not be {what} ({kind})"
17
+
18
+
19
+ class WindowsRegistry:
20
+ """One value under one key path of HKEY_CURRENT_USER: read, write and delete, nothing else. Built, it reads
21
+ nothing; an import of winreg that fails is the caller's ImportError."""
22
+
23
+ def __init__(self, key_path=RUN_KEY, name=VALUE_NAME):
24
+ import winreg
25
+
26
+ self._winreg, self.key_path, self.name = winreg, key_path, name
27
+
28
+ def read(self):
29
+ """The value's text, or None when the key or the value is missing."""
30
+ winreg = self._winreg
31
+ try:
32
+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER, self.key_path) as key:
33
+ return winreg.QueryValueEx(key, self.name)[0]
34
+ except FileNotFoundError:
35
+ return None
36
+
37
+ def write(self, text):
38
+ winreg = self._winreg
39
+ with winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER, self.key_path, 0, winreg.KEY_SET_VALUE) as key:
40
+ winreg.SetValueEx(key, self.name, 0, winreg.REG_SZ, text)
41
+
42
+ def delete(self):
43
+ """The value removed; a missing key or value is nothing to remove, no error."""
44
+ winreg = self._winreg
45
+ try:
46
+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER, self.key_path, 0, winreg.KEY_SET_VALUE) as key:
47
+ winreg.DeleteValue(key, self.name)
48
+ except FileNotFoundError:
49
+ pass
50
+
51
+
52
+ def _say_failure(what, failure):
53
+ print(FAILED_LINE.format(what=what, kind=type(failure).__name__), file=sys.stderr, flush=True)
54
+
55
+
56
+ class Autostart:
57
+ def __init__(self, registry=None, executable=None):
58
+ """`registry` is the seam of the three calls, the real Run value when None; `executable` is the
59
+ interpreter whose start line is written, this one when None."""
60
+ if registry is None:
61
+ try:
62
+ registry = WindowsRegistry()
63
+ except ImportError: # no winreg on this system: no Run value to keep
64
+ registry = None
65
+ self.registry = registry
66
+ self.available = registry is not None
67
+ self._executable = executable or sys.executable
68
+
69
+ def enabled(self):
70
+ """True when the value exists; False when it does not, when not available, or when it cannot be read."""
71
+ if not self.available:
72
+ return False
73
+ try:
74
+ return self.registry.read() is not None
75
+ except OSError as failure:
76
+ _say_failure("read", failure)
77
+ return False
78
+
79
+ def enable(self):
80
+ """The value written with command(); True when written."""
81
+ return self._change("written", lambda: self.registry.write(self.command()))
82
+
83
+ def disable(self):
84
+ """The value deleted, a missing one being no error; True when it is gone."""
85
+ return self._change("removed", lambda: self.registry.delete())
86
+
87
+ def command(self):
88
+ """install.ps1's start line plus --quiet: pythonw.exe beside the interpreter when that file exists, else
89
+ the interpreter, quoted when its path holds a space (install.ps1's Format-Arg)."""
90
+ interpreter = Path(self._executable)
91
+ windowless = interpreter.with_name("pythonw.exe")
92
+ chosen = str(windowless if windowless.is_file() else interpreter)
93
+ if any(character.isspace() for character in chosen):
94
+ chosen = f'"{chosen}"'
95
+ return f"{chosen} {START_ARGS}"
96
+
97
+ def _change(self, what, change):
98
+ if not self.available:
99
+ return False
100
+ try:
101
+ change()
102
+ except OSError as failure:
103
+ _say_failure(what, failure)
104
+ return False
105
+ return True