apprc-gui 0.26.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Toga views built on AppRC's noninteractive configuration manager."""
2
+
3
+ from apprc_gui.view import ConfigView
4
+
5
+ __all__ = ["ConfigView"]
apprc_gui/view.py ADDED
@@ -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,7 @@
1
+ apprc_gui/__init__.py,sha256=aNz4nd1_Iu767ASCzrgihDPbXx5l4TtgxCqwWRyAGws,137
2
+ apprc_gui/view.py,sha256=4pwZky-ChAiTLm1A7VyvnH-_8zFWrrK1Nb8loTmf0-o,13634
3
+ apprc_gui-0.26.0.dist-info/licenses/LICENSE,sha256=Bgl7AfoshYsZw99Y2PIkZHatOIukhbrZMWAhU0WTveo,1064
4
+ apprc_gui-0.26.0.dist-info/METADATA,sha256=UpGJx3MXz_69dJCVNjN8JhD343bCu76ADmkE-5HeQXw,1562
5
+ apprc_gui-0.26.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
6
+ apprc_gui-0.26.0.dist-info/top_level.txt,sha256=cxJfruO6tJMWlQpa1MYaiANLl4OIsFS9c08PMk9SRKU,10
7
+ apprc_gui-0.26.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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 @@
1
+ apprc_gui