pyappify 1.0.8__tar.gz → 1.0.9__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyappify
3
- Version: 1.0.8
3
+ Version: 1.0.9
4
4
  Summary: My awesome Python application
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -44,6 +44,7 @@ app_json_path = configure_app_json(os.environ.get("PYAPPIFY_APP_JSON_PATH"))
44
44
  pyappify_upgradeable = os.environ.get("PYAPPIFY_UPGRADEABLE") == '1'
45
45
  logger = None
46
46
  _console_logger = None
47
+ _DOWNLOAD_IO_TIMEOUT_SECONDS = 2
47
48
 
48
49
  try:
49
50
  pid = int(os.environ.get("PYAPPIFY_PID"))
@@ -139,13 +140,13 @@ def bring_window_to_front_by_pid(pid):
139
140
  return False
140
141
 
141
142
 
142
- def kill_pyappify(timeout=30):
143
+ def kill_pyappify(timeout=30, exit_event=None):
143
144
  if pid:
144
145
  log = _get_logger()
145
146
  log.info(f"Attempting to terminate process with PID: {pid}")
146
147
  try:
147
148
  os.kill(pid, signal.SIGTERM)
148
- if not _wait_for_process_exit(pid, timeout):
149
+ if not _wait_for_process_exit(pid, timeout, exit_event=exit_event):
149
150
  log.warning(f"Timed out waiting for process with PID {pid} to exit.")
150
151
  return False
151
152
  log.info(f'_wait_for_process_exit success {pid}')
@@ -156,11 +157,11 @@ def kill_pyappify(timeout=30):
156
157
  return False
157
158
 
158
159
 
159
- def kill_pyappify_exe(timeout=30):
160
- return kill_pyappify(timeout)
160
+ def kill_pyappify_exe(timeout=30, exit_event=None):
161
+ return kill_pyappify(timeout, exit_event=exit_event)
161
162
 
162
163
 
163
- def _wait_for_process_exit(process_pid, timeout=30):
164
+ def _wait_for_process_exit(process_pid, timeout=30, exit_event=None):
164
165
  if sys.platform == "win32" and ctypes:
165
166
  synchronize = 0x00100000
166
167
  wait_timeout = 0x00000102
@@ -181,22 +182,32 @@ def _wait_for_process_exit(process_pid, timeout=30):
181
182
  handle = ctypes.windll.kernel32.OpenProcess(synchronize, False, process_pid)
182
183
  if handle:
183
184
  try:
184
- result = ctypes.windll.kernel32.WaitForSingleObject(
185
- handle, int(timeout * 1000)
186
- )
187
- if result == wait_failed:
188
- return False
189
- return result != wait_timeout
185
+ deadline = time.monotonic() + timeout
186
+ while time.monotonic() < deadline:
187
+ if _exit_requested(exit_event):
188
+ return False
189
+ remaining_ms = max(0, int((deadline - time.monotonic()) * 1000))
190
+ result = ctypes.windll.kernel32.WaitForSingleObject(
191
+ handle, min(100, remaining_ms)
192
+ )
193
+ if result == wait_failed:
194
+ return False
195
+ if result != wait_timeout:
196
+ return True
197
+ return False
190
198
  finally:
191
199
  ctypes.windll.kernel32.CloseHandle(handle)
192
200
 
193
201
  deadline = time.monotonic() + timeout
194
202
  while time.monotonic() < deadline:
203
+ if _exit_requested(exit_event):
204
+ return False
195
205
  try:
196
206
  os.kill(process_pid, 0)
197
207
  except OSError:
198
208
  return True
199
- time.sleep(0.1)
209
+ if _wait_for_exit(exit_event, 0.1):
210
+ return False
200
211
  return False
201
212
 
202
213
 
@@ -285,7 +296,32 @@ def _require_pyappify_executable():
285
296
  return pyappify_executable
286
297
 
287
298
 
