pyappify 1.0.5__tar.gz → 1.0.7__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,12 +1,12 @@
1
- Metadata-Version: 2.4
2
- Name: pyappify
3
- Version: 1.0.5
4
- Summary: My awesome Python application
5
- Classifier: Programming Language :: Python :: 3
6
- Classifier: License :: OSI Approved :: MIT License
7
- Classifier: Operating System :: OS Independent
8
- Requires-Python: >=3.7
9
- Description-Content-Type: text/markdown
10
- Provides-Extra: dev
11
- Requires-Dist: twine; extra == "dev"
12
- Requires-Dist: build; extra == "dev"
1
+ Metadata-Version: 2.4
2
+ Name: pyappify
3
+ Version: 1.0.7
4
+ Summary: My awesome Python application
5
+ Classifier: Programming Language :: Python :: 3
6
+ Classifier: License :: OSI Approved :: MIT License
7
+ Classifier: Operating System :: OS Independent
8
+ Requires-Python: >=3.7
9
+ Description-Content-Type: text/markdown
10
+ Provides-Extra: dev
11
+ Requires-Dist: twine; extra == "dev"
12
+ Requires-Dist: build; extra == "dev"
@@ -10,13 +10,36 @@ import urllib.request
10
10
  import zipfile
11
11
  import threading
12
12
  import time
13
+ import tempfile
14
+ import uuid
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ from .app_config import (
19
+ UPDATE_METHOD_AUTO,
20
+ UPDATE_METHOD_AUTO_PRE_RELEASE,
21
+ UPDATE_METHOD_MANUAL,
22
+ add_app_config_listener,
23
+ configure_app_json,
24
+ get_app_config,
25
+ get_app_json_path,
26
+ get_auto_start,
27
+ get_update_method,
28
+ remove_app_config_listener,
29
+ set_auto_start,
30
+ set_update_method,
31
+ start_app_config_watcher,
32
+ stop_app_config_watcher,
33
+ update_app_config,
34
+ )
13
35
 
14
36
  app_version = os.environ.get("PYAPPIFY_APP_VERSION")
15
37
  app_starting_version = os.environ.get("PYAPPIFY_APP_STARTING_VERSION") or app_version
16
38
  update_note = os.environ.get("PYAPPIFY_UPDATE_NOTE")
17
39
  app_profile = os.environ.get("PYAPPIFY_APP_PROFILE")
40
+ app_locale = os.environ.get("PYAPPIFY_LOCALE") or "en"
18
41
  pyappify_version = os.environ.get("PYAPPIFY_VERSION")
19
- pyappify_executable = os.environ.get("PYAPPIFY_EXECUTABLE")
42
+ app_json_path = configure_app_json(os.environ.get("PYAPPIFY_APP_JSON_PATH"))
20
43
 
21
44
  pyappify_upgradeable = os.environ.get("PYAPPIFY_UPGRADEABLE") == '1'
22
45
  logger = None
@@ -27,14 +50,41 @@ try:
27
50
  except (ValueError, TypeError):
28
51
  pid = None
29
52
 
30
- import sys
31
-
32
53
  try:
33
54
  import ctypes
34
55
  except ImportError:
35
56
  ctypes = None
36
57
 
37
58
 
59
+ def find_pyappify_executable(start_dir=None, environ=None):
60
+ """Find the launcher configured by PyAppify or in a parent directory."""
61
+ environ = os.environ if environ is None else environ
62
+ configured = environ.get("PYAPPIFY_EXECUTABLE")
63
+ if configured:
64
+ configured_path = Path(configured).expanduser()
65
+ if configured_path.is_file():
66
+ return str(configured_path.resolve())
67
+
68
+ current = Path(start_dir or os.getcwd()).expanduser()
69
+ if current.is_file():
70
+ current = current.parent
71
+ try:
72
+ current = current.resolve()
73
+ except OSError:
74
+ current = current.absolute()
75
+
76
+ executable_names = ("pyappify.exe", "pyappify")
77
+ for directory in (current,) + tuple(current.parents):
78
+ for executable_name in executable_names:
79
+ candidate = directory / executable_name
80
+ if candidate.is_file():
81
+ return str(candidate.resolve())
82
+ return None
83
+
84
+
85
+ pyappify_executable = find_pyappify_executable()
86
+
87
+
38
88
  def _get_logger():
