pyappify 1.0.8__tar.gz → 1.0.10__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.10
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"))
@@ -57,13 +58,14 @@ except ImportError:
57
58
 
58
59
 
59
60
  def find_pyappify_executable(start_dir=None, environ=None):
60
- """Find the launcher configured by PyAppify or in a parent directory."""
61
+ """Find the configured launcher or an app launcher above ``working``."""
61
62
  environ = os.environ if environ is None else environ
62
63
  configured = environ.get("PYAPPIFY_EXECUTABLE")
63
64
  if configured:
64
65
  configured_path = Path(configured).expanduser()
65
66
  if configured_path.is_file():
66
67
  return str(configured_path.resolve())
68
+ return None
67
69
 
68
70
  current = Path(start_dir or os.getcwd()).expanduser()
69
71
  if current.is_file():
@@ -73,12 +75,52 @@ def find_pyappify_executable(start_dir=None, environ=None):
73
75
  except OSError:
74
76
  current = current.absolute()
75
77
 
76
- executable_names = ("pyappify.exe", "pyappify")
77
- for directory in (current,) + tuple(current.parents):
78
- for executable_name in executable_names:
79
- candidate = directory / executable_name
80
- if candidate.is_file():
81
- return str(candidate.resolve())
78
+ working_directory = next(
79
+ (
80
+ directory
81
+ for directory in (current,) + tuple(current.parents)
82
+ if directory.name.casefold() == "working" and directory.parent.name
83
+ ),
84
+ None,
85
+ )
86
+ if working_directory is None:
87
+ return None
88
+
89
+ app_name = working_directory.parent.name
90
+ apps_directory = working_directory.parent.parent
91
+ data_directory = apps_directory.parent
92
+ if apps_directory.name.casefold() != "apps" or data_directory.name.casefold() != "data":
93
+ return None
94
+
95
+ app_root = data_directory.parent
96
+ exact_launcher_name = "{} Launcher.exe".format(app_name).casefold()
97
+ search_directories = []
98
+ directory = working_directory
99
+ while True:
100
+ search_directories.append(directory)
101
+ if directory == app_root:
102
+ break
103
+ directory = directory.parent
104
+
105
+ for directory in search_directories:
106
+ try:
107
+ candidates = [
108
+ candidate
109
+ for candidate in directory.iterdir()
110
+ if candidate.is_file()
111
+ and candidate.suffix.casefold() == ".exe"
112
+ and candidate.name.casefold().startswith(app_name.casefold())
113
+ ]
114
+ except OSError:
115
+ continue
116
+ if candidates:
117
+ candidates.sort(
118
+ key=lambda candidate: (
119
+ candidate.name.casefold() != exact_launcher_name,
120
+ candidate.name.casefold(),
121
+ )
122
+ )
123
+ return str(candidates[0].resolve())
82
124
  return None
83
125
 
84
126
 
@@ -139,13 +181,13 @@ def bring_window_to_front_by_pid(pid):
139
181
  return False
140
182
 
141
183
 
142
- def kill_pyappify(timeout=30):
184
+ def kill_pyappify(timeout=30, exit_event=None):
143
185
  if pid:
144
186
  log = _get_logger()
145
187
  log.info(f"Attempting to terminate process with PID: {pid}")
146
188
  try:
147
189
  os.kill(pid, signal.SIGTERM)
148
- if not _wait_for_process_exit(pid, timeout):
190
+ if not _wait_for_process_exit(pid, timeout, exit_event=exit_event):
149
191
  log.warning(f"Timed out waiting for process with PID {pid} to exit.")
150
192
  return False
151
193
  log.info(f'_wait_for_process_exit success {pid}')
@@ -156,11 +198,11 @@ def kill_pyappify(timeout=30):
156
198
  return False
157
199
 
158
200
 
159
- def kill_pyappify_exe(timeout=30):
160
- return kill_pyappify(timeout)
201
+ def kill_pyappify_exe(timeout=30, exit_event=None):
202
+ return kill_pyappify(timeout, exit_event=exit_event)
161
203
 
162
204
 
163
- def _wait_for_process_exit(process_pid, timeout=30):
205
+ def _wait_for_process_exit(process_pid, timeout=30, exit_event=None):
164
206
  if sys.platform == "win32" and ctypes:
165
207
  synchronize = 0x00100000
166
208
  wait_timeout = 0x00000102