288
- def _run_launcher_api(arguments, timeout=300):
299
+ def _exit_requested(*events):
300
+ return any(event is not None and event.is_set() for event in events)
301
+
302
+
303
+ def _wait_for_exit(exit_event, timeout):
304
+ if exit_event is not None:
305
+ return exit_event.wait(timeout)
306
+ time.sleep(timeout)
307
+ return False
308
+
309
+
310
+ def _raise_if_exit_requested(exit_event):
311
+ if _exit_requested(exit_event):
312
+ raise InterruptedError("PyAppify operation cancelled because exit_event was set")
313
+
314
+
315
+ def _terminate_process(process):
316
+ try:
317
+ if process.poll() is None:
318
+ process.terminate()
319
+ except (AttributeError, OSError):
320
+ pass
321
+
322
+
323
+ def _run_launcher_api(arguments, timeout=300, exit_event=None):
324
+ _raise_if_exit_requested(exit_event)
289
325
  executable = _require_pyappify_executable()
290
326
  response_path = os.path.join(
291
327
  tempfile.gettempdir(),
@@ -293,7 +329,7 @@ def _run_launcher_api(arguments, timeout=300):
293
329
  )
294
330
  command = list(arguments) + ["--response-file", response_path]
295
331
  try:
296
- subprocess.Popen(
332
+ process = subprocess.Popen(
297
333
  [executable] + command,
298
334
  cwd=os.path.dirname(executable) or None,
299
335
  stdout=subprocess.DEVNULL,
@@ -305,8 +341,13 @@ def _run_launcher_api(arguments, timeout=300):
305
341
  )
306
342
 
307
343
  deadline = time.monotonic() + timeout
344
+ cancelled = False
308
345
  try:
309
346
  while time.monotonic() < deadline:
347
+ if _exit_requested(exit_event):
348
+ cancelled = True
349
+ _terminate_process(process)
350
+ break
310
351
  try:
311
352
  with open(response_path, "r", encoding="utf-8") as response_file:
312
353
  response = json.load(response_file)
@@ -318,18 +359,25 @@ def _run_launcher_api(arguments, timeout=300):
318
359
  except json.JSONDecodeError:
319
360
  # The running launcher may still be completing the response write.
320
361
  pass
321
- time.sleep(0.05)
362
+ if _wait_for_exit(exit_event, 0.05):
363
+ cancelled = True
364
+ _terminate_process(process)
365
+ break
322
366
  finally:
323
367
  try:
324
368
  os.remove(response_path)
325
369
  except FileNotFoundError:
326
370
  pass
327
371
 
372
+ if cancelled:
373
+ raise InterruptedError("PyAppify operation cancelled because exit_event was set")
328
374
  raise TimeoutError("Timed out waiting for a response from PyAppify")
329
375
 
330
376
 
331
- def get_version_list(number_versions=10, release_only=True, timeout=120):
332
- """Return the latest versions and each version's notes since its predecessor."""
377
+ def get_version_list(
378
+ number_versions=10, release_only=True, timeout=120, exit_event=None
379
+ ):
380
+ """Return version details, cancelling with InterruptedError on exit_event."""
333
381
  if isinstance(number_versions, bool) or not isinstance(number_versions, int):
334
382
  raise TypeError("number_versions must be an integer")
335
383
  if number_versions <= 0:
@@ -338,7 +386,8 @@ def get_version_list(number_versions=10, release_only=True, timeout=120):
338
386
  raise TypeError("release_only must be a boolean")
339
387
 
340
388
  if "PYAPPIFY_PYTHON_TEST" in os.environ:
341
- time.sleep(5)
389
+ if _wait_for_exit(exit_event, 5):
390
+ _raise_if_exit_requested(exit_event)
342
391
  return _get_mock_version_list(number_versions)
343
392
 
344
393
  response = _run_launcher_api(
@@ -350,15 +399,16 @@ def get_version_list(number_versions=10, release_only=True, timeout=120):
350
399
  str(release_only).lower(),
351
400
  ],
352
401
  timeout=timeout,
402
+ exit_event=exit_event,
353
403
  )
354
404
  if not isinstance(response, list):
