apprc-gui 0.26.0__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.
- apprc_gui-0.26.0/LICENSE +21 -0
- apprc_gui-0.26.0/PKG-INFO +43 -0
- apprc_gui-0.26.0/README.md +30 -0
- apprc_gui-0.26.0/pyproject.toml +22 -0
- apprc_gui-0.26.0/setup.cfg +4 -0
- apprc_gui-0.26.0/src/apprc_gui/__init__.py +5 -0
- apprc_gui-0.26.0/src/apprc_gui/view.py +368 -0
- apprc_gui-0.26.0/src/apprc_gui.egg-info/PKG-INFO +43 -0
- apprc_gui-0.26.0/src/apprc_gui.egg-info/SOURCES.txt +11 -0
- apprc_gui-0.26.0/src/apprc_gui.egg-info/dependency_links.txt +1 -0
- apprc_gui-0.26.0/src/apprc_gui.egg-info/requires.txt +5 -0
- apprc_gui-0.26.0/src/apprc_gui.egg-info/top_level.txt +1 -0
- apprc_gui-0.26.0/tests/test_view.py +52 -0
apprc_gui-0.26.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 markur4
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: apprc-gui
|
|
3
|
+
Version: 0.26.0
|
|
4
|
+
Summary: Toga configuration view for AppRC applications.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: apprc-core==0.26.0
|
|
10
|
+
Requires-Dist: toga-core<0.6,>=0.5.4
|
|
11
|
+
Requires-Dist: toga-winforms<0.6,>=0.5.4; sys_platform == "win32"
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# AppRC GUI
|
|
15
|
+
|
|
16
|
+
`apprc-gui` provides a Toga configuration view for applications using
|
|
17
|
+
`apprc-core`. The host application creates one `ConfigManager` from its `AppRC`
|
|
18
|
+
declaration and places `apprc_gui.ConfigView` in its own Toga window.
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
import toga
|
|
22
|
+
from apprc_gui import ConfigView
|
|
23
|
+
|
|
24
|
+
from my_app.config import MyRC
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MyDesktopApp(toga.App):
|
|
28
|
+
def startup(self):
|
|
29
|
+
window = toga.MainWindow(title="My App")
|
|
30
|
+
self.main_window = window
|
|
31
|
+
window.content = ConfigView(MyRC.manage(), window).widget
|
|
32
|
+
window.show()
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Import the config sections registered on `MyRC` before creating the view.
|
|
36
|
+
The view can open before required settings or storage are ready. It uses
|
|
37
|
+
AppRC's manager to set up files, select storage, and edit saved fields. The
|
|
38
|
+
application retains ownership of the window and decides when to start its own
|
|
39
|
+
work. See the [GUI guide](https://github.com/HisQu/apprc/blob/main/docs/How-To-User-Guides.md#add-a-native-settings-window)
|
|
40
|
+
for an application callback and installer instructions.
|
|
41
|
+
|
|
42
|
+
On Windows, installation includes the WinForms backend. On other platforms,
|
|
43
|
+
install a supported Toga backend separately before opening the window.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# AppRC GUI
|
|
2
|
+
|
|
3
|
+
`apprc-gui` provides a Toga configuration view for applications using
|
|
4
|
+
`apprc-core`. The host application creates one `ConfigManager` from its `AppRC`
|
|
5
|
+
declaration and places `apprc_gui.ConfigView` in its own Toga window.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
import toga
|
|
9
|
+
from apprc_gui import ConfigView
|
|
10
|
+
|
|
11
|
+
from my_app.config import MyRC
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MyDesktopApp(toga.App):
|
|
15
|
+
def startup(self):
|
|
16
|
+
window = toga.MainWindow(title="My App")
|
|
17
|
+
self.main_window = window
|
|
18
|
+
window.content = ConfigView(MyRC.manage(), window).widget
|
|
19
|
+
window.show()
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Import the config sections registered on `MyRC` before creating the view.
|
|
23
|
+
The view can open before required settings or storage are ready. It uses
|
|
24
|
+
AppRC's manager to set up files, select storage, and edit saved fields. The
|
|
25
|
+
application retains ownership of the window and decides when to start its own
|
|
26
|
+
work. See the [GUI guide](https://github.com/HisQu/apprc/blob/main/docs/How-To-User-Guides.md#add-a-native-settings-window)
|
|
27
|
+
for an application callback and installer instructions.
|
|
28
|
+
|
|
29
|
+
On Windows, installation includes the WinForms backend. On other platforms,
|
|
30
|
+
install a supported Toga backend separately before opening the window.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=82"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "apprc-gui"
|
|
7
|
+
version = "0.26.0"
|
|
8
|
+
description = "Toga configuration view for AppRC applications."
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"apprc-core==0.26.0",
|
|
14
|
+
"toga-core>=0.5.4,<0.6",
|
|
15
|
+
"toga-winforms>=0.5.4,<0.6; sys_platform == 'win32'",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[tool.setuptools]
|
|
19
|
+
package-dir = {"" = "src"}
|
|
20
|
+
|
|
21
|
+
[tool.setuptools.packages.find]
|
|
22
|
+
where = ["src"]
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""Reusable Toga view for setup and editing AppRC-managed layers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import toga
|
|
9
|
+
|
|
10
|
+
from apprc.services.inspection import ConfigInspection, FieldInspection
|
|
11
|
+
from apprc.services.manager import ConfigManager, WriteScope
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConfigView:
|
|
15
|
+
"""Show one AppRC declaration inside an application-owned Toga window.
|
|
16
|
+
|
|
17
|
+
The view owns widgets and prompts. ``ConfigManager`` remains responsible
|
|
18
|
+
for every read, setup action, preview, and write.
|
|
19
|
+
|
|
20
|
+
:param manager: Application-bound manager captured from one ``AppRC``.
|
|
21
|
+
:param window: Host window used for the native folder chooser.
|
|
22
|
+
:param on_change: Called with refreshed inspection after any view change.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
manager: ConfigManager,
|
|
28
|
+
window: toga.Window,
|
|
29
|
+
*,
|
|
30
|
+
on_change: Callable[[ConfigInspection], None] | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
self.manager = manager
|
|
33
|
+
self.window = window
|
|
34
|
+
self.on_change = on_change
|
|
35
|
+
self.widget = toga.ScrollContainer(horizontal=False)
|
|
36
|
+
self.inspection = manager.inspect()
|
|
37
|
+
self._error = ""
|
|
38
|
+
self._scope_select: toga.Selection | None = None
|
|
39
|
+
self.refresh()
|
|
40
|
+
|
|
41
|
+
def refresh(self) -> None:
|
|
42
|
+
"""Read current files and redraw the user and storage controls."""
|
|
43
|
+
self.inspection = self.manager.inspect()
|
|
44
|
+
root = toga.Column()
|
|
45
|
+
root.add(toga.Label(f"{self.manager.schema.display_name} settings"))
|
|
46
|
+
if self._error:
|
|
47
|
+
root.add(toga.Label(self._error))
|
|
48
|
+
if self.inspection.issues:
|
|
49
|
+
root.add(
|
|
50
|
+
toga.Label(
|
|
51
|
+
"Needs attention: " + "; ".join(self.inspection.issues)
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
else:
|
|
55
|
+
root.add(toga.Label("Configuration is ready."))
|
|
56
|
+
root.add(
|
|
57
|
+
toga.Label(
|
|
58
|
+
"Saved secret settings stay in a private file beside the selected settings layer. "
|
|
59
|
+
"AppRC does not upload them."
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
self._add_storage_controls(root)
|
|
63
|
+
self._add_scope_control(root)
|
|
64
|
+
for owner in self.manager.schema.owners:
|
|
65
|
+
root.add(toga.Label(owner.title or owner.key))
|
|
66
|
+
for item in self.inspection.fields:
|
|
67
|
+
if item.owner.key == owner.key:
|
|
68
|
+
self._add_field(root, item)
|
|
69
|
+
self.widget.content = root
|
|
70
|
+
if self.on_change is not None:
|
|
71
|
+
self.on_change(self.inspection)
|
|
72
|
+
|
|
73
|
+
def _add_storage_controls(self, root: toga.Box) -> None:
|
|
74
|
+
"""Let users initialize and select named storage without app policy."""
|
|
75
|
+
if not self.manager.schema.uses_storage():
|
|
76
|
+
if (
|
|
77
|
+
self.manager.schema.uses_user_dotenv()
|
|
78
|
+
and not self.manager.paths.user_dotenv.is_file()
|
|
79
|
+
):
|
|
80
|
+
root.add(
|
|
81
|
+
toga.Button(
|
|
82
|
+
"Create user settings file", on_press=self._setup_user
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
return
|
|
86
|
+
root.add(toga.Label("Storage"))
|
|
87
|
+
registry_state = self.manager.inspect_registry()
|
|
88
|
+
if registry_state.error is not None or registry_state.registry is None:
|
|
89
|
+
root.add(
|
|
90
|
+
toga.Label(
|
|
91
|
+
registry_state.error or "Storage registry is unavailable."
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
return
|
|
95
|
+
registry = registry_state.registry
|
|
96
|
+
if registry.storages:
|
|
97
|
+
selection = self.inspection.resolved.selection
|
|
98
|
+
if (
|
|
99
|
+
selection is not None
|
|
100
|
+
and selection.source != "apprc.toml selected_storage"
|
|
101
|
+
):
|
|
102
|
+
root.add(
|
|
103
|
+
toga.Label(
|
|
104
|
+
f"This run uses {selection.storage_name or selection.root.name} "
|
|
105
|
+
f"from {selection.source}; it overrides the saved default."
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
names = list(registry.storages)
|
|
109
|
+
selected = toga.Selection(
|
|
110
|
+
items=names, value=registry.selected_storage
|
|
111
|
+
)
|
|
112
|
+
root.add(selected)
|
|
113
|
+
root.add(
|
|
114
|
+
toga.Button(
|
|
115
|
+
"Use selected storage",
|
|
116
|
+
on_press=lambda _widget: self._select_storage(selected),
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
name_input = toga.TextInput(
|
|
120
|
+
value="default" if not registry.storages else ""
|
|
121
|
+
)
|
|
122
|
+
root.add(toga.Label("Name for a new storage"))
|
|
123
|
+
root.add(name_input)
|
|
124
|
+
root.add(
|
|
125
|
+
toga.Button(
|
|
126
|
+
"Choose storage folder",
|
|
127
|
+
on_press=self._folder_handler(name_input),
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def _folder_handler(
|
|
132
|
+
self, name_input: toga.TextInput
|
|
133
|
+
) -> Callable[..., object]:
|
|
134
|
+
"""Return an async handler bound to the visible storage name."""
|
|
135
|
+
|
|
136
|
+
async def choose(_widget: toga.Widget) -> None:
|
|
137
|
+
await self._choose_storage(name_input)
|
|
138
|
+
|
|
139
|
+
return choose
|
|
140
|
+
|
|
141
|
+
def _add_scope_control(self, root: toga.Box) -> None:
|
|
142
|
+
"""Show which saved layer the following edit buttons will change."""
|
|
143
|
+
scopes = self.manager.writable_scopes()
|
|
144
|
+
if scopes:
|
|
145
|
+
root.add(toga.Label("Save changes in"))
|
|
146
|
+
previous = (
|
|
147
|
+
None if self._scope_select is None else self._scope_select.value
|
|
148
|
+
)
|
|
149
|
+
self._scope_select = toga.Selection(
|
|
150
|
+
items=list(scopes),
|
|
151
|
+
value=previous if previous in scopes else scopes[0],
|
|
152
|
+
)
|
|
153
|
+
root.add(self._scope_select)
|
|
154
|
+
for scope in scopes:
|
|
155
|
+
self._add_secret_controls(root, scope)
|
|
156
|
+
else:
|
|
157
|
+
self._scope_select = None
|
|
158
|
+
|
|
159
|
+
def _add_secret_controls(self, root: toga.Box, scope: WriteScope) -> None:
|
|
160
|
+
"""Offer explicit migration and permission repair for a saved layer."""
|
|
161
|
+
try:
|
|
162
|
+
plan = self.manager.plan_secret_migration(scope)
|
|
163
|
+
except (OSError, ValueError) as exc:
|
|
164
|
+
root.add(toga.Label(f"{scope.title()} secret migration: {exc}"))
|
|
165
|
+
plan = None
|
|
166
|
+
if plan is not None and plan.keys:
|
|
167
|
+
root.add(
|
|
168
|
+
toga.Label(
|
|
169
|
+
f"{scope.title()} settings have secret values in the ordinary file: "
|
|
170
|
+
+ ", ".join(plan.keys)
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
root.add(
|
|
174
|
+
toga.Button(
|
|
175
|
+
f"Move {scope} secrets to private file",
|
|
176
|
+
on_press=lambda _widget: self._migrate_secrets(scope),
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
status = self.manager.secret_status(scope)
|
|
180
|
+
if not status.available:
|
|
181
|
+
root.add(
|
|
182
|
+
toga.Label(f"{scope.title()} secret saving: {status.issue}")
|
|
183
|
+
)
|
|
184
|
+
root.add(
|
|
185
|
+
toga.Button(
|
|
186
|
+
f"Repair {scope} secret file permissions",
|
|
187
|
+
on_press=lambda _widget: self._repair_secrets(scope),
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
def _add_field(self, root: toga.Box, item: FieldInspection) -> None:
|
|
192
|
+
"""Show field documentation, effective source, and edit actions."""
|
|
193
|
+
spec = item.field
|
|
194
|
+
key = item.owner.env_key(spec.name)
|
|
195
|
+
root.add(toga.Label(spec.title or key))
|
|
196
|
+
if spec.explanation_short:
|
|
197
|
+
root.add(toga.Label(spec.explanation_short))
|
|
198
|
+
if (
|
|
199
|
+
spec.explanation_long
|
|
200
|
+
and spec.explanation_long != spec.explanation_short
|
|
201
|
+
):
|
|
202
|
+
root.add(toga.Label(spec.explanation_long))
|
|
203
|
+
if not item.active:
|
|
204
|
+
root.add(toga.Label("Select storage to edit this setting."))
|
|
205
|
+
return
|
|
206
|
+
source = (
|
|
207
|
+
item.origin.path.name if item.origin.path else item.origin.origin
|
|
208
|
+
)
|
|
209
|
+
root.add(toga.Label(f"Effective source: {source}"))
|
|
210
|
+
if item.issue:
|
|
211
|
+
root.add(toga.Label(item.issue))
|
|
212
|
+
if spec.secret:
|
|
213
|
+
root.add(
|
|
214
|
+
toga.Label(
|
|
215
|
+
"A value is set." if item.value else "No value is set."
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
else:
|
|
219
|
+
root.add(toga.Label(f"Effective value: {item.display_value}"))
|
|
220
|
+
if not spec.editable:
|
|
221
|
+
return
|
|
222
|
+
if spec.secret:
|
|
223
|
+
control: toga.TextInput | toga.PasswordInput | toga.Selection = (
|
|
224
|
+
toga.PasswordInput(placeholder="Enter a replacement value")
|
|
225
|
+
)
|
|
226
|
+
elif spec.choices:
|
|
227
|
+
control = toga.Selection(items=["", *spec.choices])
|
|
228
|
+
else:
|
|
229
|
+
control = toga.TextInput(placeholder="Enter a new value")
|
|
230
|
+
root.add(control)
|
|
231
|
+
available = self._scope_select is not None
|
|
232
|
+
root.add(
|
|
233
|
+
toga.Row(
|
|
234
|
+
children=[
|
|
235
|
+
toga.Button(
|
|
236
|
+
"Save",
|
|
237
|
+
enabled=available,
|
|
238
|
+
on_press=lambda _widget: self._save(key, control),
|
|
239
|
+
),
|
|
240
|
+
toga.Button(
|
|
241
|
+
"Clear saved value",
|
|
242
|
+
enabled=available,
|
|
243
|
+
on_press=lambda _widget: self._clear(key),
|
|
244
|
+
),
|
|
245
|
+
]
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def _selected_scope(self) -> WriteScope:
|
|
250
|
+
"""Return the edit target currently shown above the fields."""
|
|
251
|
+
if self._scope_select is None or self._scope_select.value not in (
|
|
252
|
+
"user",
|
|
253
|
+
"storage",
|
|
254
|
+
):
|
|
255
|
+
raise ValueError("Run setup before saving settings.")
|
|
256
|
+
return "user" if self._scope_select.value == "user" else "storage"
|
|
257
|
+
|
|
258
|
+
def _save(
|
|
259
|
+
self,
|
|
260
|
+
key: str,
|
|
261
|
+
control: toga.TextInput | toga.PasswordInput | toga.Selection,
|
|
262
|
+
) -> None:
|
|
263
|
+
"""Preview and apply one field edit through the shared manager."""
|
|
264
|
+
value = control.value
|
|
265
|
+
if value is None or str(value) == "":
|
|
266
|
+
self._show_error("Enter a value, or use Clear saved value.")
|
|
267
|
+
return
|
|
268
|
+
try:
|
|
269
|
+
plan = self.manager.plan_update(
|
|
270
|
+
key, str(value), scope=self._selected_scope()
|
|
271
|
+
)
|
|
272
|
+
self.manager.preview_edit(plan)
|
|
273
|
+
self.manager.apply_edit(plan)
|
|
274
|
+
except (OSError, ValueError) as exc:
|
|
275
|
+
if isinstance(control, toga.PasswordInput):
|
|
276
|
+
self._show_error(
|
|
277
|
+
f"Could not save {key}. Check its value and secret file permissions."
|
|
278
|
+
)
|
|
279
|
+
else:
|
|
280
|
+
self._show_error(str(exc))
|
|
281
|
+
return
|
|
282
|
+
self._error = ""
|
|
283
|
+
self.refresh()
|
|
284
|
+
|
|
285
|
+
def _clear(self, key: str) -> None:
|
|
286
|
+
"""Remove one saved override while preserving higher-priority layers."""
|
|
287
|
+
try:
|
|
288
|
+
plan = self.manager.plan_removal(key, scope=self._selected_scope())
|
|
289
|
+
if plan is not None:
|
|
290
|
+
self.manager.preview_edit(plan)
|
|
291
|
+
self.manager.apply_edit(plan)
|
|
292
|
+
except (OSError, ValueError) as exc:
|
|
293
|
+
self._show_error(str(exc))
|
|
294
|
+
return
|
|
295
|
+
self._error = ""
|
|
296
|
+
self.refresh()
|
|
297
|
+
|
|
298
|
+
def _setup_user(self, _widget: toga.Widget) -> None:
|
|
299
|
+
"""Create the declared user layer when storage is not enabled."""
|
|
300
|
+
try:
|
|
301
|
+
self.manager.setup_user_dotenv()
|
|
302
|
+
except (OSError, ValueError) as exc:
|
|
303
|
+
self._show_error(str(exc))
|
|
304
|
+
return
|
|
305
|
+
self._error = ""
|
|
306
|
+
self.refresh()
|
|
307
|
+
|
|
308
|
+
async def _choose_storage(self, name_input: toga.TextInput) -> None:
|
|
309
|
+
"""Ask the host window for a directory, then initialize it."""
|
|
310
|
+
root = await self.window.dialog(
|
|
311
|
+
toga.SelectFolderDialog("Choose storage folder")
|
|
312
|
+
)
|
|
313
|
+
if root is None:
|
|
314
|
+
return
|
|
315
|
+
name = (name_input.value or "").strip()
|
|
316
|
+
if not name:
|
|
317
|
+
self._show_error("Enter a storage name first.")
|
|
318
|
+
return
|
|
319
|
+
try:
|
|
320
|
+
if self.manager.registry().storages:
|
|
321
|
+
self.manager.register_storage(name, Path(root))
|
|
322
|
+
self.manager.select_storage(name)
|
|
323
|
+
else:
|
|
324
|
+
self.manager.setup(storage_root=Path(root), storage_name=name)
|
|
325
|
+
except (OSError, ValueError) as exc:
|
|
326
|
+
self._show_error(str(exc))
|
|
327
|
+
return
|
|
328
|
+
self._error = ""
|
|
329
|
+
self.refresh()
|
|
330
|
+
|
|
331
|
+
def _select_storage(self, selected: toga.Selection) -> None:
|
|
332
|
+
"""Save the selected registry name for subsequent launches."""
|
|
333
|
+
if selected.value is None:
|
|
334
|
+
return
|
|
335
|
+
try:
|
|
336
|
+
self.manager.select_storage(str(selected.value))
|
|
337
|
+
except (OSError, ValueError) as exc:
|
|
338
|
+
self._show_error(str(exc))
|
|
339
|
+
return
|
|
340
|
+
self._error = ""
|
|
341
|
+
self.refresh()
|
|
342
|
+
|
|
343
|
+
def _show_error(self, message: str) -> None:
|
|
344
|
+
"""Show an actionable error without logging field input values."""
|
|
345
|
+
self._error = message
|
|
346
|
+
self.refresh()
|
|
347
|
+
|
|
348
|
+
def _migrate_secrets(self, scope: WriteScope) -> None:
|
|
349
|
+
"""Move legacy values after a visible, scope-specific button press."""
|
|
350
|
+
try:
|
|
351
|
+
self.manager.apply_secret_migration(
|
|
352
|
+
self.manager.plan_secret_migration(scope)
|
|
353
|
+
)
|
|
354
|
+
except (OSError, ValueError) as exc:
|
|
355
|
+
self._show_error(str(exc))
|
|
356
|
+
return
|
|
357
|
+
self._error = ""
|
|
358
|
+
self.refresh()
|
|
359
|
+
|
|
360
|
+
def _repair_secrets(self, scope: WriteScope) -> None:
|
|
361
|
+
"""Change permissions only after the user presses Repair."""
|
|
362
|
+
try:
|
|
363
|
+
self.manager.repair_secret_permissions(scope)
|
|
364
|
+
except (OSError, ValueError) as exc:
|
|
365
|
+
self._show_error(str(exc))
|
|
366
|
+
return
|
|
367
|
+
self._error = ""
|
|
368
|
+
self.refresh()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: apprc-gui
|
|
3
|
+
Version: 0.26.0
|
|
4
|
+
Summary: Toga configuration view for AppRC applications.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: apprc-core==0.26.0
|
|
10
|
+
Requires-Dist: toga-core<0.6,>=0.5.4
|
|
11
|
+
Requires-Dist: toga-winforms<0.6,>=0.5.4; sys_platform == "win32"
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# AppRC GUI
|
|
15
|
+
|
|
16
|
+
`apprc-gui` provides a Toga configuration view for applications using
|
|
17
|
+
`apprc-core`. The host application creates one `ConfigManager` from its `AppRC`
|
|
18
|
+
declaration and places `apprc_gui.ConfigView` in its own Toga window.
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
import toga
|
|
22
|
+
from apprc_gui import ConfigView
|
|
23
|
+
|
|
24
|
+
from my_app.config import MyRC
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MyDesktopApp(toga.App):
|
|
28
|
+
def startup(self):
|
|
29
|
+
window = toga.MainWindow(title="My App")
|
|
30
|
+
self.main_window = window
|
|
31
|
+
window.content = ConfigView(MyRC.manage(), window).widget
|
|
32
|
+
window.show()
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Import the config sections registered on `MyRC` before creating the view.
|
|
36
|
+
The view can open before required settings or storage are ready. It uses
|
|
37
|
+
AppRC's manager to set up files, select storage, and edit saved fields. The
|
|
38
|
+
application retains ownership of the window and decides when to start its own
|
|
39
|
+
work. See the [GUI guide](https://github.com/HisQu/apprc/blob/main/docs/How-To-User-Guides.md#add-a-native-settings-window)
|
|
40
|
+
for an application callback and installer instructions.
|
|
41
|
+
|
|
42
|
+
On Windows, installation includes the WinForms backend. On other platforms,
|
|
43
|
+
install a supported Toga backend separately before opening the window.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/apprc_gui/__init__.py
|
|
5
|
+
src/apprc_gui/view.py
|
|
6
|
+
src/apprc_gui.egg-info/PKG-INFO
|
|
7
|
+
src/apprc_gui.egg-info/SOURCES.txt
|
|
8
|
+
src/apprc_gui.egg-info/dependency_links.txt
|
|
9
|
+
src/apprc_gui.egg-info/requires.txt
|
|
10
|
+
src/apprc_gui.egg-info/top_level.txt
|
|
11
|
+
tests/test_view.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
apprc_gui
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Headless checks for the public native configuration view."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import apprc as rc
|
|
6
|
+
import toga
|
|
7
|
+
|
|
8
|
+
from apprc_gui import ConfigView
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_view_handles_first_run_and_secret_edit(
|
|
12
|
+
tmp_path: Path, monkeypatch
|
|
13
|
+
) -> None:
|
|
14
|
+
"""Keep setup usable before storage exists and route secret edits privately."""
|
|
15
|
+
monkeypatch.setenv("TOGA_BACKEND", "toga_dummy")
|
|
16
|
+
app = rc.AppRC(
|
|
17
|
+
app_id="gui-check",
|
|
18
|
+
user_dotenv=rc.UserDotenv(),
|
|
19
|
+
storage=rc.Storage(selector_env_key="GUI_CHECK_STORAGE"),
|
|
20
|
+
apprc_dir=tmp_path / "config",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
@app.config("client", prefix="GUI_CHECK_")
|
|
24
|
+
class Client(rc.Config):
|
|
25
|
+
token: str = rc.field("GUI_CHECK_TOKEN", default="", secret=True)
|
|
26
|
+
|
|
27
|
+
manager = app.manage(
|
|
28
|
+
rc.ResolveOptions(storage_required=True), environment={}
|
|
29
|
+
)
|
|
30
|
+
desktop = toga.App("GUI check", "org.example.gui-check")
|
|
31
|
+
window = toga.MainWindow()
|
|
32
|
+
view = ConfigView(manager, window)
|
|
33
|
+
assert not view.inspection.ready
|
|
34
|
+
|
|
35
|
+
manager.setup(storage_root=tmp_path / "storage")
|
|
36
|
+
view.refresh()
|
|
37
|
+
assert view.inspection.ready
|
|
38
|
+
assert view._scope_select is not None
|
|
39
|
+
view._scope_select.value = "storage"
|
|
40
|
+
view._save("GUI_CHECK_TOKEN", toga.PasswordInput(value="private-value"))
|
|
41
|
+
assert view._error == ""
|
|
42
|
+
assert view._scope_select is not None
|
|
43
|
+
assert view._scope_select.value == "storage"
|
|
44
|
+
assert manager.resolve().build(Client).token == "private-value"
|
|
45
|
+
assert "private-value" not in manager.writable_path("storage").read_text()
|
|
46
|
+
assert "private-value" not in str(view.widget.content)
|
|
47
|
+
|
|
48
|
+
manager.registry_path.write_text("invalid = [", encoding="utf-8")
|
|
49
|
+
view.refresh()
|
|
50
|
+
assert not view.inspection.ready
|
|
51
|
+
assert view.widget.content is not None
|
|
52
|
+
desktop.exit()
|