pyappify 1.0.4__tar.gz → 1.0.6__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.4 → pyappify-1.0.6}/PKG-INFO +1 -1
- {pyappify-1.0.4 → pyappify-1.0.6}/pyappify/__init__.py +195 -79
- pyappify-1.0.6/pyappify/app_config.py +244 -0
- {pyappify-1.0.4 → pyappify-1.0.6}/pyappify.egg-info/PKG-INFO +1 -1
- {pyappify-1.0.4 → pyappify-1.0.6}/pyappify.egg-info/SOURCES.txt +3 -0
- {pyappify-1.0.4 → pyappify-1.0.6}/pyproject.toml +1 -1
- pyappify-1.0.6/tests/TestAppConfig.py +89 -0
- pyappify-1.0.6/tests/TestProcessControls.py +77 -0
- {pyappify-1.0.4 → pyappify-1.0.6}/tests/TestUpdateEnv.py +6 -0
- {pyappify-1.0.4 → pyappify-1.0.6}/pyappify/main.py +0 -0
- {pyappify-1.0.4 → pyappify-1.0.6}/pyappify.egg-info/dependency_links.txt +0 -0
- {pyappify-1.0.4 → pyappify-1.0.6}/pyappify.egg-info/entry_points.txt +0 -0
- {pyappify-1.0.4 → pyappify-1.0.6}/pyappify.egg-info/requires.txt +0 -0
- {pyappify-1.0.4 → pyappify-1.0.6}/pyappify.egg-info/top_level.txt +0 -0
- {pyappify-1.0.4 → pyappify-1.0.6}/setup.cfg +0 -0
- {pyappify-1.0.4 → pyappify-1.0.6}/tests/TestUpgrade.py +0 -0
- {pyappify-1.0.4 → pyappify-1.0.6}/tests/TestUpgradeOnline.py +0 -0
- {pyappify-1.0.4 → pyappify-1.0.6}/tests/TestVersion.py +0 -0
|
@@ -1,35 +1,56 @@
|
|
|
1
|
-
# pyappify/__init__.py
|
|
1
|
+
# pyappify/__init__.py
|
|
2
2
|
import os
|
|
3
3
|
import signal
|
|
4
4
|
import hashlib
|
|
5
5
|
import json
|
|
6
6
|
import logging
|
|
7
7
|
import shutil
|
|
8
|
+
import subprocess
|
|
8
9
|
import urllib.request
|
|
9
10
|
import zipfile
|
|
10
11
|
import threading
|
|
11
12
|
import time
|
|
12
13
|
|
|
14
|
+
from .app_config import (
|
|
15
|
+
UPDATE_METHOD_AUTO,
|
|
16
|
+
UPDATE_METHOD_AUTO_PRE_RELEASE,
|
|
17
|
+
UPDATE_METHOD_MANUAL,
|
|
18
|
+
add_app_config_listener,
|
|
19
|
+
configure_app_json,
|
|
20
|
+
get_app_config,
|
|
21
|
+
get_app_json_path,
|
|
22
|
+
get_auto_start,
|
|
23
|
+
get_update_method,
|
|
24
|
+
remove_app_config_listener,
|
|
25
|
+
set_auto_start,
|
|
26
|
+
set_update_method,
|
|
27
|
+
start_app_config_watcher,
|
|
28
|
+
stop_app_config_watcher,
|
|
29
|
+
update_app_config,
|
|
30
|
+
)
|
|
31
|
+
|
|
13
32
|
app_version = os.environ.get("PYAPPIFY_APP_VERSION")
|
|
14
33
|
app_starting_version = os.environ.get("PYAPPIFY_APP_STARTING_VERSION") or app_version
|
|
15
34
|
update_note = os.environ.get("PYAPPIFY_UPDATE_NOTE")
|
|
16
35
|
app_profile = os.environ.get("PYAPPIFY_APP_PROFILE")
|
|
36
|
+
app_locale = os.environ.get("PYAPPIFY_LOCALE") or "en"
|
|
17
37
|
pyappify_version = os.environ.get("PYAPPIFY_VERSION")
|
|
18
38
|
pyappify_executable = os.environ.get("PYAPPIFY_EXECUTABLE")
|
|
19
|
-
|
|
39
|
+
app_json_path = configure_app_json(os.environ.get("PYAPPIFY_APP_JSON_PATH"))
|
|
40
|
+
|
|
20
41
|
pyappify_upgradeable = os.environ.get("PYAPPIFY_UPGRADEABLE") == '1'
|
|
21
42
|
logger = None
|
|
22
43
|
_console_logger = None
|
|
23
|
-
|
|
24
|
-
try:
|
|
25
|
-
pid = int(os.environ.get("PYAPPIFY_PID"))
|
|
26
|
-
except (ValueError, TypeError):
|
|
27
|
-
pid = None
|
|
28
|
-
|
|
29
|
-
import sys
|
|
30
|
-
|
|
31
|
-
try:
|
|
32
|
-
import ctypes
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
pid = int(os.environ.get("PYAPPIFY_PID"))
|
|
47
|
+
except (ValueError, TypeError):
|
|
48
|
+
pid = None
|
|
49
|
+
|
|
50
|
+
import sys
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
import ctypes
|
|
33
54
|
except ImportError:
|
|
34
55
|
ctypes = None
|
|
35
56
|
|
|
@@ -50,40 +71,63 @@ def _get_logger():
|
|
|
50
71
|
return _console_logger
|
|
51
72
|
|
|
52
73
|
|
|
74
|
+
def _find_visible_window_by_pid(process_pid):
|
|
75
|
+
if not ctypes or sys.platform != "win32":
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
found_hwnd = []
|
|
79
|
+
EnumWindowsProc = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p)
|
|
80
|
+
|
|
81
|
+
def enum_windows_callback(hwnd, lParam):
|
|
82
|
+
owner_pid = ctypes.c_ulong()
|
|
83
|
+
ctypes.windll.user32.GetWindowThreadProcessId(hwnd, ctypes.byref(owner_pid))
|
|
84
|
+
if owner_pid.value == process_pid and ctypes.windll.user32.IsWindowVisible(hwnd):
|
|
85
|
+
found_hwnd.append(hwnd)
|
|
86
|
+
return False
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
ctypes.windll.user32.EnumWindows(EnumWindowsProc(enum_windows_callback), 0)
|
|
90
|
+
|
|
91
|
+
return found_hwnd[0] if found_hwnd else None
|
|
92
|
+
|
|
93
|
+
|
|
53
94
|
def minimize_window_by_pid(pid):
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
return
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
return True
|
|
73
|
-
|
|
74
|
-
return False
|
|
75
|
-
|
|
76
|
-
def kill_pyappify():
|
|
95
|
+
hwnd = _find_visible_window_by_pid(pid)
|
|
96
|
+
if hwnd:
|
|
97
|
+
ctypes.windll.user32.ShowWindow(hwnd, 6)
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def bring_window_to_front_by_pid(pid):
|
|
104
|
+
hwnd = _find_visible_window_by_pid(pid)
|
|
105
|
+
if hwnd:
|
|
106
|
+
ctypes.windll.user32.ShowWindow(hwnd, 9)
|
|
107
|
+
return bool(ctypes.windll.user32.SetForegroundWindow(hwnd))
|
|
108
|
+
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def kill_pyappify(timeout=30):
|
|
77
113
|
if pid:
|
|
78
114
|
log = _get_logger()
|
|
79
115
|
log.info(f"Attempting to terminate process with PID: {pid}")
|
|
80
116
|
try:
|
|
81
117
|
os.kill(pid, signal.SIGTERM)
|
|
82
|
-
if not _wait_for_process_exit(pid):
|
|
118
|
+
if not _wait_for_process_exit(pid, timeout):
|
|
83
119
|
log.warning(f"Timed out waiting for process with PID {pid} to exit.")
|
|
120
|
+
return False
|
|
121
|
+
log.info(f'_wait_for_process_exit success {pid}')
|
|
122
|
+
return True
|
|
84
123
|
except Exception as e:
|
|
85
124
|
log.error(f"Failed to terminate process with PID {pid}: {e}")
|
|
86
|
-
|
|
125
|
+
return False
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def kill_pyappify_exe(timeout=30):
|
|
130
|
+
return kill_pyappify(timeout)
|
|
87
131
|
|
|
88
132
|
|
|
89
133
|
def _wait_for_process_exit(process_pid, timeout=30):
|
|
@@ -126,6 +170,74 @@ def _wait_for_process_exit(process_pid, timeout=30):
|
|
|
126
170
|
return False
|
|
127
171
|
|
|
128
172
|
|
|
173
|
+
def _is_process_running(process_pid):
|
|
174
|
+
if not process_pid:
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
if sys.platform == "win32" and ctypes:
|
|
178
|
+
synchronize = 0x00100000
|
|
179
|
+
wait_timeout = 0x00000102
|
|
180
|
+
ctypes.windll.kernel32.OpenProcess.argtypes = (
|
|
181
|
+
ctypes.c_uint,
|
|
182
|
+
ctypes.c_bool,
|
|
183
|
+
ctypes.c_ulong,
|
|
184
|
+
)
|
|
185
|
+
ctypes.windll.kernel32.OpenProcess.restype = ctypes.c_void_p
|
|
186
|
+
ctypes.windll.kernel32.WaitForSingleObject.argtypes = (
|
|
187
|
+
ctypes.c_void_p,
|
|
188
|
+
ctypes.c_uint,
|
|
189
|
+
)
|
|
190
|
+
ctypes.windll.kernel32.WaitForSingleObject.restype = ctypes.c_uint
|
|
191
|
+
ctypes.windll.kernel32.CloseHandle.argtypes = (ctypes.c_void_p,)
|
|
192
|
+
ctypes.windll.kernel32.CloseHandle.restype = ctypes.c_bool
|
|
193
|
+
handle = ctypes.windll.kernel32.OpenProcess(synchronize, False, process_pid)
|
|
194
|
+
if not handle:
|
|
195
|
+
return False
|
|
196
|
+
try:
|
|
197
|
+
return ctypes.windll.kernel32.WaitForSingleObject(handle, 0) == wait_timeout
|
|
198
|
+
finally:
|
|
199
|
+
ctypes.windll.kernel32.CloseHandle(handle)
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
os.kill(process_pid, 0)
|
|
203
|
+
except OSError:
|
|
204
|
+
return False
|
|
205
|
+
return True
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def show_pyappify(args=None, cwd=None, env=None):
|
|
209
|
+
global pid
|
|
210
|
+
|
|
211
|
+
log = _get_logger()
|
|
212
|
+
if _is_process_running(pid):
|
|
213
|
+
log.info(f"PyAppify is already running with PID: {pid}")
|
|
214
|
+
bring_window_to_front_by_pid(pid)
|
|
215
|
+
return pid
|
|
216
|
+
|
|
217
|
+
if not pyappify_executable:
|
|
218
|
+
log.error("PYAPPIFY_EXECUTABLE is not configured.")
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
command = [pyappify_executable]
|
|
222
|
+
if args:
|
|
223
|
+
if isinstance(args, str):
|
|
224
|
+
command.append(args)
|
|
225
|
+
else:
|
|
226
|
+
command.extend(args)
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
process = subprocess.Popen(
|
|
230
|
+
command,
|
|
231
|
+
cwd=cwd or os.path.dirname(pyappify_executable) or None,
|
|
232
|
+
env=env,
|
|
233
|
+
)
|
|
234
|
+
pid = process.pid
|
|
235
|
+
return pid
|
|
236
|
+
except Exception as e:
|
|
237
|
+
log.error(f"Failed to start PyAppify executable {pyappify_executable}: {e}")
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
|
|
129
241
|
def _replace_executable(source_path, target_path, timeout=30):
|
|
130
242
|
deadline = time.monotonic() + timeout
|
|
131
243
|
last_error = None
|
|
@@ -160,7 +272,7 @@ def upgrade(to_version, executable_sha256, executable_zip_urls, stop_event=None)
|
|
|
160
272
|
def _do_upgrade():
|
|
161
273
|
tmp_dir = os.path.join(os.getcwd(), "pyappify_tmp")
|
|
162
274
|
try:
|
|
163
|
-
os.makedirs(tmp_dir, exist_ok=True)
|
|
275
|
+
os.makedirs(tmp_dir, exist_ok=True)
|
|
164
276
|
downloaded_zip_path = None
|
|
165
277
|
for url in executable_zip_urls:
|
|
166
278
|
try:
|
|
@@ -172,10 +284,10 @@ def upgrade(to_version, executable_sha256, executable_zip_urls, stop_event=None)
|
|
|
172
284
|
if stop_event and stop_event.is_set():
|
|
173
285
|
log.info("pyappify Upgrade download cancelled by stop event.")
|
|
174
286
|
return
|
|
175
|
-
chunk = response.read(8192)
|
|
176
|
-
if not chunk:
|
|
177
|
-
break
|
|
178
|
-
out_file.write(chunk)
|
|
287
|
+
chunk = response.read(8192)
|
|
288
|
+
if not chunk:
|
|
289
|
+
break
|
|
290
|
+
out_file.write(chunk)
|
|
179
291
|
downloaded_zip_path = local_zip_path
|
|
180
292
|
log.info(
|
|
181
293
|
f"pyappify download success {url}")
|
|
@@ -187,26 +299,26 @@ def upgrade(to_version, executable_sha256, executable_zip_urls, stop_event=None)
|
|
|
187
299
|
if not downloaded_zip_path:
|
|
188
300
|
log.error("pyappify Failed to download upgrade.")
|
|
189
301
|
return
|
|
190
|
-
|
|
191
|
-
with zipfile.ZipFile(downloaded_zip_path, 'r') as zip_ref:
|
|
192
|
-
zip_ref.extractall(tmp_dir)
|
|
193
|
-
|
|
194
|
-
new_executable_name = os.path.basename(pyappify_executable)
|
|
195
|
-
found_executable_path = None
|
|
196
|
-
for root, _, files in os.walk(tmp_dir):
|
|
197
|
-
if new_executable_name in files:
|
|
198
|
-
found_executable_path = os.path.join(root, new_executable_name)
|
|
199
|
-
break
|
|
200
|
-
|
|
302
|
+
|
|
303
|
+
with zipfile.ZipFile(downloaded_zip_path, 'r') as zip_ref:
|
|
304
|
+
zip_ref.extractall(tmp_dir)
|
|
305
|
+
|
|
306
|
+
new_executable_name = os.path.basename(pyappify_executable)
|
|
307
|
+
found_executable_path = None
|
|
308
|
+
for root, _, files in os.walk(tmp_dir):
|
|
309
|
+
if new_executable_name in files:
|
|
310
|
+
found_executable_path = os.path.join(root, new_executable_name)
|
|
311
|
+
break
|
|
312
|
+
|
|
201
313
|
if not found_executable_path:
|
|
202
314
|
log.error("pyappify Executable not found in zip.")
|
|
203
315
|
return
|
|
204
|
-
|
|
205
|
-
sha256_hash = hashlib.sha256()
|
|
206
|
-
with open(found_executable_path, "rb") as f:
|
|
207
|
-
for byte_block in iter(lambda: f.read(4096), b""):
|
|
208
|
-
sha256_hash.update(byte_block)
|
|
209
|
-
|
|
316
|
+
|
|
317
|
+
sha256_hash = hashlib.sha256()
|
|
318
|
+
with open(found_executable_path, "rb") as f:
|
|
319
|
+
for byte_block in iter(lambda: f.read(4096), b""):
|
|
320
|
+
sha256_hash.update(byte_block)
|
|
321
|
+
|
|
210
322
|
if executable_sha256 and sha256_hash.hexdigest() != executable_sha256:
|
|
211
323
|
log.error("pyappify SHA256 checksum mismatch.")
|
|
212
324
|
return
|
|
@@ -216,10 +328,10 @@ def upgrade(to_version, executable_sha256, executable_zip_urls, stop_event=None)
|
|
|
216
328
|
log.info(f"pyappify Upgrade success")
|
|
217
329
|
except Exception as e:
|
|
218
330
|
log.error(f"pyappify Upgrade failed: {e}")
|
|
219
|
-
finally:
|
|
220
|
-
if os.path.exists(tmp_dir):
|
|
221
|
-
shutil.rmtree(tmp_dir)
|
|
222
|
-
|
|
331
|
+
finally:
|
|
332
|
+
if os.path.exists(tmp_dir):
|
|
333
|
+
shutil.rmtree(tmp_dir)
|
|
334
|
+
|
|
223
335
|
thread = threading.Thread(target=_do_upgrade)
|
|
224
336
|
thread.daemon = True
|
|
225
337
|
thread.start()
|
|
@@ -257,23 +369,27 @@ def get_update_note():
|
|
|
257
369
|
return get_update_notes()
|
|
258
370
|
|
|
259
371
|
|
|
372
|
+
def get_locale():
|
|
373
|
+
return app_locale
|
|
374
|
+
|
|
375
|
+
|
|
260
376
|
def is_greater_version(version1, version2):
|
|
261
377
|
"""
|
|
262
378
|
Compares two semantic version strings.
|
|
263
|
-
|
|
264
|
-
Args:
|
|
265
|
-
version1 (str): The first version string.
|
|
266
|
-
version2 (str): The second version string.
|
|
267
|
-
|
|
268
|
-
Returns:
|
|
269
|
-
bool: True if version1 is strictly greater than version2,
|
|
270
|
-
False otherwise or if parsing fails.
|
|
271
|
-
"""
|
|
272
|
-
try:
|
|
273
|
-
version1 = version1.lstrip('v')
|
|
274
|
-
version2 = version2.lstrip('v')
|
|
275
|
-
v1_parts = [int(p) for p in version1.split('.')]
|
|
276
|
-
v2_parts = [int(p) for p in version2.split('.')]
|
|
277
|
-
return v1_parts > v2_parts
|
|
278
|
-
except (ValueError, AttributeError):
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
version1 (str): The first version string.
|
|
382
|
+
version2 (str): The second version string.
|
|
383
|
+
|
|
384
|
+
Returns:
|
|
385
|
+
bool: True if version1 is strictly greater than version2,
|
|
386
|
+
False otherwise or if parsing fails.
|
|
387
|
+
"""
|
|
388
|
+
try:
|
|
389
|
+
version1 = version1.lstrip('v')
|
|
390
|
+
version2 = version2.lstrip('v')
|
|
391
|
+
v1_parts = [int(p) for p in version1.split('.')]
|
|
392
|
+
v2_parts = [int(p) for p in version2.split('.')]
|
|
393
|
+
return v1_parts > v2_parts
|
|
394
|
+
except (ValueError, AttributeError):
|
|
279
395
|
return False
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import atexit
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
import threading
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
UPDATE_METHOD_MANUAL = "MANUAL_UPDATE"
|
|
10
|
+
UPDATE_METHOD_AUTO = "AUTO_UPDATE"
|
|
11
|
+
UPDATE_METHOD_AUTO_PRE_RELEASE = "AUTO_UPDATE_PRE_RELEASE"
|
|
12
|
+
UPDATE_METHODS = {
|
|
13
|
+
UPDATE_METHOD_MANUAL,
|
|
14
|
+
UPDATE_METHOD_AUTO,
|
|
15
|
+
UPDATE_METHOD_AUTO_PRE_RELEASE,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
_UNSET = object()
|
|
19
|
+
_LOG = logging.getLogger("pyappify.app_config")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AppConfigAPI:
|
|
23
|
+
"""Thread-safe access to the app preferences stored by PyAppify."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, path=None, watch=False, watch_interval=0.5):
|
|
26
|
+
self._lock = threading.RLock()
|
|
27
|
+
self._path = None
|
|
28
|
+
self._preferences = {
|
|
29
|
+
"auto_start": False,
|
|
30
|
+
"update_method": UPDATE_METHOD_AUTO,
|
|
31
|
+
}
|
|
32
|
+
self._listeners = set()
|
|
33
|
+
self._watch_interval = watch_interval
|
|
34
|
+
self._watch_stop = None
|
|
35
|
+
self._watch_thread = None
|
|
36
|
+
if path:
|
|
37
|
+
self.configure(path, watch=watch, watch_interval=watch_interval)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def path(self):
|
|
41
|
+
with self._lock:
|
|
42
|
+
return self._path
|
|
43
|
+
|
|
44
|
+
def configure(self, path, watch=True, watch_interval=0.5):
|
|
45
|
+
self.stop_watcher()
|
|
46
|
+
normalized_path = os.path.abspath(os.path.expanduser(path)) if path else None
|
|
47
|
+
with self._lock:
|
|
48
|
+
self._path = normalized_path
|
|
49
|
+
self._watch_interval = watch_interval
|
|
50
|
+
self._preferences = {
|
|
51
|
+
"auto_start": False,
|
|
52
|
+
"update_method": UPDATE_METHOD_AUTO,
|
|
53
|
+
}
|
|
54
|
+
if normalized_path:
|
|
55
|
+
self.refresh(notify=False)
|
|
56
|
+
if watch:
|
|
57
|
+
self.start_watcher()
|
|
58
|
+
return normalized_path
|
|
59
|
+
|
|
60
|
+
def _read_document(self):
|
|
61
|
+
path = self.path
|
|
62
|
+
if not path:
|
|
63
|
+
raise RuntimeError("PYAPPIFY_APP_JSON_PATH is not configured")
|
|
64
|
+
with open(path, "r", encoding="utf-8") as config_file:
|
|
65
|
+
document = json.load(config_file)
|
|
66
|
+
if not isinstance(document, dict):
|
|
67
|
+
raise ValueError("app.json must contain a JSON object")
|
|
68
|
+
return document
|
|
69
|
+
|
|
70
|
+
@staticmethod
|
|
71
|
+
def _preferences_from_document(document):
|
|
72
|
+
auto_start = document.get("auto_start", False)
|
|
73
|
+
update_method = document.get("update_method", UPDATE_METHOD_AUTO)
|
|
74
|
+
if not isinstance(auto_start, bool):
|
|
75
|
+
raise ValueError("auto_start must be a boolean")
|
|
76
|
+
if not isinstance(update_method, str) or update_method not in UPDATE_METHODS:
|
|
77
|
+
raise ValueError("Unsupported update_method: {}".format(update_method))
|
|
78
|
+
return {
|
|
79
|
+
"auto_start": auto_start,
|
|
80
|
+
"update_method": update_method,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
def _store_preferences(self, preferences, notify=True):
|
|
84
|
+
with self._lock:
|
|
85
|
+
changed = preferences != self._preferences
|
|
86
|
+
self._preferences = dict(preferences)
|
|
87
|
+
listeners = tuple(self._listeners) if changed and notify else ()
|
|
88
|
+
result = dict(self._preferences)
|
|
89
|
+
for listener in listeners:
|
|
90
|
+
try:
|
|
91
|
+
listener(dict(result))
|
|
92
|
+
except Exception:
|
|
93
|
+
_LOG.exception("An app config listener failed")
|
|
94
|
+
return result
|
|
95
|
+
|
|
96
|
+
def refresh(self, notify=True):
|
|
97
|
+
with self._lock:
|
|
98
|
+
preferences = self._preferences_from_document(self._read_document())
|
|
99
|
+
return self._store_preferences(preferences, notify=notify)
|
|
100
|
+
|
|
101
|
+
def get(self, refresh=True):
|
|
102
|
+
if refresh and self.path:
|
|
103
|
+
return self.refresh()
|
|
104
|
+
with self._lock:
|
|
105
|
+
return dict(self._preferences)
|
|
106
|
+
|
|
107
|
+
def update(self, auto_start=_UNSET, update_method=_UNSET):
|
|
108
|
+
if auto_start is not _UNSET and not isinstance(auto_start, bool):
|
|
109
|
+
raise ValueError("auto_start must be a boolean")
|
|
110
|
+
if update_method is not _UNSET and (
|
|
111
|
+
not isinstance(update_method, str) or update_method not in UPDATE_METHODS
|
|
112
|
+
):
|
|
113
|
+
raise ValueError("Unsupported update_method: {}".format(update_method))
|
|
114
|
+
|
|
115
|
+
with self._lock:
|
|
116
|
+
document = self._read_document()
|
|
117
|
+
if auto_start is not _UNSET:
|
|
118
|
+
document["auto_start"] = auto_start
|
|
119
|
+
if update_method is not _UNSET:
|
|
120
|
+
document["update_method"] = update_method
|
|
121
|
+
preferences = self._preferences_from_document(document)
|
|
122
|
+
config_dir = os.path.dirname(self._path) or os.curdir
|
|
123
|
+
temporary_path = None
|
|
124
|
+
try:
|
|
125
|
+
with tempfile.NamedTemporaryFile(
|
|
126
|
+
mode="w",
|
|
127
|
+
encoding="utf-8",
|
|
128
|
+
dir=config_dir,
|
|
129
|
+
prefix=".app-json-",
|
|
130
|
+
suffix=".tmp",
|
|
131
|
+
delete=False,
|
|
132
|
+
) as temporary_file:
|
|
133
|
+
temporary_path = temporary_file.name
|
|
134
|
+
json.dump(document, temporary_file, indent=2, ensure_ascii=False)
|
|
135
|
+
temporary_file.write("\n")
|
|
136
|
+
temporary_file.flush()
|
|
137
|
+
os.fsync(temporary_file.fileno())
|
|
138
|
+
os.replace(temporary_path, self._path)
|
|
139
|
+
temporary_path = None
|
|
140
|
+
finally:
|
|
141
|
+
if temporary_path and os.path.exists(temporary_path):
|
|
142
|
+
os.unlink(temporary_path)
|
|
143
|
+
return self._store_preferences(preferences)
|
|
144
|
+
|
|
145
|
+
def add_listener(self, listener):
|
|
146
|
+
if not callable(listener):
|
|
147
|
+
raise TypeError("listener must be callable")
|
|
148
|
+
with self._lock:
|
|
149
|
+
self._listeners.add(listener)
|
|
150
|
+
return listener
|
|
151
|
+
|
|
152
|
+
def remove_listener(self, listener):
|
|
153
|
+
with self._lock:
|
|
154
|
+
self._listeners.discard(listener)
|
|
155
|
+
|
|
156
|
+
def start_watcher(self):
|
|
157
|
+
with self._lock:
|
|
158
|
+
if not self._path:
|
|
159
|
+
raise RuntimeError("PYAPPIFY_APP_JSON_PATH is not configured")
|
|
160
|
+
if self._watch_thread and self._watch_thread.is_alive():
|
|
161
|
+
return self._watch_thread
|
|
162
|
+
stop_event = threading.Event()
|
|
163
|
+
self._watch_stop = stop_event
|
|
164
|
+
thread = threading.Thread(
|
|
165
|
+
target=self._watch_loop,
|
|
166
|
+
args=(stop_event,),
|
|
167
|
+
name="pyappify-app-config-watch",
|
|
168
|
+
daemon=True,
|
|
169
|
+
)
|
|
170
|
+
self._watch_thread = thread
|
|
171
|
+
thread.start()
|
|
172
|
+
return thread
|
|
173
|
+
|
|
174
|
+
def stop_watcher(self):
|
|
175
|
+
with self._lock:
|
|
176
|
+
stop_event = self._watch_stop
|
|
177
|
+
thread = self._watch_thread
|
|
178
|
+
self._watch_stop = None
|
|
179
|
+
self._watch_thread = None
|
|
180
|
+
if stop_event:
|
|
181
|
+
stop_event.set()
|
|
182
|
+
if thread and thread is not threading.current_thread():
|
|
183
|
+
thread.join(timeout=max(1.0, self._watch_interval * 2))
|
|
184
|
+
|
|
185
|
+
def _watch_loop(self, stop_event):
|
|
186
|
+
while not stop_event.wait(self._watch_interval):
|
|
187
|
+
try:
|
|
188
|
+
self.refresh()
|
|
189
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
190
|
+
_LOG.exception("Failed to refresh app preferences from %s", self.path)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
_api = AppConfigAPI()
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def configure_app_json(path=None, watch=True, watch_interval=0.5):
|
|
197
|
+
return _api.configure(path, watch=watch, watch_interval=watch_interval)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def get_app_json_path():
|
|
201
|
+
return _api.path
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def get_app_config():
|
|
205
|
+
return _api.get()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def update_app_config(auto_start=_UNSET, update_method=_UNSET):
|
|
209
|
+
return _api.update(auto_start=auto_start, update_method=update_method)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def get_auto_start():
|
|
213
|
+
return get_app_config()["auto_start"]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def set_auto_start(auto_start):
|
|
217
|
+
return update_app_config(auto_start=auto_start)["auto_start"]
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def get_update_method():
|
|
221
|
+
return get_app_config()["update_method"]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def set_update_method(update_method):
|
|
225
|
+
return update_app_config(update_method=update_method)["update_method"]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def add_app_config_listener(listener):
|
|
229
|
+
return _api.add_listener(listener)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def remove_app_config_listener(listener):
|
|
233
|
+
_api.remove_listener(listener)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def start_app_config_watcher():
|
|
237
|
+
return _api.start_watcher()
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def stop_app_config_watcher():
|
|
241
|
+
_api.stop_watcher()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
atexit.register(stop_app_config_watcher)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
pyproject.toml
|
|
2
2
|
pyappify/__init__.py
|
|
3
|
+
pyappify/app_config.py
|
|
3
4
|
pyappify/main.py
|
|
4
5
|
pyappify.egg-info/PKG-INFO
|
|
5
6
|
pyappify.egg-info/SOURCES.txt
|
|
@@ -7,6 +8,8 @@ pyappify.egg-info/dependency_links.txt
|
|
|
7
8
|
pyappify.egg-info/entry_points.txt
|
|
8
9
|
pyappify.egg-info/requires.txt
|
|
9
10
|
pyappify.egg-info/top_level.txt
|
|
11
|
+
tests/TestAppConfig.py
|
|
12
|
+
tests/TestProcessControls.py
|
|
10
13
|
tests/TestUpdateEnv.py
|
|
11
14
|
tests/TestUpgrade.py
|
|
12
15
|
tests/TestUpgradeOnline.py
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import tempfile
|
|
4
|
+
import threading
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
from pyappify.app_config import (
|
|
8
|
+
AppConfigAPI,
|
|
9
|
+
UPDATE_METHOD_AUTO_PRE_RELEASE,
|
|
10
|
+
UPDATE_METHOD_MANUAL,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestAppConfig(unittest.TestCase):
|
|
15
|
+
def setUp(self):
|
|
16
|
+
self.temporary_directory = tempfile.TemporaryDirectory()
|
|
17
|
+
self.config_path = os.path.join(self.temporary_directory.name, "app.json")
|
|
18
|
+
self._write_document(
|
|
19
|
+
{
|
|
20
|
+
"name": "example",
|
|
21
|
+
"installed": True,
|
|
22
|
+
"auto_start": False,
|
|
23
|
+
"update_method": "AUTO_UPDATE",
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
self.api = AppConfigAPI(self.config_path)
|
|
27
|
+
|
|
28
|
+
def tearDown(self):
|
|
29
|
+
self.api.stop_watcher()
|
|
30
|
+
self.temporary_directory.cleanup()
|
|
31
|
+
|
|
32
|
+
def _write_document(self, document):
|
|
33
|
+
with open(self.config_path, "w", encoding="utf-8") as config_file:
|
|
34
|
+
json.dump(document, config_file)
|
|
35
|
+
|
|
36
|
+
def test_reads_and_updates_preferences_without_losing_other_fields(self):
|
|
37
|
+
self.assertEqual(
|
|
38
|
+
{"auto_start": False, "update_method": "AUTO_UPDATE"},
|
|
39
|
+
self.api.get(),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
self.api.update(
|
|
43
|
+
auto_start=True,
|
|
44
|
+
update_method=UPDATE_METHOD_AUTO_PRE_RELEASE,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
with open(self.config_path, "r", encoding="utf-8") as config_file:
|
|
48
|
+
saved = json.load(config_file)
|
|
49
|
+
self.assertEqual("example", saved["name"])
|
|
50
|
+
self.assertTrue(saved["installed"])
|
|
51
|
+
self.assertTrue(saved["auto_start"])
|
|
52
|
+
self.assertEqual(UPDATE_METHOD_AUTO_PRE_RELEASE, saved["update_method"])
|
|
53
|
+
|
|
54
|
+
def test_rejects_invalid_preferences(self):
|
|
55
|
+
with self.assertRaises(ValueError):
|
|
56
|
+
self.api.update(auto_start="yes")
|
|
57
|
+
with self.assertRaises(ValueError):
|
|
58
|
+
self.api.update(update_method="UNKNOWN")
|
|
59
|
+
with self.assertRaises(ValueError):
|
|
60
|
+
self.api.update(update_method=[])
|
|
61
|
+
|
|
62
|
+
def test_watcher_notifies_when_an_external_writer_changes_the_file(self):
|
|
63
|
+
changed = threading.Event()
|
|
64
|
+
received = []
|
|
65
|
+
|
|
66
|
+
def listener(config):
|
|
67
|
+
received.append(config)
|
|
68
|
+
changed.set()
|
|
69
|
+
|
|
70
|
+
self.api.add_listener(listener)
|
|
71
|
+
self.api.configure(self.config_path, watch=True, watch_interval=0.05)
|
|
72
|
+
self._write_document(
|
|
73
|
+
{
|
|
74
|
+
"name": "example",
|
|
75
|
+
"installed": True,
|
|
76
|
+
"auto_start": True,
|
|
77
|
+
"update_method": UPDATE_METHOD_MANUAL,
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
self.assertTrue(changed.wait(2), "watcher did not observe app.json change")
|
|
82
|
+
self.assertEqual(
|
|
83
|
+
{"auto_start": True, "update_method": UPDATE_METHOD_MANUAL},
|
|
84
|
+
received[-1],
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
unittest.main()
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import signal
|
|
3
|
+
import unittest
|
|
4
|
+
from unittest import mock
|
|
5
|
+
|
|
6
|
+
import pyappify
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestProcessControls(unittest.TestCase):
|
|
10
|
+
def setUp(self):
|
|
11
|
+
self.old_pid = pyappify.pid
|
|
12
|
+
self.old_executable = pyappify.pyappify_executable
|
|
13
|
+
self.old_logger = pyappify.logger
|
|
14
|
+
pyappify.logger = mock.Mock()
|
|
15
|
+
|
|
16
|
+
def tearDown(self):
|
|
17
|
+
pyappify.pid = self.old_pid
|
|
18
|
+
pyappify.pyappify_executable = self.old_executable
|
|
19
|
+
pyappify.logger = self.old_logger
|
|
20
|
+
|
|
21
|
+
def test_kill_pyappify_exe_terminates_configured_pid(self):
|
|
22
|
+
pyappify.pid = 1234
|
|
23
|
+
|
|
24
|
+
with mock.patch.object(pyappify.os, "kill") as kill, mock.patch.object(
|
|
25
|
+
pyappify, "_wait_for_process_exit", return_value=True
|
|
26
|
+
) as wait:
|
|
27
|
+
self.assertTrue(pyappify.kill_pyappify_exe(timeout=5))
|
|
28
|
+
|
|
29
|
+
kill.assert_called_once_with(1234, signal.SIGTERM)
|
|
30
|
+
wait.assert_called_once_with(1234, 5)
|
|
31
|
+
|
|
32
|
+
def test_show_pyappify_brings_existing_window_to_front(self):
|
|
33
|
+
pyappify.pid = 1234
|
|
34
|
+
|
|
35
|
+
with mock.patch.object(
|
|
36
|
+
pyappify, "_is_process_running", return_value=True
|
|
37
|
+
) as is_running, mock.patch.object(
|
|
38
|
+
pyappify, "bring_window_to_front_by_pid", return_value=True
|
|
39
|
+
) as bring_to_front, mock.patch.object(pyappify.subprocess, "Popen") as popen:
|
|
40
|
+
self.assertEqual(1234, pyappify.show_pyappify())
|
|
41
|
+
|
|
42
|
+
is_running.assert_called_once_with(1234)
|
|
43
|
+
bring_to_front.assert_called_once_with(1234)
|
|
44
|
+
popen.assert_not_called()
|
|
45
|
+
|
|
46
|
+
def test_show_pyappify_starts_executable_when_not_running(self):
|
|
47
|
+
executable = os.path.abspath("pyappify.exe")
|
|
48
|
+
pyappify.pid = 1234
|
|
49
|
+
pyappify.pyappify_executable = executable
|
|
50
|
+
process = mock.Mock()
|
|
51
|
+
process.pid = 5678
|
|
52
|
+
|
|
53
|
+
with mock.patch.object(
|
|
54
|
+
pyappify, "_is_process_running", return_value=False
|
|
55
|
+
), mock.patch.object(pyappify.subprocess, "Popen", return_value=process) as popen:
|
|
56
|
+
self.assertEqual(
|
|
57
|
+
5678,
|
|
58
|
+
pyappify.show_pyappify(args=["--profile", "dev"]),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
popen.assert_called_once_with(
|
|
62
|
+
[executable, "--profile", "dev"],
|
|
63
|
+
cwd=os.path.dirname(executable),
|
|
64
|
+
env=None,
|
|
65
|
+
)
|
|
66
|
+
self.assertEqual(5678, pyappify.pid)
|
|
67
|
+
|
|
68
|
+
def test_show_pyappify_returns_none_without_executable(self):
|
|
69
|
+
pyappify.pid = None
|
|
70
|
+
pyappify.pyappify_executable = None
|
|
71
|
+
|
|
72
|
+
with mock.patch.object(pyappify, "_is_process_running", return_value=False):
|
|
73
|
+
self.assertIsNone(pyappify.show_pyappify())
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
unittest.main()
|
|
@@ -9,6 +9,8 @@ ENV_KEYS = (
|
|
|
9
9
|
"PYAPPIFY_APP_VERSION",
|
|
10
10
|
"PYAPPIFY_APP_STARTING_VERSION",
|
|
11
11
|
"PYAPPIFY_UPDATE_NOTE",
|
|
12
|
+
"PYAPPIFY_APP_JSON_PATH",
|
|
13
|
+
"PYAPPIFY_LOCALE",
|
|
12
14
|
)
|
|
13
15
|
|
|
14
16
|
|
|
@@ -89,6 +91,10 @@ class TestUpdateEnv(unittest.TestCase):
|
|
|
89
91
|
self._reload_with_env(PYAPPIFY_UPDATE_NOTE='"single note"').get_update_notes(),
|
|
90
92
|
)
|
|
91
93
|
|
|
94
|
+
def test_reads_locale_with_english_fallback(self):
|
|
95
|
+
self.assertEqual("zh-CN", self._reload_with_env(PYAPPIFY_LOCALE="zh-CN").get_locale())
|
|
96
|
+
self.assertEqual("en", self._reload_with_env().get_locale())
|
|
97
|
+
|
|
92
98
|
|
|
93
99
|
if __name__ == "__main__":
|
|
94
100
|
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|