355
405
  raise RuntimeError("PyAppify returned an invalid version-list response")
356
406
  return response
357
407
 
358
408
 
359
- def get_versions(number_versions=10, release_only=True, timeout=120):
409
+ def get_versions(number_versions=10, release_only=True, timeout=120, exit_event=None):
360
410
  """Alias for get_version_list."""
361
- return get_version_list(number_versions, release_only, timeout)
411
+ return get_version_list(number_versions, release_only, timeout, exit_event)
362
412
 
363
413
 
364
414
  def calculate_update_notes(update_notes, current_version, target_version):
@@ -401,15 +451,17 @@ def calculate_update_notes(update_notes, current_version, target_version):
401
451
  return [str(note) for note in notes]
402
452
 
403
453
 
404
- def update_to_version(version, timeout=300):
405
- """Start PyAppify (or forward to it) and update the managed app to version."""
454
+ def update_to_version(version, timeout=300, exit_event=None):
455
+ """Update the app, cancelling with InterruptedError on exit_event."""
406
456
  if not isinstance(version, str) or not version.strip():
407
457
  raise ValueError("version must be a non-empty string")
408
458
  if "PYAPPIFY_PYTHON_TEST" in os.environ:
459
+ _raise_if_exit_requested(exit_event)
409
460
  return {"updated": True, "version": version, "mocked": True}
410
461
  response = _run_launcher_api(
411
462
  ["--update-to-version", version],
412
463
  timeout=timeout,
464
+ exit_event=exit_event,
413
465
  )
414
466
  if not isinstance(response, dict) or not response.get("updated"):
415
467
  raise RuntimeError("PyAppify returned an invalid update response")
@@ -463,10 +515,11 @@ def _get_mock_version_list(number_versions):
463
515
  return versions[:number_versions]
464
516
 
465
517
 
466
- def _replace_executable(source_path, target_path, timeout=30):
518
+ def _replace_executable(source_path, target_path, timeout=30, exit_event=None):
467
519
  deadline = time.monotonic() + timeout
468
520
  last_error = None
469
521
  while True:
522
+ _raise_if_exit_requested(exit_event)
470
523
  try:
471
524
  shutil.move(source_path, target_path)
472
525
  return
@@ -474,7 +527,8 @@ def _replace_executable(source_path, target_path, timeout=30):
474
527
  last_error = e
475
528
  if time.monotonic() >= deadline:
476
529
  raise last_error
477
- time.sleep(0.25)
530
+ if _wait_for_exit(exit_event, 0.25):
531
+ _raise_if_exit_requested(exit_event)
478
532
 
479
533
 
480
534
  def hide_pyappify():
@@ -487,7 +541,13 @@ def hide_pyappify():
487
541
  log.error(f"Failed to minimize window for process with PID {pid}: {e}")
488
542
  pass
489
543
 
490
- def upgrade(to_version, executable_sha256, executable_zip_urls, stop_event=None):
544
+ def upgrade(
545
+ to_version,
546
+ executable_sha256,
547
+ executable_zip_urls,
548
+ stop_event=None,
549
+ exit_event=None,
550
+ ):
491
551
  log = _get_logger()
492
552
  if not pyappify_upgradeable or not is_greater_version(to_version, pyappify_version):
493
553
  log.info(f"pyappify no need to upgrade {pyappify_upgradeable} {to_version} {executable_sha256} {executable_zip_urls}")
@@ -497,17 +557,23 @@ def upgrade(to_version, executable_sha256, executable_zip_urls, stop_event=None)
497
557
  def _do_upgrade():
498
558
  tmp_dir = os.path.join(os.getcwd(), "pyappify_tmp")
499
559
  try:
560
+ if _exit_requested(stop_event, exit_event):
561
+ return
500
562
  os.makedirs(tmp_dir, exist_ok=True)
501
563
  downloaded_zip_path = None
502
564
  for url in executable_zip_urls:
565
+ if _exit_requested(stop_event, exit_event):
566
+ return
503
567
  try:
504
568
  log.info(
505
569
  f"pyappify start to download {url}")
