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.
- {pyappify-1.0.5 → pyappify-1.0.7}/PKG-INFO +12 -12
- {pyappify-1.0.5 → pyappify-1.0.7}/pyappify/__init__.py +259 -10
- pyappify-1.0.7/pyappify/app_config.py +244 -0
- pyappify-1.0.7/pyappify/main.py +2 -0
- {pyappify-1.0.5 → pyappify-1.0.7}/pyappify.egg-info/PKG-INFO +12 -12
- {pyappify-1.0.5 → pyappify-1.0.7}/pyappify.egg-info/SOURCES.txt +2 -6
- {pyappify-1.0.5 → pyappify-1.0.7}/pyproject.toml +24 -24
- {pyappify-1.0.5 → pyappify-1.0.7}/setup.cfg +4 -4
- pyappify-1.0.5/pyappify/main.py +0 -2
- pyappify-1.0.5/tests/TestProcessControls.py +0 -77
- pyappify-1.0.5/tests/TestUpdateEnv.py +0 -94
- pyappify-1.0.5/tests/TestUpgrade.py +0 -110
- pyappify-1.0.5/tests/TestUpgradeOnline.py +0 -21
- pyappify-1.0.5/tests/TestVersion.py +0 -33
- {pyappify-1.0.5 → pyappify-1.0.7}/pyappify.egg-info/dependency_links.txt +0 -0
- {pyappify-1.0.5 → pyappify-1.0.7}/pyappify.egg-info/entry_points.txt +0 -0
- {pyappify-1.0.5 → pyappify-1.0.7}/pyappify.egg-info/requires.txt +0 -0
- {pyappify-1.0.5 → pyappify-1.0.7}/pyappify.egg-info/top_level.txt +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: pyappify
|
|
3
|
-
Version: 1.0.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
198
|
-
|
|
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 = [
|
|
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(
|
|
263
|
+
cwd=cwd or os.path.dirname(executable) or None,
|
|
212
264
|
env=env,
|
|
213
265
|
)
|
|
214
|
-
|
|
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 {
|
|
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)
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: pyappify
|
|
3
|
-
Version: 1.0.
|
|
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.
|
|
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
|
+
|
pyappify-1.0.5/pyappify/main.py
DELETED
|
@@ -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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|