39
89
  global _console_logger
40
90
 
@@ -189,16 +239,18 @@ def show_pyappify(args=None, cwd=None, env=None):
189
239
  global pid
190
240
 
191
241
  log = _get_logger()
192
- if _is_process_running(pid):
242
+ already_running = _is_process_running(pid)
243
+ if already_running and not args:
193
244
  log.info(f"PyAppify is already running with PID: {pid}")
194
245
  bring_window_to_front_by_pid(pid)
195
246
  return pid
196
247
 
197
- if not pyappify_executable:
198
- log.error("PYAPPIFY_EXECUTABLE is not configured.")
248
+ executable = pyappify_executable or find_pyappify_executable()
249
+ if not executable:
250
+ log.error("PyAppify executable was not found.")
199
251
  return None
200
252
 
201
- command = [pyappify_executable]
253
+ command = [executable]
202
254
  if args:
203
255
  if isinstance(args, str):
204
256
  command.append(args)
@@ -208,15 +260,208 @@ def show_pyappify(args=None, cwd=None, env=None):
208
260
  try:
209
261
  process = subprocess.Popen(
210
262
  command,
211
- cwd=cwd or os.path.dirname(pyappify_executable) or None,
263
+ cwd=cwd or os.path.dirname(executable) or None,
212
264
  env=env,
213
265
  )
214
- pid = process.pid
266
+ if not already_running:
267
+ pid = process.pid
215
268
  return pid
216
269
  except Exception as e:
217
- log.error(f"Failed to start PyAppify executable {pyappify_executable}: {e}")
270
+ log.error(f"Failed to start PyAppify executable {executable}: {e}")
271
+ return None
272
+
273
+
274
+ def _require_pyappify_executable():
275
+ global pyappify_executable
276
+
277
+ if pyappify_executable and os.path.isfile(pyappify_executable):
278
+ return os.path.abspath(pyappify_executable)
279
+ pyappify_executable = find_pyappify_executable()
280
+ if not pyappify_executable:
281
+ raise FileNotFoundError(
282
+ "PyAppify executable was not found. Set PYAPPIFY_EXECUTABLE to an "
283
+ "existing executable or place pyappify.exe in this directory or a parent directory."
284
+ )
285
+ return pyappify_executable
286
+
287
+
288
+ def _run_launcher_api(arguments, timeout=300):
289
+ executable = _require_pyappify_executable()
290
+ response_path = os.path.join(
291
+ tempfile.gettempdir(),
292
+ "pyappify-response-{}-{}.json".format(os.getpid(), uuid.uuid4().hex),
293
+ )
294
+ command = list(arguments) + ["--response-file", response_path]
295
+ try:
296
+ subprocess.Popen(
297
+ [executable] + command,
298
+ cwd=os.path.dirname(executable) or None,
299
+ stdout=subprocess.DEVNULL,
300
+ stderr=subprocess.DEVNULL,
301
+ )
302
+ except OSError as error:
303
+ raise RuntimeError(
304
+ "Failed to execute PyAppify at '{}': {}".format(executable, error)
305
+ )
306
+
307
+ deadline = time.monotonic() + timeout
308
+ try:
309
+ while time.monotonic() < deadline:
310
+ try:
311
+ with open(response_path, "r", encoding="utf-8") as response_file:
312
+ response = json.load(response_file)
313
+ if isinstance(response, dict) and response.get("error"):
314
+ raise RuntimeError(response["error"])
315
+ return response
316
+ except FileNotFoundError:
317
+ pass
318
+ except json.JSONDecodeError:
319
+ # The running launcher may still be completing the response write.
320
+ pass
321
+ time.sleep(0.05)
322
+ finally:
323
+ try:
324
+ os.remove(response_path)
325
+ except FileNotFoundError:
326
+ pass
327
+
328
+ raise TimeoutError("Timed out waiting for a response from PyAppify")
329
+
330
+
331
+ def get_version_list(number_versions=10, release_only=True, timeout=300):
332
+ """Return the latest versions and each version's notes since its predecessor."""
333
+ if isinstance(number_versions, bool) or not isinstance(number_versions, int):
334
+ raise TypeError("number_versions must be an integer")
335
+ if number_versions <= 0:
336
+ raise ValueError("number_versions must be greater than zero")
337
+ if not isinstance(release_only, bool):
338
+ raise TypeError("release_only must be a boolean")
339
+
340
+ if "PYAPPIFY_PYTHON_TEST" in os.environ:
341
+ time.sleep(5)
342
+ return _get_mock_version_list(number_versions)
343
+
344
+ response = _run_launcher_api(
345
+ [
346
+ "--get-version-list",
347
+ "--number-versions",
348
+ str(number_versions),
349
+ "--release-only",
350
+ str(release_only).lower(),
351
+ ],
352
+ timeout=timeout,
353
+ )
354
+ if not isinstance(response, list):
355
+ raise RuntimeError("PyAppify returned an invalid version-list response")
356
+ return response
357
+
358
+
359
+ def get_versions(number_versions=10, release_only=True, timeout=300):
360
+ """Alias for get_version_list."""
361
+ return get_version_list(number_versions, release_only, timeout)
362
+
363
+
364
+ def calculate_update_notes(update_notes, current_version, target_version):
365
+ """Return descending notes without including versions newer than the target."""
366
+ if not isinstance(update_notes, list):
367
+ raise TypeError("update_notes must be a list")
368
+
369
+ versions = [
370
+ item for item in update_notes
371
+ if isinstance(item, dict) and item.get("version")
372
+ ]
373
+
374
+ def normalize(version):
375
+ return str(version or "").lstrip("v")
376
+
377
+ def find_index(version):
378
+ normalized = normalize(version)
379
+ return next(
380
+ (index for index, item in enumerate(versions)
381
+ if normalize(item["version"]) == normalized),
382
+ None,
383
+ )
384
+
385
+ target_index = find_index(target_version)
386
+ if target_index is None:
387
+ return []
388
+
389
+ current_index = find_index(current_version)
390
+ if current_index is None:
391
+ selected_versions = versions[target_index:]
392
+ else:
393
+ first = min(current_index, target_index)
394
+ last = max(current_index, target_index)
395
+ selected_versions = versions[first:last + 1]
396
+
397
+ notes = []
398
+ for item in selected_versions:
399
+ raw_notes = item.get("update_note") or []
400
+ notes.extend(raw_notes if isinstance(raw_notes, list) else [raw_notes])
401
+ return [str(note) for note in notes]
402
+
403
+
404
+ def update_to_version(version, timeout=300):
405
+ """Start PyAppify (or forward to it) and update the managed app to version."""
406
+ if not isinstance(version, str) or not version.strip():
407
+ raise ValueError("version must be a non-empty string")
408
+ if "PYAPPIFY_PYTHON_TEST" in os.environ:
409
+ return {"updated": True, "version": version, "mocked": True}
410
+ response = _run_launcher_api(
411
+ ["--update-to-version", version],
412
+ timeout=timeout,
413
+ )
414
+ if not isinstance(response, dict) or not response.get("updated"):
415
+ raise RuntimeError("PyAppify returned an invalid update response")
416
+ return response
417
+
418
+
419
+ def _get_mock_version_list(number_versions):
420
+ """Return deterministic launcher data for UI and integration tests."""
421
+ current = app_version or "v1.0.0"
422
+ prefix = "v" if current.startswith("v") else ""
423
+ try:
424
+ parts = [int(part) for part in current.lstrip("v").split(".")]
425
+ except (TypeError, ValueError):
426
+ parts = [1, 0, 0]
427
+ prefix = "v"
428
+ parts = (parts + [0, 0, 0])[:3]
429
+
430
+ def previous_parts(version_parts):
431
+ major, minor, patch = version_parts
432
+ if patch > 0:
433
+ return [major, minor, patch - 1]
434
+ if minor > 0:
435
+ return [major, minor - 1, 9]
436
+ if major > 0:
437
+ return [major - 1, 9, 9]
218
438
  return None
