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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyappify
3
- Version: 1.0.5
3
+ Version: 1.0.6
4
4
  Summary: My awesome Python application
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyappify
3
- Version: 1.0.5
3
+ Version: 1.0.6
4
4
  Summary: My awesome Python application
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -1,5 +1,6 @@
1
1
  pyproject.toml
2
2
  pyappify/__init__.py
3
+ pyappify/app_config.py
3
4
  pyappify/main.py
4
5
  pyappify.egg-info/PKG-INFO
5
6
  pyappify.egg-info/SOURCES.txt
@@ -7,6 +8,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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pyappify"
7
- version = "1.0.5"
7
+ version = "1.0.6"
8
8
  description = "My awesome Python application"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.7"
@@ -0,0 +1,89 @@
1
+ import json
2
+ import os
3
+ import tempfile
4
+ import threading
5
+ import unittest
6
+
7
+ from pyappify.app_config import (
8
+ AppConfigAPI,
9
+ UPDATE_METHOD_AUTO_PRE_RELEASE,
10
+ UPDATE_METHOD_MANUAL,
11
+ )
12
+
13
+
14
+ class TestAppConfig(unittest.TestCase):
15
+ def setUp(self):
16
+ self.temporary_directory = tempfile.TemporaryDirectory()
17
+ self.config_path = os.path.join(self.temporary_directory.name, "app.json")
18
+ self._write_document(
19
+ {
20
+ "name": "example",
21
+ "installed": True,
22
+ "auto_start": False,
23
+ "update_method": "AUTO_UPDATE",
24
+ }
25
+ )
26
+ self.api = AppConfigAPI(self.config_path)
27
+
28
+ def tearDown(self):
29
+ self.api.stop_watcher()
30
+ self.temporary_directory.cleanup()
31
+
32
+ def _write_document(self, document):
33
+ with open(self.config_path, "w", encoding="utf-8") as config_file:
34
+ json.dump(document, config_file)
35
+
36
+ def test_reads_and_updates_preferences_without_losing_other_fields(self):
37
+ self.assertEqual(
38
+ {"auto_start": False, "update_method": "AUTO_UPDATE"},
39
+ self.api.get(),
40
+ )
41
+
42
+ self.api.update(
43
+ auto_start=True,
44
+ update_method=UPDATE_METHOD_AUTO_PRE_RELEASE,
45
+ )
46
+
47
+ with open(self.config_path, "r", encoding="utf-8") as config_file:
48
+ saved = json.load(config_file)
49
+ self.assertEqual("example", saved["name"])
50
+ self.assertTrue(saved["installed"])
51
+ self.assertTrue(saved["auto_start"])
52
+ self.assertEqual(UPDATE_METHOD_AUTO_PRE_RELEASE, saved["update_method"])
53
+
54
+ def test_rejects_invalid_preferences(self):
55
+ with self.assertRaises(ValueError):
56
+ self.api.update(auto_start="yes")
57
+ with self.assertRaises(ValueError):
58
+ self.api.update(update_method="UNKNOWN")
59
+ with self.assertRaises(ValueError):
60
+ self.api.update(update_method=[])
61
+
62
+ def test_watcher_notifies_when_an_external_writer_changes_the_file(self):
63
+ changed = threading.Event()
64
+ received = []
65
+
66
+ def listener(config):
67
+ received.append(config)
68
+ changed.set()
69
+
70
+ self.api.add_listener(listener)
71
+ self.api.configure(self.config_path, watch=True, watch_interval=0.05)
72
+ self._write_document(
73
+ {
74
+ "name": "example",
75
+ "installed": True,
76
+ "auto_start": True,
77
+ "update_method": UPDATE_METHOD_MANUAL,
78
+ }
79
+ )
80
+
81
+ self.assertTrue(changed.wait(2), "watcher did not observe app.json change")
82
+ self.assertEqual(
83
+ {"auto_start": True, "update_method": UPDATE_METHOD_MANUAL},
84
+ received[-1],
85
+ )
86
+
87
+
88
+ if __name__ == "__main__":
89
+ unittest.main()
@@ -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