pyappify 1.0.13__tar.gz → 1.0.14__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.13 → pyappify-1.0.14}/PKG-INFO +3 -1
- pyappify-1.0.14/pyappify/__init__.py +100 -0
- pyappify-1.0.14/pyappify/_process.py +144 -0
- pyappify-1.0.14/pyappify/_state.py +33 -0
- pyappify-1.0.14/pyappify/launcher.py +201 -0
- pyappify-1.0.14/pyappify/self_upgrade.py +154 -0
- pyappify-1.0.14/pyappify/shortcut.py +246 -0
- pyappify-1.0.14/pyappify/standalone.py +533 -0
- pyappify-1.0.14/pyappify/standalone_updater.py +291 -0
- pyappify-1.0.14/pyappify/updates.py +143 -0
- pyappify-1.0.14/pyappify/versions.py +114 -0
- {pyappify-1.0.13 → pyappify-1.0.14}/pyappify.egg-info/PKG-INFO +3 -1
- {pyappify-1.0.13 → pyappify-1.0.14}/pyappify.egg-info/SOURCES.txt +9 -0
- pyappify-1.0.14/pyappify.egg-info/requires.txt +9 -0
- {pyappify-1.0.13 → pyappify-1.0.14}/pyproject.toml +3 -1
- pyappify-1.0.13/pyappify/__init__.py +0 -749
- pyappify-1.0.13/pyappify.egg-info/requires.txt +0 -4
- {pyappify-1.0.13 → pyappify-1.0.14}/pyappify/app_config.py +0 -0
- {pyappify-1.0.13 → pyappify-1.0.14}/pyappify/main.py +0 -0
- {pyappify-1.0.13 → pyappify-1.0.14}/pyappify.egg-info/dependency_links.txt +0 -0
- {pyappify-1.0.13 → pyappify-1.0.14}/pyappify.egg-info/entry_points.txt +0 -0
- {pyappify-1.0.13 → pyappify-1.0.14}/pyappify.egg-info/top_level.txt +0 -0
- {pyappify-1.0.13 → pyappify-1.0.14}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyappify
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.14
|
|
4
4
|
Summary: My awesome Python application
|
|
5
5
|
Classifier: Programming Language :: Python :: 3
|
|
6
6
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -10,3 +10,5 @@ Description-Content-Type: text/markdown
|
|
|
10
10
|
Provides-Extra: dev
|
|
11
11
|
Requires-Dist: twine; extra == "dev"
|
|
12
12
|
Requires-Dist: build; extra == "dev"
|
|
13
|
+
Provides-Extra: standalone
|
|
14
|
+
Requires-Dist: dulwich>=1.2; python_version >= "3.10" and extra == "standalone"
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# pyappify/__init__.py
|
|
2
|
+
"""Python API for apps run by the PyAppify launcher.
|
|
3
|
+
|
|
4
|
+
Runtime state below is read from the environment the launcher sets. It stays
|
|
5
|
+
on the package so apps can override it (``pyappify.logger = my_logger``);
|
|
6
|
+
the submodules read it from here at call time.
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
from .app_config import (
|
|
12
|
+
UPDATE_METHOD_AUTO,
|
|
13
|
+
UPDATE_METHOD_AUTO_PRE_RELEASE,
|
|
14
|
+
UPDATE_METHOD_MANUAL,
|
|
15
|
+
add_app_config_listener,
|
|
16
|
+
configure_app_json,
|
|
17
|
+
get_app_config,
|
|
18
|
+
get_app_json_path,
|
|
19
|
+
get_auto_start,
|
|
20
|
+
get_update_method,
|
|
21
|
+
remove_app_config_listener,
|
|
22
|
+
set_auto_start,
|
|
23
|
+
set_update_method,
|
|
24
|
+
start_app_config_watcher,
|
|
25
|
+
stop_app_config_watcher,
|
|
26
|
+
update_app_config,
|
|
27
|
+
)
|
|
28
|
+
from ._process import bring_window_to_front_by_pid, minimize_window_by_pid
|
|
29
|
+
from .launcher import (
|
|
30
|
+
find_pyappify_executable,
|
|
31
|
+
hide_pyappify,
|
|
32
|
+
kill_pyappify,
|
|
33
|
+
kill_pyappify_exe,
|
|
34
|
+
show_pyappify,
|
|
35
|
+
)
|
|
36
|
+
from .self_upgrade import upgrade
|
|
37
|
+
from .shortcut import create_shortcut, refresh_shortcuts
|
|
38
|
+
from .updates import get_version_list, get_versions, update_to_version
|
|
39
|
+
from .versions import calculate_update_notes, is_greater_version
|
|
40
|
+
|
|
41
|
+
app_version = os.environ.get("PYAPPIFY_APP_VERSION")
|
|
42
|
+
app_starting_version = os.environ.get("PYAPPIFY_APP_STARTING_VERSION") or app_version
|
|
43
|
+
update_note = os.environ.get("PYAPPIFY_UPDATE_NOTE")
|
|
44
|
+
app_profile = os.environ.get("PYAPPIFY_APP_PROFILE")
|
|
45
|
+
app_locale = os.environ.get("PYAPPIFY_LOCALE") or "en"
|
|
46
|
+
pyappify_version = os.environ.get("PYAPPIFY_VERSION")
|
|
47
|
+
app_json_path = configure_app_json(os.environ.get("PYAPPIFY_APP_JSON_PATH"))
|
|
48
|
+
|
|
49
|
+
pyappify_upgradeable = os.environ.get("PYAPPIFY_UPGRADEABLE") == '1'
|
|
50
|
+
logger = None
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
pid = int(os.environ.get("PYAPPIFY_PID"))
|
|
54
|
+
except (ValueError, TypeError):
|
|
55
|
+
pid = None
|
|
56
|
+
|
|
57
|
+
pyappify_executable = find_pyappify_executable()
|
|
58
|
+
|
|
59
|
+
if app_version is None and not pyappify_version and not pyappify_executable:
|
|
60
|
+
# Running from a git clone without the launcher: use the version tag at HEAD.
|
|
61
|
+
from .standalone import detect_app_version
|
|
62
|
+
|
|
63
|
+
app_version = detect_app_version()
|
|
64
|
+
app_starting_version = app_starting_version or app_version
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def is_app_updated():
|
|
68
|
+
return is_greater_version(app_version, app_starting_version)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def is_app_downgraded():
|
|
72
|
+
return is_greater_version(app_starting_version, app_version)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def is_updated():
|
|
76
|
+
return is_app_updated()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def is_downgrade():
|
|
80
|
+
return is_app_downgraded()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_update_notes():
|
|
84
|
+
if not update_note:
|
|
85
|
+
return []
|
|
86
|
+
try:
|
|
87
|
+
notes = json.loads(update_note)
|
|
88
|
+
except (TypeError, ValueError):
|
|
89
|
+
return []
|
|
90
|
+
if isinstance(notes, list):
|
|
91
|
+
return [str(note) for note in notes]
|
|
92
|
+
return []
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def get_update_note():
|
|
96
|
+
return get_update_notes()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def get_locale():
|
|
100
|
+
return app_locale
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Process, window and cancellation helpers shared by the other modules."""
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
import ctypes
|
|
8
|
+
except ImportError:
|
|
9
|
+
ctypes = None
|
|
10
|
+
|
|
11
|
+
_SYNCHRONIZE = 0x00100000
|
|
12
|
+
_WAIT_TIMEOUT = 0x00000102
|
|
13
|
+
_WAIT_FAILED = 0xFFFFFFFF
|
|
14
|
+
_SW_MINIMIZE = 6
|
|
15
|
+
_SW_RESTORE = 9
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _exit_requested(*events):
|
|
19
|
+
return any(event is not None and event.is_set() for event in events)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _wait_for_exit(exit_event, timeout):
|
|
23
|
+
if exit_event is not None:
|
|
24
|
+
return exit_event.wait(timeout)
|
|
25
|
+
time.sleep(timeout)
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _raise_if_exit_requested(exit_event):
|
|
30
|
+
if _exit_requested(exit_event):
|
|
31
|
+
raise InterruptedError("PyAppify operation cancelled because exit_event was set")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _terminate_process(process):
|
|
35
|
+
try:
|
|
36
|
+
if process.poll() is None:
|
|
37
|
+
process.terminate()
|
|
38
|
+
except (AttributeError, OSError):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _kernel32():
|
|
43
|
+
"""Return kernel32 with the signatures used here, or None off Windows."""
|
|
44
|
+
if sys.platform != "win32" or not ctypes:
|
|
45
|
+
return None
|
|
46
|
+
kernel32 = ctypes.windll.kernel32
|
|
47
|
+
kernel32.OpenProcess.argtypes = (ctypes.c_uint, ctypes.c_bool, ctypes.c_ulong)
|
|
48
|
+
kernel32.OpenProcess.restype = ctypes.c_void_p
|
|
49
|
+
kernel32.WaitForSingleObject.argtypes = (ctypes.c_void_p, ctypes.c_uint)
|
|
50
|
+
kernel32.WaitForSingleObject.restype = ctypes.c_uint
|
|
51
|
+
kernel32.CloseHandle.argtypes = (ctypes.c_void_p,)
|
|
52
|
+
kernel32.CloseHandle.restype = ctypes.c_bool
|
|
53
|
+
return kernel32
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _wait_for_process_exit(process_pid, timeout=30, exit_event=None):
|
|
57
|
+
kernel32 = _kernel32()
|
|
58
|
+
handle = kernel32.OpenProcess(_SYNCHRONIZE, False, process_pid) if kernel32 else None
|
|
59
|
+
if handle:
|
|
60
|
+
try:
|
|
61
|
+
deadline = time.monotonic() + timeout
|
|
62
|
+
while time.monotonic() < deadline:
|
|
63
|
+
if _exit_requested(exit_event):
|
|
64
|
+
return False
|
|
65
|
+
remaining_ms = max(0, int((deadline - time.monotonic()) * 1000))
|
|
66
|
+
result = kernel32.WaitForSingleObject(handle, min(100, remaining_ms))
|
|
67
|
+
if result == _WAIT_FAILED:
|
|
68
|
+
return False
|
|
69
|
+
if result != _WAIT_TIMEOUT:
|
|
70
|
+
return True
|
|
71
|
+
return False
|
|
72
|
+
finally:
|
|
73
|
+
kernel32.CloseHandle(handle)
|
|
74
|
+
|
|
75
|
+
deadline = time.monotonic() + timeout
|
|
76
|
+
while time.monotonic() < deadline:
|
|
77
|
+
if _exit_requested(exit_event):
|
|
78
|
+
return False
|
|
79
|
+
try:
|
|
80
|
+
os.kill(process_pid, 0)
|
|
81
|
+
except OSError:
|
|
82
|
+
return True
|
|
83
|
+
if _wait_for_exit(exit_event, 0.1):
|
|
84
|
+
return False
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _is_process_running(process_pid):
|
|
89
|
+
if not process_pid:
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
kernel32 = _kernel32()
|
|
93
|
+
if kernel32:
|
|
94
|
+
handle = kernel32.OpenProcess(_SYNCHRONIZE, False, process_pid)
|
|
95
|
+
if not handle:
|
|
96
|
+
return False
|
|
97
|
+
try:
|
|
98
|
+
return kernel32.WaitForSingleObject(handle, 0) == _WAIT_TIMEOUT
|
|
99
|
+
finally:
|
|
100
|
+
kernel32.CloseHandle(handle)
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
os.kill(process_pid, 0)
|
|
104
|
+
except OSError:
|
|
105
|
+
return False
|
|
106
|
+
return True
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _find_visible_window_by_pid(process_pid):
|
|
110
|
+
if not ctypes or sys.platform != "win32":
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
found_hwnd = []
|
|
114
|
+
EnumWindowsProc = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p)
|
|
115
|
+
|
|
116
|
+
def enum_windows_callback(hwnd, lParam):
|
|
117
|
+
owner_pid = ctypes.c_ulong()
|
|
118
|
+
ctypes.windll.user32.GetWindowThreadProcessId(hwnd, ctypes.byref(owner_pid))
|
|
119
|
+
if owner_pid.value == process_pid and ctypes.windll.user32.IsWindowVisible(hwnd):
|
|
120
|
+
found_hwnd.append(hwnd)
|
|
121
|
+
return False
|
|
122
|
+
return True
|
|
123
|
+
|
|
124
|
+
ctypes.windll.user32.EnumWindows(EnumWindowsProc(enum_windows_callback), 0)
|
|
125
|
+
|
|
126
|
+
return found_hwnd[0] if found_hwnd else None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def minimize_window_by_pid(pid):
|
|
130
|
+
hwnd = _find_visible_window_by_pid(pid)
|
|
131
|
+
if hwnd:
|
|
132
|
+
ctypes.windll.user32.ShowWindow(hwnd, _SW_MINIMIZE)
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def bring_window_to_front_by_pid(pid):
|
|
139
|
+
hwnd = _find_visible_window_by_pid(pid)
|
|
140
|
+
if hwnd:
|
|
141
|
+
ctypes.windll.user32.ShowWindow(hwnd, _SW_RESTORE)
|
|
142
|
+
return bool(ctypes.windll.user32.SetForegroundWindow(hwnd))
|
|
143
|
+
|
|
144
|
+
return False
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Access to the runtime state kept on the ``pyappify`` package.
|
|
2
|
+
|
|
3
|
+
Public state such as ``pid``, ``logger`` and ``pyappify_executable`` lives on
|
|
4
|
+
the package itself so applications (and tests) can assign it directly, for
|
|
5
|
+
example ``pyappify.logger = my_logger``. Submodules look it up through
|
|
6
|
+
:func:`pkg` at call time instead of copying it at import time.
|
|
7
|
+
"""
|
|
8
|
+
import logging
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
_console_logger = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def pkg():
|
|
15
|
+
return sys.modules[__package__]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_logger():
|
|
19
|
+
"""Return the logger assigned to ``pyappify.logger`` or a console logger."""
|
|
20
|
+
global _console_logger
|
|
21
|
+
|
|
22
|
+
logger = getattr(pkg(), "logger", None)
|
|
23
|
+
if logger is not None:
|
|
24
|
+
return logger
|
|
25
|
+
if _console_logger is None:
|
|
26
|
+
_console_logger = logging.getLogger("pyappify")
|
|
27
|
+
if not _console_logger.handlers:
|
|
28
|
+
handler = logging.StreamHandler()
|
|
29
|
+
handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
|
|
30
|
+
_console_logger.addHandler(handler)
|
|
31
|
+
_console_logger.setLevel(logging.INFO)
|
|
32
|
+
_console_logger.propagate = False
|
|
33
|
+
return _console_logger
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Talking to and controlling the PyAppify launcher executable."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import signal
|
|
5
|
+
import subprocess
|
|
6
|
+
import tempfile
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import _process
|
|
12
|
+
from ._state import get_logger, pkg
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def find_pyappify_executable(start_dir=None, environ=None):
|
|
16
|
+
"""Find the configured launcher or an app launcher above ``working``."""
|
|
17
|
+
environ = os.environ if environ is None else environ
|
|
18
|
+
configured = environ.get("PYAPPIFY_EXECUTABLE")
|
|
19
|
+
if configured:
|
|
20
|
+
configured_path = Path(configured).expanduser()
|
|
21
|
+
if configured_path.is_file():
|
|
22
|
+
return str(configured_path.resolve())
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
current = Path(start_dir or os.getcwd()).expanduser()
|
|
26
|
+
if current.is_file():
|
|
27
|
+
current = current.parent
|
|
28
|
+
try:
|
|
29
|
+
current = current.resolve()
|
|
30
|
+
except OSError:
|
|
31
|
+
current = current.absolute()
|
|
32
|
+
|
|
33
|
+
working_directory = next(
|
|
34
|
+
(
|
|
35
|
+
directory
|
|
36
|
+
for directory in (current,) + tuple(current.parents)
|
|
37
|
+
if directory.name.casefold() == "working" and directory.parent.name
|
|
38
|
+
),
|
|
39
|
+
None,
|
|
40
|
+
)
|
|
41
|
+
if working_directory is None:
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
app_name = working_directory.parent.name
|
|
45
|
+
apps_directory = working_directory.parent.parent
|
|
46
|
+
data_directory = apps_directory.parent
|
|
47
|
+
if apps_directory.name.casefold() != "apps" or data_directory.name.casefold() != "data":
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
app_root = data_directory.parent
|
|
51
|
+
executable_name = "{}.exe".format(app_name)
|
|
52
|
+
search_directories = []
|
|
53
|
+
directory = working_directory
|
|
54
|
+
while True:
|
|
55
|
+
search_directories.append(directory)
|
|
56
|
+
if directory == app_root:
|
|
57
|
+
break
|
|
58
|
+
directory = directory.parent
|
|
59
|
+
|
|
60
|
+
for directory in search_directories:
|
|
61
|
+
candidate = directory / executable_name
|
|
62
|
+
if candidate.is_file():
|
|
63
|
+
return str(candidate.resolve())
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _require_pyappify_executable():
|
|
68
|
+
state = pkg()
|
|
69
|
+
if state.pyappify_executable and os.path.isfile(state.pyappify_executable):
|
|
70
|
+
return os.path.abspath(state.pyappify_executable)
|
|
71
|
+
state.pyappify_executable = find_pyappify_executable()
|
|
72
|
+
if not state.pyappify_executable:
|
|
73
|
+
raise FileNotFoundError(
|
|
74
|
+
"PyAppify executable was not found. Set PYAPPIFY_EXECUTABLE to an "
|
|
75
|
+
"existing executable or place <app_name>.exe between the working directory "
|
|
76
|
+
"and the app root."
|
|
77
|
+
)
|
|
78
|
+
return state.pyappify_executable
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def kill_pyappify(timeout=30, exit_event=None):
|
|
82
|
+
pid = pkg().pid
|
|
83
|
+
if pid:
|
|
84
|
+
log = get_logger()
|
|
85
|
+
log.info(f"Attempting to terminate process with PID: {pid}")
|
|
86
|
+
try:
|
|
87
|
+
os.kill(pid, signal.SIGTERM)
|
|
88
|
+
if not _process._wait_for_process_exit(pid, timeout, exit_event=exit_event):
|
|
89
|
+
log.warning(f"Timed out waiting for process with PID {pid} to exit.")
|
|
90
|
+
return False
|
|
91
|
+
log.info(f'_wait_for_process_exit success {pid}')
|
|
92
|
+
return True
|
|
93
|
+
except Exception as e:
|
|
94
|
+
log.error(f"Failed to terminate process with PID {pid}: {e}")
|
|
95
|
+
return False
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def kill_pyappify_exe(timeout=30, exit_event=None):
|
|
100
|
+
return kill_pyappify(timeout, exit_event=exit_event)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def show_pyappify(args=None, cwd=None, env=None):
|
|
104
|
+
state = pkg()
|
|
105
|
+
log = get_logger()
|
|
106
|
+
already_running = _process._is_process_running(state.pid)
|
|
107
|
+
if already_running and not args:
|
|
108
|
+
log.info(f"PyAppify is already running with PID: {state.pid}")
|
|
109
|
+
_process.bring_window_to_front_by_pid(state.pid)
|
|
110
|
+
return state.pid
|
|
111
|
+
|
|
112
|
+
executable = state.pyappify_executable or find_pyappify_executable()
|
|
113
|
+
if not executable:
|
|
114
|
+
log.error("PyAppify executable was not found.")
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
command = [executable]
|
|
118
|
+
if args:
|
|
119
|
+
if isinstance(args, str):
|
|
120
|
+
command.append(args)
|
|
121
|
+
else:
|
|
122
|
+
command.extend(args)
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
process = subprocess.Popen(
|
|
126
|
+
command,
|
|
127
|
+
cwd=cwd or os.path.dirname(executable) or None,
|
|
128
|
+
env=env,
|
|
129
|
+
)
|
|
130
|
+
if not already_running:
|
|
131
|
+
state.pid = process.pid
|
|
132
|
+
return state.pid
|
|
133
|
+
except Exception as e:
|
|
134
|
+
log.error(f"Failed to start PyAppify executable {executable}: {e}")
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def hide_pyappify():
|
|
139
|
+
pid = pkg().pid
|
|
140
|
+
if pid:
|
|
141
|
+
log = get_logger()
|
|
142
|
+
log.info(f"Attempting to minimize window for process with PID: {pid}")
|
|
143
|
+
try:
|
|
144
|
+
_process.minimize_window_by_pid(pid)
|
|
145
|
+
except Exception as e:
|
|
146
|
+
log.error(f"Failed to minimize window for process with PID {pid}: {e}")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _run_launcher_api(arguments, timeout=300, exit_event=None):
|
|
150
|
+
"""Run a launcher API command and return its JSON response."""
|
|
151
|
+
_process._raise_if_exit_requested(exit_event)
|
|
152
|
+
executable = _require_pyappify_executable()
|
|
153
|
+
response_path = os.path.join(
|
|
154
|
+
tempfile.gettempdir(),
|
|
155
|
+
"pyappify-response-{}-{}.json".format(os.getpid(), uuid.uuid4().hex),
|
|
156
|
+
)
|
|
157
|
+
command = list(arguments) + ["--response-file", response_path]
|
|
158
|
+
try:
|
|
159
|
+
process = subprocess.Popen(
|
|
160
|
+
[executable] + command,
|
|
161
|
+
cwd=os.path.dirname(executable) or None,
|
|
162
|
+
stdout=subprocess.DEVNULL,
|
|
163
|
+
stderr=subprocess.DEVNULL,
|
|
164
|
+
)
|
|
165
|
+
except OSError as error:
|
|
166
|
+
raise RuntimeError(
|
|
167
|
+
"Failed to execute PyAppify at '{}': {}".format(executable, error)
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
deadline = time.monotonic() + timeout
|
|
171
|
+
cancelled = False
|
|
172
|
+
try:
|
|
173
|
+
while time.monotonic() < deadline:
|
|
174
|
+
if _process._exit_requested(exit_event):
|
|
175
|
+
cancelled = True
|
|
176
|
+
_process._terminate_process(process)
|
|
177
|
+
break
|
|
178
|
+
try:
|
|
179
|
+
with open(response_path, "r", encoding="utf-8") as response_file:
|
|
180
|
+
response = json.load(response_file)
|
|
181
|
+
if isinstance(response, dict) and response.get("error"):
|
|
182
|
+
raise RuntimeError(response["error"])
|
|
183
|
+
return response
|
|
184
|
+
except FileNotFoundError:
|
|
185
|
+
pass
|
|
186
|
+
except json.JSONDecodeError:
|
|
187
|
+
# The running launcher may still be completing the response write.
|
|
188
|
+
pass
|
|
189
|
+
if _process._wait_for_exit(exit_event, 0.05):
|
|
190
|
+
cancelled = True
|
|
191
|
+
_process._terminate_process(process)
|
|
192
|
+
break
|
|
193
|
+
finally:
|
|
194
|
+
try:
|
|
195
|
+
os.remove(response_path)
|
|
196
|
+
except FileNotFoundError:
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
if cancelled:
|
|
200
|
+
raise InterruptedError("PyAppify operation cancelled because exit_event was set")
|
|
201
|
+
raise TimeoutError("Timed out waiting for a response from PyAppify")
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Replacing the PyAppify launcher executable with a newer release."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
import urllib.request
|
|
8
|
+
import zipfile
|
|
9
|
+
|
|
10
|
+
from . import _process, launcher
|
|
11
|
+
from ._state import get_logger, pkg
|
|
12
|
+
from .versions import is_greater_version
|
|
13
|
+
|
|
14
|
+
_DOWNLOAD_IO_TIMEOUT_SECONDS = 2
|
|
15
|
+
_TMP_DIR_NAME = "pyappify_tmp"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _Cancelled(Exception):
|
|
19
|
+
"""Raised inside the upgrade worker when stop_event or exit_event is set."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def upgrade(
|
|
23
|
+
to_version,
|
|
24
|
+
executable_sha256,
|
|
25
|
+
executable_zip_urls,
|
|
26
|
+
stop_event=None,
|
|
27
|
+
exit_event=None,
|
|
28
|
+
):
|
|
29
|
+
state = pkg()
|
|
30
|
+
log = get_logger()
|
|
31
|
+
if not state.pyappify_upgradeable or not is_greater_version(to_version, state.pyappify_version):
|
|
32
|
+
log.info(f"pyappify no need to upgrade {state.pyappify_upgradeable} {to_version} {executable_sha256} {executable_zip_urls}")
|
|
33
|
+
return
|
|
34
|
+
log.info(
|
|
35
|
+
f"pyappify start to upgrade {state.pyappify_upgradeable} {to_version} {executable_sha256} {executable_zip_urls}")
|
|
36
|
+
|
|
37
|
+
thread = threading.Thread(
|
|
38
|
+
target=_run_upgrade,
|
|
39
|
+
args=(executable_sha256, executable_zip_urls, stop_event, exit_event),
|
|
40
|
+
)
|
|
41
|
+
thread.daemon = True
|
|
42
|
+
thread.start()
|
|
43
|
+
return thread
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _run_upgrade(executable_sha256, executable_zip_urls, stop_event, exit_event):
|
|
47
|
+
log = get_logger()
|
|
48
|
+
tmp_dir = os.path.join(os.getcwd(), _TMP_DIR_NAME)
|
|
49
|
+
|
|
50
|
+
def check_cancelled():
|
|
51
|
+
if _process._exit_requested(stop_event, exit_event):
|
|
52
|
+
raise _Cancelled()
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
check_cancelled()
|
|
56
|
+
os.makedirs(tmp_dir, exist_ok=True)
|
|
57
|
+
|
|
58
|
+
zip_path = _download_first(executable_zip_urls, tmp_dir, check_cancelled)
|
|
59
|
+
if not zip_path:
|
|
60
|
+
log.error("pyappify Failed to download upgrade.")
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
_extract_zip(zip_path, tmp_dir, check_cancelled)
|
|
64
|
+
|
|
65
|
+
target_executable = pkg().pyappify_executable
|
|
66
|
+
new_executable = _find_file(tmp_dir, os.path.basename(target_executable), check_cancelled)
|
|
67
|
+
if not new_executable:
|
|
68
|
+
log.error("pyappify Executable not found in zip.")
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
if executable_sha256 and _sha256_file(new_executable, check_cancelled) != executable_sha256:
|
|
72
|
+
log.error("pyappify SHA256 checksum mismatch.")
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
check_cancelled()
|
|
76
|
+
cancel_event = exit_event or stop_event
|
|
77
|
+
launcher.kill_pyappify(exit_event=cancel_event)
|
|
78
|
+
_replace_executable(new_executable, target_executable, exit_event=cancel_event)
|
|
79
|
+
log.info("pyappify Upgrade success")
|
|
80
|
+
except _Cancelled:
|
|
81
|
+
pass
|
|
82
|
+
except Exception as e:
|
|
83
|
+
log.error(f"pyappify Upgrade failed: {e}")
|
|
84
|
+
finally:
|
|
85
|
+
if os.path.exists(tmp_dir):
|
|
86
|
+
shutil.rmtree(tmp_dir)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _download_first(urls, directory, check_cancelled):
|
|
90
|
+
"""Download the first URL that succeeds into ``directory`` and return its path."""
|
|
91
|
+
log = get_logger()
|
|
92
|
+
for url in urls:
|
|
93
|
+
check_cancelled()
|
|
94
|
+
try:
|
|
95
|
+
log.info(f"pyappify start to download {url}")
|
|
96
|
+
local_path = os.path.join(directory, os.path.basename(url))
|
|
97
|
+
with urllib.request.urlopen(
|
|
98
|
+
url, timeout=_DOWNLOAD_IO_TIMEOUT_SECONDS
|
|
99
|
+
) as response, open(local_path, 'wb') as out_file:
|
|
100
|
+
while True:
|
|
101
|
+
check_cancelled()
|
|
102
|
+
chunk = response.read(8192)
|
|
103
|
+
if not chunk:
|
|
104
|
+
break
|
|
105
|
+
out_file.write(chunk)
|
|
106
|
+
log.info(f"pyappify download success {url}")
|
|
107
|
+
return local_path
|
|
108
|
+
except _Cancelled:
|
|
109
|
+
log.info("pyappify Upgrade download cancelled.")
|
|
110
|
+
raise
|
|
111
|
+
except Exception as e:
|
|
112
|
+
log.warning(f"pyappify Failed to download from {url}: {e}")
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _extract_zip(zip_path, directory, check_cancelled):
|
|
117
|
+
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
|
118
|
+
for member in zip_ref.infolist():
|
|
119
|
+
check_cancelled()
|
|
120
|
+
zip_ref.extract(member, directory)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _find_file(directory, file_name, check_cancelled):
|
|
124
|
+
for root, _, files in os.walk(directory):
|
|
125
|
+
check_cancelled()
|
|
126
|
+
if file_name in files:
|
|
127
|
+
return os.path.join(root, file_name)
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _sha256_file(path, check_cancelled):
|
|
132
|
+
sha256_hash = hashlib.sha256()
|
|
133
|
+
with open(path, "rb") as f:
|
|
134
|
+
while True:
|
|
135
|
+
check_cancelled()
|
|
136
|
+
byte_block = f.read(4096)
|
|
137
|
+
if not byte_block:
|
|
138
|
+
break
|
|
139
|
+
sha256_hash.update(byte_block)
|
|
140
|
+
return sha256_hash.hexdigest()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _replace_executable(source_path, target_path, timeout=30, exit_event=None):
|
|
144
|
+
deadline = time.monotonic() + timeout
|
|
145
|
+
while True:
|
|
146
|
+
_process._raise_if_exit_requested(exit_event)
|
|
147
|
+
try:
|
|
148
|
+
shutil.move(source_path, target_path)
|
|
149
|
+
return
|
|
150
|
+
except PermissionError:
|
|
151
|
+
if time.monotonic() >= deadline:
|
|
152
|
+
raise
|
|
153
|
+
if _process._wait_for_exit(exit_event, 0.25):
|
|
154
|
+
_process._raise_if_exit_requested(exit_event)
|