pyappify 1.0.5__tar.gz → 1.0.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pyappify-1.0.5 → pyappify-1.0.6}/PKG-INFO +1 -1
- {pyappify-1.0.5 → pyappify-1.0.6}/pyappify/__init__.py +24 -0
- pyappify-1.0.6/pyappify/app_config.py +244 -0
- {pyappify-1.0.5 → pyappify-1.0.6}/pyappify.egg-info/PKG-INFO +1 -1
- {pyappify-1.0.5 → pyappify-1.0.6}/pyappify.egg-info/SOURCES.txt +2 -0
- {pyappify-1.0.5 → pyappify-1.0.6}/pyproject.toml +1 -1
- pyappify-1.0.6/tests/TestAppConfig.py +89 -0
- {pyappify-1.0.5 → pyappify-1.0.6}/tests/TestUpdateEnv.py +6 -0
- {pyappify-1.0.5 → pyappify-1.0.6}/pyappify/main.py +0 -0
- {pyappify-1.0.5 → pyappify-1.0.6}/pyappify.egg-info/dependency_links.txt +0 -0
- {pyappify-1.0.5 → pyappify-1.0.6}/pyappify.egg-info/entry_points.txt +0 -0
- {pyappify-1.0.5 → pyappify-1.0.6}/pyappify.egg-info/requires.txt +0 -0
- {pyappify-1.0.5 → pyappify-1.0.6}/pyappify.egg-info/top_level.txt +0 -0
- {pyappify-1.0.5 → pyappify-1.0.6}/setup.cfg +0 -0
- {pyappify-1.0.5 → pyappify-1.0.6}/tests/TestProcessControls.py +0 -0
- {pyappify-1.0.5 → pyappify-1.0.6}/tests/TestUpgrade.py +0 -0
- {pyappify-1.0.5 → pyappify-1.0.6}/tests/TestUpgradeOnline.py +0 -0
- {pyappify-1.0.5 → pyappify-1.0.6}/tests/TestVersion.py +0 -0
|
@@ -11,12 +11,32 @@ import zipfile
|
|
|
11
11
|
import threading
|
|
12
12
|
import time
|
|
13
13
|
|
|
14
|
+
from .app_config import (
|
|
15
|
+
UPDATE_METHOD_AUTO,
|
|
16
|
+
UPDATE_METHOD_AUTO_PRE_RELEASE,
|
|
17
|
+
UPDATE_METHOD_MANUAL,
|
|
18
|
+
add_app_config_listener,
|
|
19
|
+
configure_app_json,
|
|
20
|
+
get_app_config,
|
|
21
|
+
get_app_json_path,
|
|
22
|
+
get_auto_start,
|
|
23
|
+
get_update_method,
|
|
24
|
+
remove_app_config_listener,
|
|
25
|
+
set_auto_start,
|
|
26
|
+
set_update_method,
|
|
27
|
+
start_app_config_watcher,
|
|
28
|
+
stop_app_config_watcher,
|
|
29
|
+
update_app_config,
|
|
30
|
+
)
|
|
31
|
+
|
|
14
32
|
app_version = os.environ.get("PYAPPIFY_APP_VERSION")
|
|
15
33
|
app_starting_version = os.environ.get("PYAPPIFY_APP_STARTING_VERSION") or app_version
|
|
16
34
|
update_note = os.environ.get("PYAPPIFY_UPDATE_NOTE")
|
|
17
35
|
app_profile = os.environ.get("PYAPPIFY_APP_PROFILE")
|
|
36
|
+
app_locale = os.environ.get("PYAPPIFY_LOCALE") or "en"
|
|
18
37
|
pyappify_version = os.environ.get("PYAPPIFY_VERSION")
|
|
19
38
|
pyappify_executable = os.environ.get("PYAPPIFY_EXECUTABLE")
|
|
39
|
+
app_json_path = configure_app_json(os.environ.get("PYAPPIFY_APP_JSON_PATH"))
|
|
20
40
|
|
|
21
41
|
pyappify_upgradeable = os.environ.get("PYAPPIFY_UPGRADEABLE") == '1'
|
|
22
42
|
logger = None
|
|
@@ -349,6 +369,10 @@ def get_update_note():
|
|
|
349
369
|
return get_update_notes()
|
|
350
370
|
|
|
351
371
|
|
|
372
|
+
def get_locale():
|
|
373
|
+
return app_locale
|
|
374
|
+
|
|
375
|
+
|
|
352
376
|
def is_greater_version(version1, version2):
|
|
353
377
|
"""
|
|
354
378
|
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,5 +1,6 @@
|
|
|
1
1
|
pyproject.toml
|
|
2
2
|
pyappify/__init__.py
|
|
3
|
+
pyappify/app_config.py
|
|
3
4
|
pyappify/main.py
|
|
4
5
|
pyappify.egg-info/PKG-INFO
|
|
5
6
|
pyappify.egg-info/SOURCES.txt
|
|
@@ -7,6 +8,7 @@ pyappify.egg-info/dependency_links.txt
|
|
|
7
8
|
pyappify.egg-info/entry_points.txt
|
|
8
9
|
pyappify.egg-info/requires.txt
|
|
9
10
|
pyappify.egg-info/top_level.txt
|
|
11
|
+
tests/TestAppConfig.py
|
|
10
12
|
tests/TestProcessControls.py
|
|
11
13
|
tests/TestUpdateEnv.py
|
|
12
14
|
tests/TestUpgrade.py
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import tempfile
|
|
4
|
+
import threading
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
from pyappify.app_config import (
|
|
8
|
+
AppConfigAPI,
|
|
9
|
+
UPDATE_METHOD_AUTO_PRE_RELEASE,
|
|
10
|
+
UPDATE_METHOD_MANUAL,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestAppConfig(unittest.TestCase):
|
|
15
|
+
def setUp(self):
|
|
16
|
+
self.temporary_directory = tempfile.TemporaryDirectory()
|
|
17
|
+
self.config_path = os.path.join(self.temporary_directory.name, "app.json")
|
|
18
|
+
self._write_document(
|
|
19
|
+
{
|
|
20
|
+
"name": "example",
|
|
21
|
+
"installed": True,
|
|
22
|
+
"auto_start": False,
|
|
23
|
+
"update_method": "AUTO_UPDATE",
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
self.api = AppConfigAPI(self.config_path)
|
|
27
|
+
|
|
28
|
+
def tearDown(self):
|
|
29
|
+
self.api.stop_watcher()
|
|
30
|
+
self.temporary_directory.cleanup()
|
|
31
|
+
|
|
32
|
+
def _write_document(self, document):
|
|
33
|
+
with open(self.config_path, "w", encoding="utf-8") as config_file:
|
|
34
|
+
json.dump(document, config_file)
|
|
35
|
+
|
|
36
|
+
def test_reads_and_updates_preferences_without_losing_other_fields(self):
|
|
37
|
+
self.assertEqual(
|
|
38
|
+
{"auto_start": False, "update_method": "AUTO_UPDATE"},
|
|
39
|
+
self.api.get(),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
self.api.update(
|
|
43
|
+
auto_start=True,
|
|
44
|
+
update_method=UPDATE_METHOD_AUTO_PRE_RELEASE,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
with open(self.config_path, "r", encoding="utf-8") as config_file:
|
|
48
|
+
saved = json.load(config_file)
|
|
49
|
+
self.assertEqual("example", saved["name"])
|
|
50
|
+
self.assertTrue(saved["installed"])
|
|
51
|
+
self.assertTrue(saved["auto_start"])
|
|
52
|
+
self.assertEqual(UPDATE_METHOD_AUTO_PRE_RELEASE, saved["update_method"])
|
|
53
|
+
|
|
54
|
+
def test_rejects_invalid_preferences(self):
|
|
55
|
+
with self.assertRaises(ValueError):
|
|
56
|
+
self.api.update(auto_start="yes")
|
|
57
|
+
with self.assertRaises(ValueError):
|
|
58
|
+
self.api.update(update_method="UNKNOWN")
|
|
59
|
+
with self.assertRaises(ValueError):
|
|
60
|
+
self.api.update(update_method=[])
|
|
61
|
+
|
|
62
|
+
def test_watcher_notifies_when_an_external_writer_changes_the_file(self):
|
|
63
|
+
changed = threading.Event()
|
|
64
|
+
received = []
|
|
65
|
+
|
|
66
|
+
def listener(config):
|
|
67
|
+
received.append(config)
|
|
68
|
+
changed.set()
|
|
69
|
+
|
|
70
|
+
self.api.add_listener(listener)
|
|
71
|
+
self.api.configure(self.config_path, watch=True, watch_interval=0.05)
|
|
72
|
+
self._write_document(
|
|
73
|
+
{
|
|
74
|
+
"name": "example",
|
|
75
|
+
"installed": True,
|
|
76
|
+
"auto_start": True,
|
|
77
|
+
"update_method": UPDATE_METHOD_MANUAL,
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
self.assertTrue(changed.wait(2), "watcher did not observe app.json change")
|
|
82
|
+
self.assertEqual(
|
|
83
|
+
{"auto_start": True, "update_method": UPDATE_METHOD_MANUAL},
|
|
84
|
+
received[-1],
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
unittest.main()
|
|
@@ -9,6 +9,8 @@ ENV_KEYS = (
|
|
|
9
9
|
"PYAPPIFY_APP_VERSION",
|
|
10
10
|
"PYAPPIFY_APP_STARTING_VERSION",
|
|
11
11
|
"PYAPPIFY_UPDATE_NOTE",
|
|
12
|
+
"PYAPPIFY_APP_JSON_PATH",
|
|
13
|
+
"PYAPPIFY_LOCALE",
|
|
12
14
|
)
|
|
13
15
|
|
|
14
16
|
|
|
@@ -89,6 +91,10 @@ class TestUpdateEnv(unittest.TestCase):
|
|
|
89
91
|
self._reload_with_env(PYAPPIFY_UPDATE_NOTE='"single note"').get_update_notes(),
|
|
90
92
|
)
|
|
91
93
|
|
|
94
|
+
def test_reads_locale_with_english_fallback(self):
|
|
95
|
+
self.assertEqual("zh-CN", self._reload_with_env(PYAPPIFY_LOCALE="zh-CN").get_locale())
|
|
96
|
+
self.assertEqual("en", self._reload_with_env().get_locale())
|
|
97
|
+
|
|
92
98
|
|
|
93
99
|
if __name__ == "__main__":
|
|
94
100
|
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|