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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyappify
3
- Version: 1.0.4
3
+ Version: 1.0.6
4
4
  Summary: My awesome Python application
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -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
- if not ctypes or sys.platform != "win32":
55
- return False
56
-
57
- found_hwnd = []
58
- EnumWindowsProc = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int))
59
-
60
- def enum_windows_callback(hwnd, lParam):
61
- owner_pid = ctypes.c_ulong()
62
- ctypes.windll.user32.GetWindowThreadProcessId(hwnd, ctypes.byref(owner_pid))
63
- if owner_pid.value == pid and ctypes.windll.user32.IsWindowVisible(hwnd):
64
- found_hwnd.append(hwnd)
65
- return False
66
- return True
67
-
68
- ctypes.windll.user32.EnumWindows(EnumWindowsProc(enum_windows_callback), 0)
69
-
70
- if found_hwnd:
71
- ctypes.windll.user32.ShowWindow(found_hwnd[0], 6)
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
- pass
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyappify
3
- Version: 1.0.4
3
+ Version: 1.0.6
4
4
  Summary: My awesome Python application
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pyappify"
7
- version = "1.0.4"
7
+ version = "1.0.6"
8
8
  description = "My awesome Python application"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.7"
@@ -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