pyappify 1.0.2__tar.gz → 1.0.3__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.2
3
+ Version: 1.0.3
4
4
  Summary: My awesome Python application
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -1,19 +1,24 @@
1
1
  # pyappify/__init__.py
2
- import os
3
- import signal
4
- import hashlib
5
- import shutil
6
- import urllib.request
7
- import zipfile
8
- import threading
9
-
10
- app_version = os.environ.get("PYAPPIFY_APP_VERSION")
11
- app_profile = os.environ.get("PYAPPIFY_APP_PROFILE")
12
- pyappify_version = os.environ.get("PYAPPIFY_VERSION")
13
- pyappify_executable = os.environ.get("PYAPPIFY_EXECUTABLE")
14
-
15
- pyappify_upgradeable = os.environ.get("PYAPPIFY_UPGRADEABLE") == '1'
16
- logger = None
2
+ import os
3
+ import signal
4
+ import hashlib
5
+ import json
6
+ import logging
7
+ import shutil
8
+ import urllib.request
9
+ import zipfile
10
+ import threading
11
+
12
+ app_version = os.environ.get("PYAPPIFY_APP_VERSION")
13
+ app_starting_version = os.environ.get("PYAPPIFY_APP_STARTING_VERSION") or app_version
14
+ update_note = os.environ.get("PYAPPIFY_UPDATE_NOTE")
15
+ app_profile = os.environ.get("PYAPPIFY_APP_PROFILE")
16
+ pyappify_version = os.environ.get("PYAPPIFY_VERSION")
17
+ pyappify_executable = os.environ.get("PYAPPIFY_EXECUTABLE")
18
+
19
+ pyappify_upgradeable = os.environ.get("PYAPPIFY_UPGRADEABLE") == '1'
20
+ logger = None
21
+ _console_logger = None
17
22
 
18
23
  try:
19
24
  pid = int(os.environ.get("PYAPPIFY_PID"))
@@ -24,11 +29,27 @@ import sys
24
29
 
25
30
  try:
26
31
  import ctypes
27
- except ImportError:
28
- ctypes = None
29
-
30
-
31
- def minimize_window_by_pid(pid):
32
+ except ImportError:
33
+ ctypes = None
34
+
35
+
36
+ def _get_logger():
37
+ global _console_logger
38
+
39
+ if logger is not None:
40
+ return logger
41
+ if _console_logger is None:
42
+ _console_logger = logging.getLogger("pyappify")
43
+ if not _console_logger.handlers:
44
+ handler = logging.StreamHandler()
45
+ handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
46
+ _console_logger.addHandler(handler)
47
+ _console_logger.setLevel(logging.INFO)
48
+ _console_logger.propagate = False
49
+ return _console_logger
50
+
51
+
52
+ def minimize_window_by_pid(pid):
32
53
  if not ctypes or sys.platform != "win32":
33
54
  return False
34
55
 
@@ -51,71 +72,63 @@ def minimize_window_by_pid(pid):
51
72
 
52
73
  return False
53
74
 