506
570
  local_zip_path = os.path.join(tmp_dir, os.path.basename(url))
507
- with urllib.request.urlopen(url) as response, open(local_zip_path, 'wb') as out_file:
571
+ with urllib.request.urlopen(
572
+ url, timeout=_DOWNLOAD_IO_TIMEOUT_SECONDS
573
+ ) as response, open(local_zip_path, 'wb') as out_file:
508
574
  while True:
509
- if stop_event and stop_event.is_set():
510
- log.info("pyappify Upgrade download cancelled by stop event.")
575
+ if _exit_requested(stop_event, exit_event):
576
+ log.info("pyappify Upgrade download cancelled.")
511
577
  return
512
578
  chunk = response.read(8192)
513
579
  if not chunk:
@@ -526,11 +592,16 @@ def upgrade(to_version, executable_sha256, executable_zip_urls, stop_event=None)
526
592
  return
527
593
 
528
594
  with zipfile.ZipFile(downloaded_zip_path, 'r') as zip_ref:
529
- zip_ref.extractall(tmp_dir)
595
+ for member in zip_ref.infolist():
596
+ if _exit_requested(stop_event, exit_event):
597
+ return
598
+ zip_ref.extract(member, tmp_dir)
530
599
 
531
600
  new_executable_name = os.path.basename(pyappify_executable)
532
601
  found_executable_path = None
533
602
  for root, _, files in os.walk(tmp_dir):
603
+ if _exit_requested(stop_event, exit_event):
604
+ return
534
605
  if new_executable_name in files:
535
606
  found_executable_path = os.path.join(root, new_executable_name)
536
607
  break
@@ -541,15 +612,26 @@ def upgrade(to_version, executable_sha256, executable_zip_urls, stop_event=None)
541
612
 
542
613
  sha256_hash = hashlib.sha256()
543
614
  with open(found_executable_path, "rb") as f:
544
- for byte_block in iter(lambda: f.read(4096), b""):
615
+ while True:
616
+ if _exit_requested(stop_event, exit_event):
617
+ return
618
+ byte_block = f.read(4096)
619
+ if not byte_block:
620
+ break
545
621
  sha256_hash.update(byte_block)
546
622
 
547
623
  if executable_sha256 and sha256_hash.hexdigest() != executable_sha256:
548
624
  log.error("pyappify SHA256 checksum mismatch.")
549
625
  return
550
626
 
551
- kill_pyappify()
552
- _replace_executable(found_executable_path, pyappify_executable)
627
+ if _exit_requested(stop_event, exit_event):
628
+ return
629
+ kill_pyappify(exit_event=exit_event or stop_event)
630
+ _replace_executable(
631
+ found_executable_path,
632
+ pyappify_executable,
633
+ exit_event=exit_event or stop_event,
634
+ )
553
635
  log.info(f"pyappify Upgrade success")
554
636
  except Exception as e:
555
637
  log.error(f"pyappify Upgrade failed: {e}")
@@ -560,6 +642,7 @@ def upgrade(to_version, executable_sha256, executable_zip_urls, stop_event=None)
560
642
  thread = threading.Thread(target=_do_upgrade)
561
643
  thread.daemon = True
562
644
  thread.start()
645
+ return thread
563
646
 
564
647
 
565
648
  def is_app_updated():
@@ -22,7 +22,7 @@ _LOG = logging.getLogger("pyappify.app_config")
22
22
  class AppConfigAPI:
23
23
  """Thread-safe access to the app preferences stored by PyAppify."""
24
24
 
25
- def __init__(self, path=None, watch=False, watch_interval=0.5):
25
+ def __init__(self, path=None, watch=False, watch_interval=0.5, exit_event=None):
26
26
  self._lock = threading.RLock()
27
27
  self._path = None
28
28
  self._preferences = {
@@ -31,22 +31,29 @@ class AppConfigAPI:
31
31
  }
32
32
  self._listeners = set()
33
33
  self._watch_interval = watch_interval