219
439
 
440
+ version_parts = [[100, 1, 1], parts[:2] + [parts[2] + 2], parts[:2] + [parts[2] + 1], parts]
441
+ previous = previous_parts(parts)
442
+ while len(version_parts) < number_versions:
443
+ if previous is not None:
444
+ version_parts.append(previous)
445
+ previous = previous_parts(previous)
446
+ else:
447
+ break
448
+
449
+ versions = []
450
+ for item_parts in version_parts:
451
+ item_prefix = "v" if item_parts == [100, 1, 1] else prefix
452
+ version = item_prefix + ".".join(str(part) for part in item_parts)
453
+ previous_item = previous_parts(item_parts)
454
+ previous = item_prefix + ".".join(str(part) for part in previous_item) if previous_item else version
455
+ versions.append({
456
+ "version": version,
457
+ "previous_version": previous,
458
+ "update_note": [
459
+ f"Mock update note for {version}",
460
+ f"Changes since {previous}",
461
+ ],
462
+ })
463
+ return versions[:number_versions]
464
+
220
465
 
221
466
  def _replace_executable(source_path, target_path, timeout=30):
222
467
  deadline = time.monotonic() + timeout
@@ -349,6 +594,10 @@ def get_update_note():
349
594
  return get_update_notes()
350
595
 
351
596
 
597
+ def get_locale():
598
+ return app_locale
599
+
600
+
352
601
  def is_greater_version(version1, version2):