54
- def kill_pyappify():
55
- if pid:
56
- if logger:
57
- logger.info(f"Attempting to terminate process with PID: {pid}")
58
- try:
59
- os.kill(pid, signal.SIGTERM)
60
- except Exception as e:
61
- if logger:
62
- logger.error(f"Failed to terminate process with PID {pid}: {e}")
63
- pass
64
-
65
- def hide_pyappify():
66
- if pid:
67
- if logger:
68
- logger.info(f"Attempting to minimize window for process with PID: {pid}")
69
- try:
70
- minimize_window_by_pid(pid)
71
- except Exception as e:
72
- if logger:
73
- logger.error(f"Failed to minimize window for process with PID {pid}: {e}")
74
- pass
75
-
76
- def upgrade(to_version, executable_sha256, executable_zip_urls, stop_event=None):
77
- if not pyappify_upgradeable or not is_greater_version(to_version, pyappify_version):
78
- if logger:
79
- logger.info(f"pyappify no need to upgrade {pyappify_upgradeable} {to_version} {executable_sha256} {executable_zip_urls}")
80
- return
81
- if logger:
82
- logger.info(
83
- f"pyappify start to upgrade {pyappify_upgradeable} {to_version} {executable_sha256} {executable_zip_urls}")
84
- def _do_upgrade():
85
- tmp_dir = os.path.join(os.getcwd(), "pyappify_tmp")
86
- try:
75
+ def kill_pyappify():
76
+ if pid:
77
+ log = _get_logger()
78
+ log.info(f"Attempting to terminate process with PID: {pid}")
79
+ try:
80
+ os.kill(pid, signal.SIGTERM)
81
+ except Exception as e:
82
+ log.error(f"Failed to terminate process with PID {pid}: {e}")
83
+ pass
84
+
85
+ def hide_pyappify():
86
+ if pid:
87
+ log = _get_logger()
88
+ log.info(f"Attempting to minimize window for process with PID: {pid}")
89
+ try:
90
+ minimize_window_by_pid(pid)
91
+ except Exception as e:
92
+ log.error(f"Failed to minimize window for process with PID {pid}: {e}")
93
+ pass
94
+
95
+ def upgrade(to_version, executable_sha256, executable_zip_urls, stop_event=None):
96
+ log = _get_logger()
97
+ if not pyappify_upgradeable or not is_greater_version(to_version, pyappify_version):
98
+ log.info(f"pyappify no need to upgrade {pyappify_upgradeable} {to_version} {executable_sha256} {executable_zip_urls}")
99
+ return
100
+ log.info(
101
+ f"pyappify start to upgrade {pyappify_upgradeable} {to_version} {executable_sha256} {executable_zip_urls}")
102
+ def _do_upgrade():
103
+ tmp_dir = os.path.join(os.getcwd(), "pyappify_tmp")
104
+ try:
87
105
  os.makedirs(tmp_dir, exist_ok=True)
88
- downloaded_zip_path = None
89
- for url in executable_zip_urls:
90
- try:
91
- if logger:
92
- logger.info(
93
- f"pyappify start to download {url}")
94
- local_zip_path = os.path.join(tmp_dir, os.path.basename(url))
95
- with urllib.request.urlopen(url) as response, open(local_zip_path, 'wb') as out_file:
96
- while True:
97
- if stop_event and stop_event.is_set():
98
- if logger:
99
- logger.info("pyappify Upgrade download cancelled by stop event.")
100
- return
106
+ downloaded_zip_path = None
107
+ for url in executable_zip_urls:
108
+ try:
109
+ log.info(
110
+ f"pyappify start to download {url}")
111
+ local_zip_path = os.path.join(tmp_dir, os.path.basename(url))
112
+ with urllib.request.urlopen(url) as response, open(local_zip_path, 'wb') as out_file:
113
+ while True:
114
+ if stop_event and stop_event.is_set():
115
+ log.info("pyappify Upgrade download cancelled by stop event.")
116
+ return
101
117
  chunk = response.read(8192)
102
118
  if not chunk:
103
119
  break
104
120
  out_file.write(chunk)
105
- downloaded_zip_path = local_zip_path
106
- if logger:
107
- logger.info(
108
- f"pyappify download success {url}")
109
- break
110
- except Exception as e:
111
- if logger:
112
- logger.warning(f"pyappify Failed to download from {url}: {e}")
113
- continue
114
-
115
- if not downloaded_zip_path:
116
- if logger:
117
- logger.error("pyappify Failed to download upgrade.")
118
- return
121
+ downloaded_zip_path = local_zip_path
122
+ log.info(
123
+ f"pyappify download success {url}")
124
+ break
125
+ except Exception as e:
126
+ log.warning(f"pyappify Failed to download from {url}: {e}")
127
+ continue
128
+
129
+ if not downloaded_zip_path:
130
+ log.error("pyappify Failed to download upgrade.")
131
+ return
119
132
 
120
133
  with zipfile.ZipFile(downloaded_zip_path, 'r') as zip_ref:
121
134
  zip_ref.extractall(tmp_dir)
@@ -127,39 +140,68 @@ def upgrade(to_version, executable_sha256, executable_zip_urls, stop_event=None)
127
140
  found_executable_path = os.path.join(root, new_executable_name)
128
141
  break
129
142
 
