pyappify 1.0.3__tar.gz → 1.0.5__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.3
3
+ Version: 1.0.5
4
4
  Summary: My awesome Python application
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -1,13 +1,15 @@
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
12
+ import time
11
13
 
12
14
  app_version = os.environ.get("PYAPPIFY_APP_VERSION")
13
15
  app_starting_version = os.environ.get("PYAPPIFY_APP_STARTING_VERSION") or app_version
@@ -15,20 +17,20 @@ update_note = os.environ.get("PYAPPIFY_UPDATE_NOTE")
15
17
  app_profile = os.environ.get("PYAPPIFY_APP_PROFILE")
16
18
  pyappify_version = os.environ.get("PYAPPIFY_VERSION")
17
19
  pyappify_executable = os.environ.get("PYAPPIFY_EXECUTABLE")
18
-
20
+
19
21
  pyappify_upgradeable = os.environ.get("PYAPPIFY_UPGRADEABLE") == '1'
20
22
  logger = None
21
23
  _console_logger = None
22
-
23
- try:
24
- pid = int(os.environ.get("PYAPPIFY_PID"))
25
- except (ValueError, TypeError):
26
- pid = None
27
-
28
- import sys
29
-
30
- try:
31
- import ctypes
24
+
25
+ try:
26
+ pid = int(os.environ.get("PYAPPIFY_PID"))
27
+ except (ValueError, TypeError):
28
+ pid = None
29
+
30
+ import sys
31
+
32
+ try:
33
+ import ctypes
32
34
  except ImportError:
33
35
  ctypes = None
34
36
 
@@ -49,38 +51,186 @@ def _get_logger():
49
51
  return _console_logger
50
52
 
51
53
 
54
+ def _find_visible_window_by_pid(process_pid):
55
+ if not ctypes or sys.platform != "win32":
56
+ return None
57
+
58
+ found_hwnd = []
59
+ EnumWindowsProc = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p)
60
+
61
+ def enum_windows_callback(hwnd, lParam):
62
+ owner_pid = ctypes.c_ulong()
63
+ ctypes.windll.user32.GetWindowThreadProcessId(hwnd, ctypes.byref(owner_pid))
64
+ if owner_pid.value == process_pid and ctypes.windll.user32.IsWindowVisible(hwnd):
65
+ found_hwnd.append(hwnd)
66
+ return False
67
+ return True
68
+
69
+ ctypes.windll.user32.EnumWindows(EnumWindowsProc(enum_windows_callback), 0)
70
+
71
+ return found_hwnd[0] if found_hwnd else None
72
+
73
+
52
74
  def minimize_window_by_pid(pid):
53
- if not ctypes or sys.platform != "win32":
54
- return False
55
-
56
- found_hwnd = []
57
- EnumWindowsProc = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int))
58
-
59
- def enum_windows_callback(hwnd, lParam):
60
- owner_pid = ctypes.c_ulong()
61
- ctypes.windll.user32.GetWindowThreadProcessId(hwnd, ctypes.byref(owner_pid))
62
- if owner_pid.value == pid and ctypes.windll.user32.IsWindowVisible(hwnd):
63
- found_hwnd.append(hwnd)
64
- return False
65
- return True
66
-
67
- ctypes.windll.user32.EnumWindows(EnumWindowsProc(enum_windows_callback), 0)
68
-
69
- if found_hwnd:
70
- ctypes.windll.user32.ShowWindow(found_hwnd[0], 6)
71
- return True
72
-
73
- return False
74
-
75
- def kill_pyappify():
75
+ hwnd = _find_visible_window_by_pid(pid)
76
+ if hwnd:
77
+ ctypes.windll.user32.ShowWindow(hwnd, 6)
78
+ return True
79
+
80
+ return False
81
+
82
+
83
+ def bring_window_to_front_by_pid(pid):
84
+ hwnd = _find_visible_window_by_pid(pid)
85
+ if hwnd:
86
+ ctypes.windll.user32.ShowWindow(hwnd, 9)
87
+ return bool(ctypes.windll.user32.SetForegroundWindow(hwnd))
88
+
89
+ return False
90
+
91
+
92
+ def kill_pyappify(timeout=30):
76
93
  if pid:
77
94
  log = _get_logger()
78
95
  log.info(f"Attempting to terminate process with PID: {pid}")
79
96
  try:
80
97
  os.kill(pid, signal.SIGTERM)
98
+ if not _wait_for_process_exit(pid, timeout):
99
+ log.warning(f"Timed out waiting for process with PID {pid} to exit.")
100
+ return False
101
+ log.info(f'_wait_for_process_exit success {pid}')
102
+ return True
81
103
  except Exception as e:
82
104
  log.error(f"Failed to terminate process with PID {pid}: {e}")
83
- pass
105
+ return False
106
+ return False
107
+
108
+
109
+ def kill_pyappify_exe(timeout=30):
110
+ return kill_pyappify(timeout)
111
+
112
+
113
+ def _wait_for_process_exit(process_pid, timeout=30):
114
+ if sys.platform == "win32" and ctypes:
115
+ synchronize = 0x00100000
116
+ wait_timeout = 0x00000102
117
+ wait_failed = 0xFFFFFFFF
118
+ ctypes.windll.kernel32.OpenProcess.argtypes = (
119
+ ctypes.c_uint,
120
+ ctypes.c_bool,
121
+ ctypes.c_ulong,
122
+ )
123
+ ctypes.windll.kernel32.OpenProcess.restype = ctypes.c_void_p
124
+ ctypes.windll.kernel32.WaitForSingleObject.argtypes = (
125
+ ctypes.c_void_p,
126
+ ctypes.c_uint,
127
+ )
128
+ ctypes.windll.kernel32.WaitForSingleObject.restype = ctypes.c_uint
129
+ ctypes.windll.kernel32.CloseHandle.argtypes = (ctypes.c_void_p,)
130
+ ctypes.windll.kernel32.CloseHandle.restype = ctypes.c_bool
131
+ handle = ctypes.windll.kernel32.OpenProcess(synchronize, False, process_pid)
132
+ if handle:
133
+ try:
134
+ result = ctypes.windll.kernel32.WaitForSingleObject(
135
+ handle, int(timeout * 1000)
136
+ )
137
+ if result == wait_failed:
138
+ return False
139
+ return result != wait_timeout
140
+ finally:
141
+ ctypes.windll.kernel32.CloseHandle(handle)
142
+
143
+ deadline = time.monotonic() + timeout
144
+ while time.monotonic() < deadline:
145
+ try:
146
+ os.kill(process_pid, 0)
147
+ except OSError:
148
+ return True
149
+ time.sleep(0.1)
150
+ return False
151
+
152
+
153
+ def _is_process_running(process_pid):
154
+ if not process_pid:
155
+ return False
156
+
157
+ if sys.platform == "win32" and ctypes:
158
+ synchronize = 0x00100000
159
+ wait_timeout = 0x00000102
160
+ ctypes.windll.kernel32.OpenProcess.argtypes = (
161
+ ctypes.c_uint,
162
+ ctypes.c_bool,
163
+ ctypes.c_ulong,
164
+ )
165
+ ctypes.windll.kernel32.OpenProcess.restype = ctypes.c_void_p
166
+ ctypes.windll.kernel32.WaitForSingleObject.argtypes = (
167
+ ctypes.c_void_p,
168
+ ctypes.c_uint,
169
+ )
170
+ ctypes.windll.kernel32.WaitForSingleObject.restype = ctypes.c_uint
171
+ ctypes.windll.kernel32.CloseHandle.argtypes = (ctypes.c_void_p,)
172
+ ctypes.windll.kernel32.CloseHandle.restype = ctypes.c_bool
173
+ handle = ctypes.windll.kernel32.OpenProcess(synchronize, False, process_pid)
174
+ if not handle:
175
+ return False
176
+ try:
177
+ return ctypes.windll.kernel32.WaitForSingleObject(handle, 0) == wait_timeout
178
+ finally:
179
+ ctypes.windll.kernel32.CloseHandle(handle)
180
+
181
+ try:
182
+ os.kill(process_pid, 0)
183
+ except OSError:
184
+ return False
185
+ return True
186
+
187
+
188
+ def show_pyappify(args=None, cwd=None, env=None):
189
+ global pid
190
+
191
+ log = _get_logger()
192
+ if _is_process_running(pid):
193
+ log.info(f"PyAppify is already running with PID: {pid}")
194
+ bring_window_to_front_by_pid(pid)
195
+ return pid
196
+
197
+ if not pyappify_executable:
198
+ log.error("PYAPPIFY_EXECUTABLE is not configured.")
199
+ return None
200
+
201
+ command = [pyappify_executable]
202
+ if args:
203
+ if isinstance(args, str):
204
+ command.append(args)
205
+ else:
206
+ command.extend(args)
207
+
208
+ try:
209
+ process = subprocess.Popen(
210
+ command,
211
+ cwd=cwd or os.path.dirname(pyappify_executable) or None,
212
+ env=env,
213
+ )
214
+ pid = process.pid
215
+ return pid
216
+ except Exception as e:
217
+ log.error(f"Failed to start PyAppify executable {pyappify_executable}: {e}")
218
+ return None
219
+
220
+
221
+ def _replace_executable(source_path, target_path, timeout=30):
222
+ deadline = time.monotonic() + timeout
223
+ last_error = None
224
+ while True:
225
+ try:
226
+ shutil.move(source_path, target_path)
227
+ return
228
+ except PermissionError as e:
229
+ last_error = e
230
+ if time.monotonic() >= deadline:
231
+ raise last_error
232
+ time.sleep(0.25)
233
+
84
234
 