34
+ self._exit_event = exit_event
34
35
  self._watch_stop = None
35
36
  self._watch_thread = None
36
37
  if path:
37
- self.configure(path, watch=watch, watch_interval=watch_interval)
38
+ self.configure(
39
+ path,
40
+ watch=watch,
41
+ watch_interval=watch_interval,
42
+ exit_event=exit_event,
43
+ )
38
44
 
39
45
  @property
40
46
  def path(self):
41
47
  with self._lock:
42
48
  return self._path
43
49
 
44
- def configure(self, path, watch=True, watch_interval=0.5):
50
+ def configure(self, path, watch=True, watch_interval=0.5, exit_event=None):
45
51
  self.stop_watcher()
46
52
  normalized_path = os.path.abspath(os.path.expanduser(path)) if path else None
47
53
  with self._lock:
48
54
  self._path = normalized_path
49
55
  self._watch_interval = watch_interval
56
+ self._exit_event = exit_event
50
57
  self._preferences = {
51
58
  "auto_start": False,
52
59
  "update_method": UPDATE_METHOD_AUTO,
@@ -153,17 +160,19 @@ class AppConfigAPI:
153
160
  with self._lock:
154
161
  self._listeners.discard(listener)
155
162
 
156
- def start_watcher(self):
163
+ def start_watcher(self, exit_event=None):
157
164
  with self._lock:
158
165
  if not self._path:
159
166
  raise RuntimeError("PYAPPIFY_APP_JSON_PATH is not configured")
160
167
  if self._watch_thread and self._watch_thread.is_alive():
161
168
  return self._watch_thread
169
+ if exit_event is not None:
170
+ self._exit_event = exit_event
162
171
  stop_event = threading.Event()
163
172
  self._watch_stop = stop_event
164
173
  thread = threading.Thread(
165
174
  target=self._watch_loop,
166
- args=(stop_event,),
175
+ args=(stop_event, self._exit_event),
167
176
  name="pyappify-app-config-watch",
168
177
  daemon=True,
169
178
  )
@@ -182,8 +191,12 @@ class AppConfigAPI:
182
191
  if thread and thread is not threading.current_thread():
183
192
  thread.join(timeout=max(1.0, self._watch_interval * 2))
184
193
 
185
- def _watch_loop(self, stop_event):
194
+ def _watch_loop(self, stop_event, exit_event=None):
195
+ if exit_event is not None and exit_event.is_set():
196
+ return
186
197
  while not stop_event.wait(self._watch_interval):
198
+ if exit_event is not None and exit_event.is_set():
199
+ return
187
200
  try:
188
201
  self.refresh()
189
202
  except (OSError, ValueError, json.JSONDecodeError):
@@ -193,8 +206,13 @@ class AppConfigAPI:
193
206
  _api = AppConfigAPI()
194
207
 
195
208
 
196
- def configure_app_json(path=None, watch=True, watch_interval=0.5):
197
- return _api.configure(path, watch=watch, watch_interval=watch_interval)
209
+ def configure_app_json(path=None, watch=True, watch_interval=0.5, exit_event=None):
210
+ return _api.configure(
211
+ path,
212
+ watch=watch,
213
+ watch_interval=watch_interval,
214
+ exit_event=exit_event,
215
+ )
198
216
 
199
217
 
200
218
  def get_app_json_path():
@@ -233,8 +251,8 @@ def remove_app_config_listener(listener):
233
251
  _api.remove_listener(listener)
234
252
 
235
253
 
236
- def start_app_config_watcher():
237
- return _api.start_watcher()
254
+ def start_app_config_watcher(exit_event=None):
255
+ return _api.start_watcher(exit_event=exit_event)
238
256
 
239
257
 
240
258
  def stop_app_config_watcher():
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyappify
3
- Version: 1.0.8
3
+ Version: 1.0.9
4
4
  Summary: My awesome Python application
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pyappify"
7
- version = "1.0.8"
7
+ version = "1.0.9"
8
8
  description = "My awesome Python application"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.7"
File without changes
File without changes