@@ -181,22 +223,32 @@ def _wait_for_process_exit(process_pid, timeout=30):
181
223
  handle = ctypes.windll.kernel32.OpenProcess(synchronize, False, process_pid)
182
224
  if handle:
183
225
  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
226
+ deadline = time.monotonic() + timeout
227
+ while time.monotonic() < deadline:
228
+ if _exit_requested(exit_event):
229
+ return False
230
+ remaining_ms = max(0, int((deadline - time.monotonic()) * 1000))
231
+ result = ctypes.windll.kernel32.WaitForSingleObject(
232
+ handle, min(100, remaining_ms)
233
+ )
234
+ if result == wait_failed:
235
+ return False
236
+ if result != wait_timeout:
237
+ return True
238
+ return False
190
239
  finally:
191
240
  ctypes.windll.kernel32.CloseHandle(handle)
192
241
 
193
242
  deadline = time.monotonic() + timeout
194
243
  while time.monotonic() < deadline:
244
+ if _exit_requested(exit_event):
245
+ return False
195
246
  try:
196
247
  os.kill(process_pid, 0)
197
248
  except OSError:
198
249
  return True
199
- time.sleep(0.1)
250
+ if _wait_for_exit(exit_event, 0.1):
251
+ return False
200
252
  return False
201
253
 
202
254
 
@@ -280,12 +332,38 @@ def _require_pyappify_executable():
280
332
  if not pyappify_executable:
281
333
  raise FileNotFoundError(
282
334
  "PyAppify executable was not found. Set PYAPPIFY_EXECUTABLE to an "
283
- "existing executable or place pyappify.exe in this directory or a parent directory."
335
+ "existing executable or place an executable whose name starts with the app name "
336
+ "in the working directory or a parent directory."
284
337
  )
285
338
  return pyappify_executable
286
339
 
287
340
 
288
- def _run_launcher_api(arguments, timeout=300):
341
+ def _exit_requested(*events):
342
+ return any(event is not None and event.is_set() for event in events)
343
+
344
+
345
+ def _wait_for_exit(exit_event, timeout):
346
+ if exit_event is not None:
347
+ return exit_event.wait(timeout)
348
+ time.sleep(timeout)
349
+ return False
350
+
351
+
352
+ def _raise_if_exit_requested(exit_event):
353
+ if _exit_requested(exit_event):
354
+ raise InterruptedError("PyAppify operation cancelled because exit_event was set")
355
+
356
+
357
+ def _terminate_process(process):
358
+ try:
359
+ if process.poll() is None:
360
+ process.terminate()
361
+ except (AttributeError, OSError):
362
+ pass
363
+
364
+
365
+ def _run_launcher_api(arguments, timeout=300, exit_event=None):
366
+ _raise_if_exit_requested(exit_event)
289
367
  executable = _require_pyappify_executable()
290
368
  response_path = os.path.join(
291
369
  tempfile.gettempdir(),
@@ -293,7 +371,7 @@ def _run_launcher_api(arguments, timeout=300):
293
371
  )
294
372
  command = list(arguments) + ["--response-file", response_path]
295
373
  try:
296
- subprocess.Popen(
374
+ process = subprocess.Popen(
297
375
  [executable] + command,
298
376
  cwd=os.path.dirname(executable) or None,
299
377
  stdout=subprocess.DEVNULL,
@@ -305,8 +383,13 @@ def _run_launcher_api(arguments, timeout=300):
305
383
  )
306
384
 
307
385
  deadline = time.monotonic() + timeout
386
+ cancelled = False
308
387
  try:
309
388
  while time.monotonic() < deadline:
389
+ if _exit_requested(exit_event):
390
+ cancelled = True
391
+ _terminate_process(process)
392
+ break
310
393
  try:
311
394
  with open(response_path, "r", encoding="utf-8") as response_file:
312
395
  response = json.load(response_file)
@@ -318,18 +401,25 @@ def _run_launcher_api(arguments, timeout=300):
318
401
  except json.JSONDecodeError:
319
402
  # The running launcher may still be completing the response write.
320
403
  pass
321
- time.sleep(0.05)
404
+ if _wait_for_exit(exit_event, 0.05):
405
+ cancelled = True
406
+ _terminate_process(process)
407
+ break
322
408
  finally:
323
409
  try:
324
410
  os.remove(response_path)
325
411
  except FileNotFoundError:
326
412
  pass
327
413
 
414
+ if cancelled:
415
+ raise InterruptedError("PyAppify operation cancelled because exit_event was set")
328
416
  raise TimeoutError("Timed out waiting for a response from PyAppify")
329
417
 
330
418
 
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."""
419
+ def get_version_list(
420
+ number_versions=10, release_only=True, timeout=120, exit_event=None
421
+ ):
422
+ """Return version details, cancelling with InterruptedError on exit_event."""
333
423
  if isinstance(number_versions, bool) or not isinstance(number_versions, int):
334
424
  raise TypeError("number_versions must be an integer")
335
425
  if number_versions <= 0:
@@ -338,7 +428,8 @@ def get_version_list(number_versions=10, release_only=True, timeout=120):
338
428
  raise TypeError("release_only must be a boolean")
339
429
 
340
430
  if "PYAPPIFY_PYTHON_TEST" in os.environ:
341
- time.sleep(5)
431
+ if _wait_for_exit(exit_event, 5):
432
+ _raise_if_exit_requested(exit_event)
342
433
  return _get_mock_version_list(number_versions)
343
434
 
344
435
  response = _run_launcher_api(
@@ -350,15 +441,16 @@ def get_version_list(number_versions=10, release_only=True, timeout=120):
350
441
  str(release_only).lower(),
351
442
  ],
352
443
  timeout=timeout,
444
+ exit_event=exit_event,
353
445
  )
354
446
  if not isinstance(response, list):
355
447
  raise RuntimeError("PyAppify returned an invalid version-list response")
356
448
  return response
357
449
 
358
450
 
359
- def get_versions(number_versions=10, release_only=True, timeout=120):
451
+ def get_versions(number_versions=10, release_only=True, timeout=120, exit_event=None):
360
452
  """Alias for get_version_list."""
361
- return get_version_list(number_versions, release_only, timeout)
453
+ return get_version_list(number_versions, release_only, timeout, exit_event)
362
454
 
363
455
 
364
456
  def calculate_update_notes(update_notes, current_version, target_version):
@@ -401,15 +493,17 @@ def calculate_update_notes(update_notes, current_version, target_version):
401
493
  return [str(note) for note in notes]
402
494
 
403
495
 
404
- def update_to_version(version, timeout=300):
405
- """Start PyAppify (or forward to it) and update the managed app to version."""
496
+ def update_to_version(version, timeout=300, exit_event=None):
497
+ """Update the app, cancelling with InterruptedError on exit_event."""
406
498
  if not isinstance(version, str) or not version.strip():
407
499
  raise ValueError("version must be a non-empty string")
408
500
  if "PYAPPIFY_PYTHON_TEST" in os.environ:
501
+ _raise_if_exit_requested(exit_event)
409
502
  return {"updated": True, "version": version, "mocked": True}
410
503
  response = _run_launcher_api(
411
504
  ["--update-to-version", version],
412
505
  timeout=timeout,
506
+ exit_event=exit_event,
413
507
  )
414
508
  if not isinstance(response, dict) or not response.get("updated"):
415
509
  raise RuntimeError("PyAppify returned an invalid update response")
@@ -463,10 +557,11 @@ def _get_mock_version_list(number_versions):
463
557
  return versions[:number_versions]
464
558
 
465
559
 
466
- def _replace_executable(source_path, target_path, timeout=30):
560
+ def _replace_executable(source_path, target_path, timeout=30, exit_event=None):
467
561
  deadline = time.monotonic() + timeout
468
562
  last_error = None
469
563
  while True:
564
+ _raise_if_exit_requested(exit_event)
470
565
  try:
471
566
  shutil.move(source_path, target_path)
472
567
  return
@@ -474,7 +569,8 @@ def _replace_executable(source_path, target_path, timeout=30):
474
569
  last_error = e
475
570
  if time.monotonic() >= deadline:
476
571
  raise last_error
477
- time.sleep(0.25)
572
+ if _wait_for_exit(exit_event, 0.25):
573
+ _raise_if_exit_requested(exit_event)
478
574
 
479
575
 
480
576
  def hide_pyappify():
@@ -487,7 +583,13 @@ def hide_pyappify():
487
583
  log.error(f"Failed to minimize window for process with PID {pid}: {e}")
488
584
  pass
489
585
 
490
- def upgrade(to_version, executable_sha256, executable_zip_urls, stop_event=None):
586
+ def upgrade(
587
+ to_version,
588
+ executable_sha256,
589
+ executable_zip_urls,
590
+ stop_event=None,
591
+ exit_event=None,
592
+ ):
491
593
  log = _get_logger()
492
594
  if not pyappify_upgradeable or not is_greater_version(to_version, pyappify_version):
493
595
  log.info(f"pyappify no need to upgrade {pyappify_upgradeable} {to_version} {executable_sha256} {executable_zip_urls}")
@@ -497,17 +599,23 @@ def upgrade(to_version, executable_sha256, executable_zip_urls, stop_event=None)
497
599
  def _do_upgrade():
498
600
  tmp_dir = os.path.join(os.getcwd(), "pyappify_tmp")
499
601
  try:
602
+ if _exit_requested(stop_event, exit_event):
603
+ return
500
604
  os.makedirs(tmp_dir, exist_ok=True)
501
605
  downloaded_zip_path = None
502
606
  for url in executable_zip_urls:
607
+ if _exit_requested(stop_event, exit_event):
608
+ return
503
609
  try:
504
610
  log.info(
505
611
  f"pyappify start to download {url}")
506
612
  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:
613
+ with urllib.request.urlopen(
614
+ url, timeout=_DOWNLOAD_IO_TIMEOUT_SECONDS
615
+ ) as response, open(local_zip_path, 'wb') as out_file:
508
616
  while True:
509
- if stop_event and stop_event.is_set():
510
- log.info("pyappify Upgrade download cancelled by stop event.")
617
+ if _exit_requested(stop_event, exit_event):
618
+ log.info("pyappify Upgrade download cancelled.")
511
619
  return
512
620
  chunk = response.read(8192)
513
621
  if not chunk:
@@ -526,11 +634,16 @@ def upgrade(to_version, executable_sha256, executable_zip_urls, stop_event=None)
526
634
  return
527
635
 
528
636
  with zipfile.ZipFile(downloaded_zip_path, 'r') as zip_ref:
529
- zip_ref.extractall(tmp_dir)
637
+ for member in zip_ref.infolist():
638
+ if _exit_requested(stop_event, exit_event):
639
+ return
640
+ zip_ref.extract(member, tmp_dir)
530
641
 
531
642
  new_executable_name = os.path.basename(pyappify_executable)
532
643
  found_executable_path = None
533
644
  for root, _, files in os.walk(tmp_dir):
645
+ if _exit_requested(stop_event, exit_event):
646
+ return
534
647
  if new_executable_name in files:
535
648
  found_executable_path = os.path.join(root, new_executable_name)
536
649
  break
@@ -541,15 +654,26 @@ def upgrade(to_version, executable_sha256, executable_zip_urls, stop_event=None)
541
654
 
542
655
  sha256_hash = hashlib.sha256()
543
656
  with open(found_executable_path, "rb") as f:
544
- for byte_block in iter(lambda: f.read(4096), b""):
657
+ while True:
658
+ if _exit_requested(stop_event, exit_event):
659
+ return
660
+ byte_block = f.read(4096)
661
+ if not byte_block:
662
+ break
545
663
  sha256_hash.update(byte_block)
546
664
 
547
665
  if executable_sha256 and sha256_hash.hexdigest() != executable_sha256:
548
666
  log.error("pyappify SHA256 checksum mismatch.")
549
667
  return
550
668
 
551
- kill_pyappify()
552
- _replace_executable(found_executable_path, pyappify_executable)
669
+ if _exit_requested(stop_event, exit_event):
670
+ return
671
+ kill_pyappify(exit_event=exit_event or stop_event)
672
+ _replace_executable(
673
+ found_executable_path,
674
+ pyappify_executable,
675
+ exit_event=exit_event or stop_event,
676
+ )
553
677
  log.info(f"pyappify Upgrade success")
554
678
  except Exception as e:
555
679
  log.error(f"pyappify Upgrade failed: {e}")
@@ -560,6 +684,7 @@ def upgrade(to_version, executable_sha256, executable_zip_urls, stop_event=None)
560
684
  thread = threading.Thread(target=_do_upgrade)
561
685
  thread.daemon = True
562
686
  thread.start()
687
+ return thread
563
688
 
564
689
 
565
690
  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.10
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.10"
8
8
  description = "My awesome Python application"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.7"
File without changes
File without changes