85
235
  def hide_pyappify():
86
236
  if pid:
@@ -102,7 +252,7 @@ def upgrade(to_version, executable_sha256, executable_zip_urls, stop_event=None)
102
252
  def _do_upgrade():
103
253
  tmp_dir = os.path.join(os.getcwd(), "pyappify_tmp")
104
254
  try:
105
- os.makedirs(tmp_dir, exist_ok=True)
255
+ os.makedirs(tmp_dir, exist_ok=True)
106
256
  downloaded_zip_path = None
107
257
  for url in executable_zip_urls:
108
258
  try:
@@ -114,10 +264,10 @@ def upgrade(to_version, executable_sha256, executable_zip_urls, stop_event=None)
114
264
  if stop_event and stop_event.is_set():
115
265
  log.info("pyappify Upgrade download cancelled by stop event.")
116
266
  return
117
- chunk = response.read(8192)
118
- if not chunk:
119
- break
120
- out_file.write(chunk)
267
+ chunk = response.read(8192)
268
+ if not chunk:
269
+ break
270
+ out_file.write(chunk)
121
271
  downloaded_zip_path = local_zip_path
122
272
  log.info(
123
273
  f"pyappify download success {url}")
@@ -129,39 +279,39 @@ def upgrade(to_version, executable_sha256, executable_zip_urls, stop_event=None)
129
279
  if not downloaded_zip_path:
130
280
  log.error("pyappify Failed to download upgrade.")
131
281
  return
132
-
133
- with zipfile.ZipFile(downloaded_zip_path, 'r') as zip_ref:
134
- zip_ref.extractall(tmp_dir)
135
-
136
- new_executable_name = os.path.basename(pyappify_executable)
137
- found_executable_path = None
138
- for root, _, files in os.walk(tmp_dir):
139
- if new_executable_name in files:
140
- found_executable_path = os.path.join(root, new_executable_name)
141
- break
142
-
282
+
283
+ with zipfile.ZipFile(downloaded_zip_path, 'r') as zip_ref:
284
+ zip_ref.extractall(tmp_dir)
285
+
286
+ new_executable_name = os.path.basename(pyappify_executable)
287
+ found_executable_path = None
288
+ for root, _, files in os.walk(tmp_dir):
289
+ if new_executable_name in files:
290
+ found_executable_path = os.path.join(root, new_executable_name)
291
+ break
292
+
143
293
  if not found_executable_path:
144
294
  log.error("pyappify Executable not found in zip.")
145
295
  return
146
-
147
- sha256_hash = hashlib.sha256()
148
- with open(found_executable_path, "rb") as f:
149
- for byte_block in iter(lambda: f.read(4096), b""):
150
- sha256_hash.update(byte_block)
151
-
296
+
297
+ sha256_hash = hashlib.sha256()
298
+ with open(found_executable_path, "rb") as f:
299
+ for byte_block in iter(lambda: f.read(4096), b""):
300
+ sha256_hash.update(byte_block)
301
+
152
302
  if executable_sha256 and sha256_hash.hexdigest() != executable_sha256:
153
303
  log.error("pyappify SHA256 checksum mismatch.")
154
304
  return
155
305
 
156
306
  kill_pyappify()
157
- shutil.move(found_executable_path, pyappify_executable)
307
+ _replace_executable(found_executable_path, pyappify_executable)
158
308
  log.info(f"pyappify Upgrade success")
159
309
  except Exception as e:
160
310
  log.error(f"pyappify Upgrade failed: {e}")
161
- finally:
162
- if os.path.exists(tmp_dir):
163
- shutil.rmtree(tmp_dir)
164
-
311
+ finally:
312
+ if os.path.exists(tmp_dir):
313
+ shutil.rmtree(tmp_dir)
314
+
165
315
  thread = threading.Thread(target=_do_upgrade)
166
316
  thread.daemon = True
167
317
  thread.start()
@@ -202,20 +352,20 @@ def get_update_note():
202
352
  def is_greater_version(version1, version2):
203
353
  """
204
354
  Compares two semantic version strings.
205
-
206
- Args:
207
- version1 (str): The first version string.
208
- version2 (str): The second version string.
209
-
210
- Returns:
211
- bool: True if version1 is strictly greater than version2,
212
- False otherwise or if parsing fails.
213
- """
214
- try:
215
- version1 = version1.lstrip('v')
216
- version2 = version2.lstrip('v')
217
- v1_parts = [int(p) for p in version1.split('.')]
218
- v2_parts = [int(p) for p in version2.split('.')]
219
- return v1_parts > v2_parts
220
- except (ValueError, AttributeError):
355
+
356
+ Args:
357
+ version1 (str): The first version string.
358
+ version2 (str): The second version string.
359
+
360
+ Returns:
361
+ bool: True if version1 is strictly greater than version2,
362
+ False otherwise or if parsing fails.
363
+ """
364
+ try:
365
+ version1 = version1.lstrip('v')
366
+ version2 = version2.lstrip('v')
367
+ v1_parts = [int(p) for p in version1.split('.')]
368
+ v2_parts = [int(p) for p in version2.split('.')]
369
+ return v1_parts > v2_parts
370
+ except (ValueError, AttributeError):
221
371
  return False
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyappify
3
- Version: 1.0.3
3
+ Version: 1.0.5
4
4
  Summary: My awesome Python application
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -7,6 +7,7 @@ pyappify.egg-info/dependency_links.txt
7
7
  pyappify.egg-info/entry_points.txt
8
8
  pyappify.egg-info/requires.txt
9
9
  pyappify.egg-info/top_level.txt
10
+ tests/TestProcessControls.py
10
11
  tests/TestUpdateEnv.py
11
12
  tests/TestUpgrade.py
12
13
  tests/TestUpgradeOnline.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pyappify"
7
- version = "1.0.3"
7
+ version = "1.0.5"
8
8
  description = "My awesome Python application"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.7"
@@ -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()
File without changes
File without changes
File without changes
File without changes