130
- if not found_executable_path:
131
- if logger:
132
- logger.error("pyappify Executable not found in zip.")
133
- return
143
+ if not found_executable_path:
144
+ log.error("pyappify Executable not found in zip.")
145
+ return
134
146
 
135
147
  sha256_hash = hashlib.sha256()
136
148
  with open(found_executable_path, "rb") as f:
137
149
  for byte_block in iter(lambda: f.read(4096), b""):
138
150
  sha256_hash.update(byte_block)
139
151
 
140
- if executable_sha256 and sha256_hash.hexdigest() != executable_sha256:
141
- if logger:
142
- logger.error("pyappify SHA256 checksum mismatch.")
143
- return
144
-
145
- kill_pyappify()
146
- shutil.move(found_executable_path, pyappify_executable)
147
- if logger:
148
- logger.info(f"pyappify Upgrade success")
149
- except Exception as e:
150
- if logger:
151
- logger.error(f"pyappify Upgrade failed: {e}")
152
+ if executable_sha256 and sha256_hash.hexdigest() != executable_sha256:
153
+ log.error("pyappify SHA256 checksum mismatch.")
154
+ return
155
+
156
+ kill_pyappify()
157
+ shutil.move(found_executable_path, pyappify_executable)
158
+ log.info(f"pyappify Upgrade success")
159
+ except Exception as e:
160
+ log.error(f"pyappify Upgrade failed: {e}")
152
161
  finally:
153
162
  if os.path.exists(tmp_dir):
154
163
  shutil.rmtree(tmp_dir)
155
164
 
156
- thread = threading.Thread(target=_do_upgrade)
157
- thread.daemon = True
158
- thread.start()
159
-
160
- def is_greater_version(version1, version2):
161
- """
162
- Compares two semantic version strings.
165
+ thread = threading.Thread(target=_do_upgrade)
166
+ thread.daemon = True
167
+ thread.start()
168
+
169
+
170
+ def is_app_updated():
171
+ return is_greater_version(app_version, app_starting_version)
172
+
173
+
174
+ def is_app_downgraded():
175
+ return is_greater_version(app_starting_version, app_version)
176
+
177
+
178
+ def is_updated():
179
+ return is_app_updated()
180
+
181
+
182
+ def is_downgrade():
183
+ return is_app_downgraded()
184
+
185
+
186
+ def get_update_notes():
187
+ if not update_note:
188
+ return []
189
+ try:
190
+ notes = json.loads(update_note)
191
+ except (TypeError, ValueError):
192
+ return []
193
+ if isinstance(notes, list):
194
+ return [str(note) for note in notes]
195
+ return []
196
+
197
+
198
+ def get_update_note():
199
+ return get_update_notes()
200
+
201
+
202
+ def is_greater_version(version1, version2):
203
+ """
204
+ Compares two semantic version strings.
163
205
 
164
206
  Args:
165
207
  version1 (str): The first version string.
@@ -176,4 +218,4 @@ def is_greater_version(version1, version2):
176
218
  v2_parts = [int(p) for p in version2.split('.')]
177
219
  return v1_parts > v2_parts
178
220
  except (ValueError, AttributeError):
179
- return False
221
+ return False
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyappify
3
- Version: 1.0.2
3
+ Version: 1.0.3
4
4
  Summary: My awesome Python application
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -7,4 +7,7 @@ pyappify.egg-info/dependency_links.txt
7
7
  pyappify.egg-info/entry_points.txt
8
8
  pyappify.egg-info/requires.txt
9
9
  pyappify.egg-info/top_level.txt
10
+ tests/TestUpdateEnv.py
11
+ tests/TestUpgrade.py
12
+ tests/TestUpgradeOnline.py
10
13
  tests/TestVersion.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pyappify"
7
- version = "1.0.2"
7
+ version = "1.0.3"
8
8
  description = "My awesome Python application"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.7"
@@ -22,4 +22,4 @@ dev = ["twine", "build"]
22
22
 
23
23
  [tool.setuptools.packages.find]
24
24
  where = ["."]
25
- include = ["pyappify"]
25
+ include = ["pyappify"]
@@ -0,0 +1,94 @@
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()
@@ -0,0 +1,110 @@
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()
@@ -0,0 +1,21 @@
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()
File without changes
File without changes
File without changes