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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyappify
3
- Version: 1.0.13
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)