353
602
  """
354
603
  Compares two semantic version strings.
@@ -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)
@@ -0,0 +1,2 @@
1
+ def main():
2
+ print("PyAppify Hello world")
@@ -1,12 +1,12 @@
1
- Metadata-Version: 2.4
2
- Name: pyappify
3
- Version: 1.0.5
4
- Summary: My awesome Python application
5
- Classifier: Programming Language :: Python :: 3
6
- Classifier: License :: OSI Approved :: MIT License
7
- Classifier: Operating System :: OS Independent
8
- Requires-Python: >=3.7
9
- Description-Content-Type: text/markdown
10
- Provides-Extra: dev
11
- Requires-Dist: twine; extra == "dev"
12
- Requires-Dist: build; extra == "dev"
1
+ Metadata-Version: 2.4
2
+ Name: pyappify
3
+ Version: 1.0.7
4
+ Summary: My awesome Python application
5
+ Classifier: Programming Language :: Python :: 3
6
+ Classifier: License :: OSI Approved :: MIT License
7
+ Classifier: Operating System :: OS Independent
8
+ Requires-Python: >=3.7
9
+ Description-Content-Type: text/markdown
10
+ Provides-Extra: dev
11
+ Requires-Dist: twine; extra == "dev"
12
+ Requires-Dist: build; extra == "dev"
@@ -1,14 +1,10 @@
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
6
7
  pyappify.egg-info/dependency_links.txt
7
8
  pyappify.egg-info/entry_points.txt
8
9
  pyappify.egg-info/requires.txt
9
- pyappify.egg-info/top_level.txt
10
- tests/TestProcessControls.py
11
- tests/TestUpdateEnv.py
12
- tests/TestUpgrade.py
13
- tests/TestUpgradeOnline.py
14
- tests/TestVersion.py
10
+ pyappify.egg-info/top_level.txt
@@ -1,25 +1,25 @@
1
- [build-system]
2
- requires = ["setuptools>=61.0"]
3
- build-backend = "setuptools.build_meta"
4
-
5
- [project]
6
- name = "pyappify"
7
- version = "1.0.5"
8
- description = "My awesome Python application"
9
- readme = "README.md"
10
- requires-python = ">=3.7"
11
- classifiers = [
12
- "Programming Language :: Python :: 3",
13
- "License :: OSI Approved :: MIT License",
14
- "Operating System :: OS Independent",
15
- ]
16
-
17
- [project.scripts]
18
- pyappify = "pyappify.main:main"
19
-
20
- [project.optional-dependencies]
21
- dev = ["twine", "build"]
22
-
23
- [tool.setuptools.packages.find]
24
- where = ["."]
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pyappify"
7
+ version = "1.0.7"
8
+ description = "My awesome Python application"
9
+ readme = "README.md"
10
+ requires-python = ">=3.7"
11
+ classifiers = [
12
+ "Programming Language :: Python :: 3",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Operating System :: OS Independent",
15
+ ]
16
+
17
+ [project.scripts]
18
+ pyappify = "pyappify.main:main"
19
+
20
+ [project.optional-dependencies]
21
+ dev = ["twine", "build"]
22
+
23
+ [tool.setuptools.packages.find]
24
+ where = ["."]
25
25
  include = ["pyappify"]
@@ -1,4 +1,4 @@
1
- [egg_info]
2
- tag_build =
3
- tag_date = 0
4
-
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -1,2 +0,0 @@
1
- def main():
2
- print("PyAppify Hello world")
@@ -1,77 +0,0 @@
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()
@@ -1,94 +0,0 @@
1
- import importlib
2
- import os
3
- import unittest
4
-
5
- import pyappify
6
-
7
-
8
- ENV_KEYS = (
9
- "PYAPPIFY_APP_VERSION",
10
- "PYAPPIFY_APP_STARTING_VERSION",
11
- "PYAPPIFY_UPDATE_NOTE",
12
- )
13
-
14
-
15
- class TestUpdateEnv(unittest.TestCase):
16
- def setUp(self):
17
- self._saved_env = {key: os.environ.get(key) for key in ENV_KEYS}
18
-
19
- def tearDown(self):
20
- for key, value in self._saved_env.items():
21
- if value is None:
22
- os.environ.pop(key, None)
23
- else:
24
- os.environ[key] = value
25
- importlib.reload(pyappify)
26
-
27
- def _reload_with_env(self, **env):
28
- for key in ENV_KEYS:
29
- os.environ.pop(key, None)
30
- for key, value in env.items():
31
- os.environ[key] = value
32
- return importlib.reload(pyappify)
33
-
34
- def test_starting_version_falls_back_to_app_version(self):
35
- module = self._reload_with_env(PYAPPIFY_APP_VERSION="1.2.3")
36
-
37
- self.assertEqual("1.2.3", module.app_version)
38
- self.assertEqual("1.2.3", module.app_starting_version)
39
- self.assertFalse(module.is_app_updated())
40
- self.assertFalse(module.is_updated())
41
- self.assertFalse(module.is_app_downgraded())
42
- self.assertFalse(module.is_downgrade())
43
-
44
- def test_missing_versions_are_not_update_or_downgrade(self):
45
- module = self._reload_with_env()
46
-
47
- self.assertIsNone(module.app_version)
48
- self.assertIsNone(module.app_starting_version)
49
- self.assertFalse(module.is_app_updated())
50
- self.assertFalse(module.is_app_downgraded())
51
-
52
- def test_detects_update_and_downgrade(self):
53
- module = self._reload_with_env(
54
- PYAPPIFY_APP_VERSION="2.0.0",
55
- PYAPPIFY_APP_STARTING_VERSION="1.0.0",
56
- )
57
-
58
- self.assertTrue(module.is_app_updated())
59
- self.assertTrue(module.is_updated())
60
- self.assertFalse(module.is_app_downgraded())
61
- self.assertFalse(module.is_downgrade())
62
-
63
- module = self._reload_with_env(
64
- PYAPPIFY_APP_VERSION="1.0.0",
65
- PYAPPIFY_APP_STARTING_VERSION="2.0.0",
66
- )
67
-
68
- self.assertFalse(module.is_app_updated())
69
- self.assertFalse(module.is_updated())
70
- self.assertTrue(module.is_app_downgraded())
71
- self.assertTrue(module.is_downgrade())
72
-
73
- def test_returns_update_notes_from_json_array(self):
74
- module = self._reload_with_env(
75
- PYAPPIFY_UPDATE_NOTE='["first change", "second change"]'
76
- )
77
-
78
- self.assertEqual(["first change", "second change"], module.get_update_notes())
79
- self.assertEqual(["first change", "second change"], module.get_update_note())
80
-
81
- def test_invalid_or_missing_update_notes_return_empty_list(self):
82
- self.assertEqual([], self._reload_with_env().get_update_notes())
83
- self.assertEqual(
84
- [],
85
- self._reload_with_env(PYAPPIFY_UPDATE_NOTE="not json").get_update_notes(),
86
- )
87
- self.assertEqual(
88
- [],
89
- self._reload_with_env(PYAPPIFY_UPDATE_NOTE='"single note"').get_update_notes(),
90
- )
91
-
92
-
93
- if __name__ == "__main__":
94
- unittest.main()
@@ -1,110 +0,0 @@
1
- import hashlib
2
- import logging
3
- import os
4
- import shutil
5
- import subprocess
6
- import tempfile
7
- import time
8
- import unittest
9
- from pathlib import Path
10
-
11
- import pyappify
12
-
13
-
14
- EXECUTABLE_SHA256 = "50ef2557c193ca1f97c1e699bab3dad35b3966c9e8e8b860fb609bd5fb3861d1"
15
-
16
-
17
- def _sha256(path):
18
- digest = hashlib.sha256()
19
- with open(path, "rb") as file:
20
- for block in iter(lambda: file.read(1024 * 1024), b""):
21
- digest.update(block)
22
- return digest.hexdigest()
23
-
24
-
25
- def assert_upgrade_kills_running_executable_and_replaces_it(
26
- test_case, zip_url, timeout_seconds
27
- ):
28
- source_executable = Path(os.environ["WINDIR"]) / "System32" / "PING.EXE"
29
- test_case.assertTrue(source_executable.exists(), "Windows PING.EXE is required")
30
-
31
- old_cwd = os.getcwd()
32
- old_upgradeable = pyappify.pyappify_upgradeable
33
- old_version = pyappify.pyappify_version
34
- old_executable = pyappify.pyappify_executable
35
- old_pid = pyappify.pid
36
- old_logger = pyappify.logger
37
-
38
- with tempfile.TemporaryDirectory() as temporary_directory:
39
- executable = Path(temporary_directory) / "ok-ww.exe"
40
- upgrade_temporary_directory = Path(temporary_directory) / "pyappify_tmp"
41
- shutil.copy2(source_executable, executable)
42
- test_case.assertNotEqual(_sha256(executable), EXECUTABLE_SHA256)
43
-
44
- process = subprocess.Popen(
45
- [str(executable), "-t", "127.0.0.1"],
46
- cwd=temporary_directory,
47
- stdout=subprocess.DEVNULL,
48
- stderr=subprocess.DEVNULL,
49
- )
50
- try:
51
- time.sleep(1)
52
- test_case.assertIsNone(
53
- process.poll(), "the current executable exited before upgrade began"
54
- )
55
-
56
- pyappify.pyappify_upgradeable = True
57
- pyappify.pyappify_version = "v3.3.45"
58
- pyappify.pyappify_executable = str(executable)
59
- pyappify.pid = process.pid
60
- pyappify.logger = logging.getLogger(__name__)
61
- os.chdir(temporary_directory)
62
-
63
- pyappify.upgrade("v3.3.46", EXECUTABLE_SHA256, [zip_url])
64
-
65
- deadline = time.monotonic() + timeout_seconds
66
- while time.monotonic() < deadline:
67
- process_stopped = process.poll() is not None
68
- upgraded = (
69
- executable.exists()
70
- and _sha256(executable) == EXECUTABLE_SHA256
71
- )
72
- update_finished = not upgrade_temporary_directory.exists()
73
- if process_stopped and upgraded and update_finished:
74
- break
75
- time.sleep(0.1)
76
-
77
- test_case.assertIsNotNone(
78
- process.poll(), "upgrade did not stop the old process"
79
- )
80
- test_case.assertEqual(EXECUTABLE_SHA256, _sha256(executable))
81
- finally:
82
- os.chdir(old_cwd)
83
- pyappify.pyappify_upgradeable = old_upgradeable
84
- pyappify.pyappify_version = old_version
85
- pyappify.pyappify_executable = old_executable
86
- pyappify.pid = old_pid
87
- pyappify.logger = old_logger
88
- if process.poll() is None:
89
- process.terminate()
90
- try:
91
- process.wait(timeout=5)
92
- except subprocess.TimeoutExpired:
93
- process.kill()
94
- process.wait(timeout=5)
95
-
96
-
97
- class TestUpgrade(unittest.TestCase):
98
- LOCAL_ZIP = Path(__file__).with_name("ok-ww-win32.zip")
99
-
100
- @unittest.skipUnless(os.name == "nt", "executable replacement test is Windows-only")
101
- def test_upgrade_from_local_zip_kills_running_executable_and_replaces_it(self):
102
- self.assertTrue(self.LOCAL_ZIP.exists(), "tests/ok-ww-win32.zip is required")
103
- assert_upgrade_kills_running_executable_and_replaces_it(
104
- self,
105
- self.LOCAL_ZIP.as_uri(), timeout_seconds=30
106
- )
107
-
108
-
109
- if __name__ == "__main__":
110
- unittest.main()
@@ -1,21 +0,0 @@
1
- import os
2
- import unittest
3
-
4
- from tests.TestUpgrade import assert_upgrade_kills_running_executable_and_replaces_it
5
-
6
-
7
- class TestUpgradeOnline(unittest.TestCase):
8
- ZIP_URL = (
9
- "https://github.com/ok-oldking/ok-wuthering-waves/"
10
- "releases/download/v3.3.46/ok-ww-win32.zip"
11
- )
12
-
13
- @unittest.skipUnless(os.name == "nt", "executable replacement test is Windows-only")
14
- def test_upgrade_from_online_zip_kills_running_executable_and_replaces_it(self):
15
- assert_upgrade_kills_running_executable_and_replaces_it(
16
- self, self.ZIP_URL, timeout_seconds=600
17
- )
18
-
19
-
20
- if __name__ == "__main__":
21
- unittest.main()
@@ -1,33 +0,0 @@
1
- # filename: test_pyappify.is_greater_version.py
2
- import unittest
3
- import pyappify
4
-
5
- class TestIsGreaterVersion(unittest.TestCase):
6
-
7
- def test_version1_is_greater(self):
8
- self.assertTrue(pyappify.is_greater_version("2.0.0", "1.9.9"))
9
- self.assertTrue(pyappify.is_greater_version("1.2.0", "v1.1.9"))
10
- self.assertTrue(pyappify.is_greater_version("v1.1.2", "1.1.1"))
11
- self.assertTrue(pyappify.is_greater_version("1.1.0", "1.1"))
12
- self.assertTrue(pyappify.is_greater_version("v10.0.0", "9.0.0"))
13
-
14
- def test_version1_is_not_greater(self):
15
- self.assertFalse(pyappify.is_greater_version("v1.0.0", "2.0.0"))
16
- self.assertFalse(pyappify.is_greater_version("1.1.0", "v1.2.0"))
17
- self.assertFalse(pyappify.is_greater_version("1.1.1", "1.1.2"))
18
- self.assertFalse(pyappify.is_greater_version("1.1", "1.1.0"))
19
-
20
- def test_versions_are_equal(self):
21
- self.assertFalse(pyappify.is_greater_version("qwer", "1.0.0"))
22
- self.assertFalse(pyappify.is_greater_version("1.2.3", "1.2.3"))
23
-
24
- def test_invalid_or_edge_cases(self):
25
- self.assertFalse(pyappify.is_greater_version(None, "1.0.0"))
26
- self.assertFalse(pyappify.is_greater_version("1.0.0", None))
27
- self.assertFalse(pyappify.is_greater_version(None, None))
28
- self.assertFalse(pyappify.is_greater_version("v1.a.0", "1.0.0"))
29
- self.assertFalse(pyappify.is_greater_version("1.0.0", "invalid"))
30
- self.assertFalse(pyappify.is_greater_version("", "1.0.0"))
31
-
32
- if __name__ == '__main__':
33
- unittest.main(argv=['first-arg-is-ignored'], exit=False)