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.
- {pyappify-1.0.8 → pyappify-1.0.10}/PKG-INFO +1 -1
- {pyappify-1.0.8 → pyappify-1.0.10}/pyappify/__init__.py +165 -40
- {pyappify-1.0.8 → pyappify-1.0.10}/pyappify/app_config.py +28 -10
- {pyappify-1.0.8 → pyappify-1.0.10}/pyappify.egg-info/PKG-INFO +1 -1
- {pyappify-1.0.8 → pyappify-1.0.10}/pyproject.toml +1 -1
- {pyappify-1.0.8 → pyappify-1.0.10}/pyappify/main.py +0 -0
- {pyappify-1.0.8 → pyappify-1.0.10}/pyappify.egg-info/SOURCES.txt +0 -0
- {pyappify-1.0.8 → pyappify-1.0.10}/pyappify.egg-info/dependency_links.txt +0 -0
- {pyappify-1.0.8 → pyappify-1.0.10}/pyappify.egg-info/entry_points.txt +0 -0
- {pyappify-1.0.8 → pyappify-1.0.10}/pyappify.egg-info/requires.txt +0 -0
- {pyappify-1.0.8 → pyappify-1.0.10}/pyappify.egg-info/top_level.txt +0 -0
- {pyappify-1.0.8 → pyappify-1.0.10}/setup.cfg +0 -0
|
@@ -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
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if
|
|
81
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
332
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
510
|
-
log.info("pyappify Upgrade download cancelled
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
552
|
-
|
|
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(
|
|
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(
|
|
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():
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|