odsbox-pilot 1.0.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.
@@ -0,0 +1,3 @@
1
+ """ASAM ODS Pilot — desktop query tool for ASAM ODS servers."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,23 @@
1
+ """Entry point: python -m odsbox_pilot"""
2
+
3
+ import sys
4
+
5
+
6
+ def main() -> None:
7
+ try:
8
+ from odsbox_pilot.app import OdsPilotApp
9
+ except ImportError as exc:
10
+ print(
11
+ "wxPython is required to run odsbox-pilot.\n"
12
+ "Install it with: pip install odsbox-pilot[gui]",
13
+ file=sys.stderr,
14
+ )
15
+ raise SystemExit(1) from exc
16
+
17
+ app = OdsPilotApp()
18
+ app.MainLoop()
19
+ sys.exit(0)
20
+
21
+
22
+ if __name__ == "__main__":
23
+ main()
odsbox_pilot/app.py ADDED
@@ -0,0 +1,77 @@
1
+ """OdsPilotApp: application bootstrap."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+
7
+ import wx # type: ignore[import-untyped]
8
+
9
+ from odsbox_pilot.connection.manager import ServerConfigManager
10
+ from odsbox_pilot.connection.server_list_dialog import ServerListDialog
11
+
12
+
13
+ class OdsPilotApp(wx.App):
14
+ def OnInit(self) -> bool: # noqa: N802
15
+ manager = ServerConfigManager()
16
+ dlg = ServerListDialog(None, manager)
17
+
18
+ while True:
19
+ result = dlg.ShowModal()
20
+ if result != wx.ID_OK:
21
+ dlg.Destroy()
22
+ return False # user closed the dialog — exit
23
+
24
+ config = dlg.selected_config
25
+ if config is None:
26
+ dlg.Destroy()
27
+ return False
28
+
29
+ # Try to connect (opens ConnectDialog flow via Save & Connect path,
30
+ # but here the user clicked "Connect" from the list which means the
31
+ # config already exists — we just need to open ConnectDialog in
32
+ # connect-only mode to resolve the secret and build a ConI).
33
+ con_i = self._connect(dlg, manager, config)
34
+ if con_i is None:
35
+ # Connection cancelled or failed — go back to server list
36
+ continue
37
+
38
+ dlg.Destroy()
39
+ self._open_main_frame(con_i, config.name)
40
+ return True
41
+
42
+ # ------------------------------------------------------------------
43
+ # Helpers
44
+ # ------------------------------------------------------------------
45
+
46
+ def _connect(self, parent, manager: ServerConfigManager, config): # type: ignore[return]
47
+ """Connect using saved credentials; open ConnectDialog only on failure."""
48
+ from odsbox_pilot.connection.connect_dialog import ConnectDialog, do_connect
49
+
50
+ secret = manager.load_secret(config) or ""
51
+ try:
52
+ wx.BeginBusyCursor()
53
+ con_i = do_connect(config, secret)
54
+ return con_i
55
+ except Exception as exc:
56
+ wx.MessageBox(
57
+ f"Connection failed:\n\n{exc}\n\nPlease check your settings.",
58
+ "Connection Error",
59
+ wx.OK | wx.ICON_ERROR,
60
+ parent,
61
+ )
62
+ # Fall back to edit dialog so user can fix credentials
63
+ connect_dlg = ConnectDialog(parent, manager, config=config)
64
+ result = connect_dlg.ShowModal()
65
+ con_i = connect_dlg.con_i if result == wx.ID_OK else None
66
+ connect_dlg.Destroy()
67
+ return con_i
68
+ finally:
69
+ with contextlib.suppress(Exception):
70
+ wx.EndBusyCursor()
71
+
72
+ def _open_main_frame(self, con_i, server_name: str) -> None:
73
+ from odsbox_pilot.query.main_frame import MainFrame
74
+
75
+ frame = MainFrame(con_i, server_name)
76
+ frame.Show()
77
+ self.SetTopWindow(frame)
File without changes
@@ -0,0 +1,401 @@
1
+ """ConnectDialog: create or edit an ODS server config.
2
+
3
+ Three tabs: Basic (username/password), M2M (client credentials), OIDC.
4
+ On OK the config is saved and, if the user clicked "Save & Connect",
5
+ the returned ConI is available via the `con_i` property.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import uuid
12
+
13
+ import wx # type: ignore[import-untyped]
14
+
15
+ from odsbox_pilot.connection.manager import ServerConfigManager
16
+ from odsbox_pilot.models import AuthType, ServerConfig
17
+
18
+
19
+ def do_connect(config: ServerConfig, secret: str): # type: ignore[return]
20
+ """Create a live ConI from *config* + *secret* without any UI."""
21
+ from odsbox.con_i_factory import ConIFactory # type: ignore[import-untyped]
22
+
23
+ if config.auth_type == AuthType.BASIC:
24
+ return ConIFactory.basic(
25
+ url=config.url,
26
+ username=config.username,
27
+ password=secret,
28
+ verify_certificate=config.verify_certificate,
29
+ )
30
+ elif config.auth_type == AuthType.M2M:
31
+ return ConIFactory.m2m(
32
+ url=config.url,
33
+ token_endpoint=config.token_endpoint,
34
+ client_id=config.client_id,
35
+ client_secret=secret,
36
+ scope=config.scope or None,
37
+ verify_certificate=config.verify_certificate,
38
+ )
39
+ else: # OIDC
40
+ return ConIFactory.oidc(
41
+ url=config.url,
42
+ client_id=config.client_id,
43
+ redirect_uri=config.redirect_uri,
44
+ redirect_url_allow_insecure=config.redirect_url_allow_insecure,
45
+ webfinger_path_prefix=config.webfinger_path_prefix,
46
+ verify_certificate=config.verify_certificate,
47
+ )
48
+
49
+
50
+ class ConnectDialog(wx.Dialog):
51
+ """Dialog to create or edit an ODS server connection config."""
52
+
53
+ def __init__(
54
+ self,
55
+ parent: wx.Window | None,
56
+ manager: ServerConfigManager,
57
+ config: ServerConfig | None,
58
+ ) -> None:
59
+ title = "Edit Server" if config else "New Server"
60
+ super().__init__(
61
+ parent,
62
+ title=title,
63
+ size=(480, 480),
64
+ style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER,
65
+ )
66
+ self._manager = manager
67
+ self._original_config = config
68
+ self._con_i = None # set when "Save & Connect" succeeds
69
+
70
+ self._build_ui(config)
71
+ self._populate(config)
72
+ self.Centre()
73
+
74
+ # ------------------------------------------------------------------
75
+ # Public result
76
+ # ------------------------------------------------------------------
77
+
78
+ @property
79
+ def con_i(self): # type: ignore[return]
80
+ """The live ConI instance after a successful 'Save & Connect'."""
81
+ return self._con_i
82
+
83
+ # ------------------------------------------------------------------
84
+ # UI
85
+ # ------------------------------------------------------------------
86
+
87
+ def _build_ui(self, config: ServerConfig | None) -> None:
88
+ panel = wx.Panel(self)
89
+ vbox = wx.BoxSizer(wx.VERTICAL)
90
+
91
+ # --- Common fields (name + URL) ---
92
+ grid = wx.FlexGridSizer(cols=2, hgap=8, vgap=6)
93
+ grid.AddGrowableCol(1)
94
+
95
+ grid.Add(wx.StaticText(panel, label="Name:"), flag=wx.ALIGN_CENTER_VERTICAL)
96
+ self._txt_name = wx.TextCtrl(panel, size=(300, -1))
97
+ grid.Add(self._txt_name, flag=wx.EXPAND)
98
+
99
+ grid.Add(wx.StaticText(panel, label="URL:"), flag=wx.ALIGN_CENTER_VERTICAL)
100
+ self._txt_url = wx.TextCtrl(panel, size=(300, -1))
101
+ grid.Add(self._txt_url, flag=wx.EXPAND)
102
+
103
+ self._chk_verify = wx.CheckBox(panel, label="Verify TLS certificate")
104
+ self._chk_verify.SetValue(True)
105
+ grid.Add((0, 0))
106
+ grid.Add(self._chk_verify)
107
+
108
+ vbox.Add(grid, flag=wx.EXPAND | wx.ALL, border=10)
109
+
110
+ # --- Auth notebook ---
111
+ self._notebook = wx.Notebook(panel)
112
+ self._page_basic = self._build_basic_page()
113
+ self._page_m2m = self._build_m2m_page()
114
+ self._page_oidc = self._build_oidc_page()
115
+ self._notebook.AddPage(self._page_basic, "Basic")
116
+ self._notebook.AddPage(self._page_m2m, "M2M")
117
+ self._notebook.AddPage(self._page_oidc, "OIDC")
118
+ vbox.Add(self._notebook, proportion=1, flag=wx.EXPAND | wx.LEFT | wx.RIGHT, border=10)
119
+
120
+ # --- Buttons ---
121
+ btn_sizer = wx.BoxSizer(wx.HORIZONTAL)
122
+ self._btn_save_connect = wx.Button(panel, label="Save && Connect")
123
+ self._btn_save_connect.SetDefault()
124
+ btn_save = wx.Button(panel, label="Save only")
125
+ btn_cancel = wx.Button(panel, wx.ID_CANCEL, label="Cancel")
126
+
127
+ btn_sizer.AddStretchSpacer()
128
+ btn_sizer.Add(btn_cancel, flag=wx.RIGHT, border=4)
129
+ btn_sizer.Add(btn_save, flag=wx.RIGHT, border=4)
130
+ btn_sizer.Add(self._btn_save_connect)
131
+
132
+ vbox.Add(btn_sizer, flag=wx.EXPAND | wx.ALL, border=10)
133
+ panel.SetSizer(vbox)
134
+
135
+ self._btn_save_connect.Bind(wx.EVT_BUTTON, self._on_save_connect)
136
+ btn_save.Bind(wx.EVT_BUTTON, self._on_save_only)
137
+
138
+ def _build_basic_page(self) -> wx.Panel:
139
+ page = wx.Panel(self._notebook)
140
+ grid = wx.FlexGridSizer(cols=2, hgap=8, vgap=6)
141
+ grid.AddGrowableCol(1)
142
+
143
+ grid.Add(wx.StaticText(page, label="Username:"), flag=wx.ALIGN_CENTER_VERTICAL)
144
+ self._txt_basic_user = wx.TextCtrl(page)
145
+ grid.Add(self._txt_basic_user, flag=wx.EXPAND)
146
+
147
+ grid.Add(wx.StaticText(page, label="Password:"), flag=wx.ALIGN_CENTER_VERTICAL)
148
+ self._txt_basic_pass = wx.TextCtrl(page, style=wx.TE_PASSWORD)
149
+ grid.Add(self._txt_basic_pass, flag=wx.EXPAND)
150
+
151
+ page.SetSizer(self._wrap_page(page, grid))
152
+ return page
153
+
154
+ def _build_m2m_page(self) -> wx.Panel:
155
+ page = wx.Panel(self._notebook)
156
+ grid = wx.FlexGridSizer(cols=2, hgap=8, vgap=6)
157
+ grid.AddGrowableCol(1)
158
+
159
+ grid.Add(wx.StaticText(page, label="Token endpoint:"), flag=wx.ALIGN_CENTER_VERTICAL)
160
+ self._txt_m2m_token_ep = wx.TextCtrl(page)
161
+ grid.Add(self._txt_m2m_token_ep, flag=wx.EXPAND)
162
+
163
+ grid.Add(wx.StaticText(page, label="Client ID:"), flag=wx.ALIGN_CENTER_VERTICAL)
164
+ self._txt_m2m_client_id = wx.TextCtrl(page)
165
+ grid.Add(self._txt_m2m_client_id, flag=wx.EXPAND)
166
+
167
+ grid.Add(wx.StaticText(page, label="Client secret:"), flag=wx.ALIGN_CENTER_VERTICAL)
168
+ self._txt_m2m_secret = wx.TextCtrl(page, style=wx.TE_PASSWORD)
169
+ grid.Add(self._txt_m2m_secret, flag=wx.EXPAND)
170
+
171
+ grid.Add(
172
+ wx.StaticText(page, label="Scope (space-separated):"),
173
+ flag=wx.ALIGN_CENTER_VERTICAL,
174
+ )
175
+ self._txt_m2m_scope = wx.TextCtrl(page)
176
+ grid.Add(self._txt_m2m_scope, flag=wx.EXPAND)
177
+
178
+ page.SetSizer(self._wrap_page(page, grid))
179
+ return page
180
+
181
+ def _build_oidc_page(self) -> wx.Panel:
182
+ page = wx.Panel(self._notebook)
183
+ grid = wx.FlexGridSizer(cols=2, hgap=8, vgap=6)
184
+ grid.AddGrowableCol(1)
185
+
186
+ grid.Add(wx.StaticText(page, label="Client ID:"), flag=wx.ALIGN_CENTER_VERTICAL)
187
+ self._txt_oidc_client_id = wx.TextCtrl(page)
188
+ grid.Add(self._txt_oidc_client_id, flag=wx.EXPAND)
189
+
190
+ grid.Add(wx.StaticText(page, label="Redirect URI:"), flag=wx.ALIGN_CENTER_VERTICAL)
191
+ self._txt_oidc_redirect = wx.TextCtrl(page)
192
+ grid.Add(self._txt_oidc_redirect, flag=wx.EXPAND)
193
+
194
+ grid.Add(wx.StaticText(page, label="WebFinger prefix:"), flag=wx.ALIGN_CENTER_VERTICAL)
195
+ self._txt_oidc_webfinger = wx.TextCtrl(page)
196
+ grid.Add(self._txt_oidc_webfinger, flag=wx.EXPAND)
197
+
198
+ self._chk_oidc_insecure = wx.CheckBox(page, label="Allow insecure redirect (localhost)")
199
+ self._chk_oidc_insecure.SetValue(True)
200
+ grid.Add((0, 0))
201
+ grid.Add(self._chk_oidc_insecure)
202
+
203
+ note = wx.StaticText(
204
+ page,
205
+ label="OIDC re-authenticates via your browser on each app launch.",
206
+ style=wx.ST_ELLIPSIZE_END,
207
+ )
208
+ note.SetForegroundColour(wx.Colour(100, 100, 100))
209
+ grid.Add((0, 0))
210
+ grid.Add(note, flag=wx.EXPAND)
211
+
212
+ page.SetSizer(self._wrap_page(page, grid))
213
+ return page
214
+
215
+ @staticmethod
216
+ def _wrap_page(page: wx.Panel, inner: wx.Sizer) -> wx.BoxSizer:
217
+ outer = wx.BoxSizer(wx.VERTICAL)
218
+ outer.Add(inner, proportion=1, flag=wx.EXPAND | wx.ALL, border=10)
219
+ return outer
220
+
221
+ # ------------------------------------------------------------------
222
+ # Populate from existing config
223
+ # ------------------------------------------------------------------
224
+
225
+ def _populate(self, config: ServerConfig | None) -> None:
226
+ if config is None:
227
+ self._txt_oidc_redirect.SetValue("http://127.0.0.1:12345")
228
+ return
229
+
230
+ self._txt_name.SetValue(config.name)
231
+ self._txt_url.SetValue(config.url)
232
+ self._chk_verify.SetValue(config.verify_certificate)
233
+
234
+ if config.auth_type == AuthType.BASIC:
235
+ self._notebook.SetSelection(0)
236
+ self._txt_basic_user.SetValue(config.username)
237
+ secret = self._manager.load_secret(config)
238
+ if secret:
239
+ self._txt_basic_pass.SetValue(secret)
240
+
241
+ elif config.auth_type == AuthType.M2M:
242
+ self._notebook.SetSelection(1)
243
+ self._txt_m2m_token_ep.SetValue(config.token_endpoint)
244
+ self._txt_m2m_client_id.SetValue(config.client_id)
245
+ self._txt_m2m_scope.SetValue(" ".join(config.scope))
246
+ secret = self._manager.load_secret(config)
247
+ if secret:
248
+ self._txt_m2m_secret.SetValue(secret)
249
+
250
+ elif config.auth_type == AuthType.OIDC:
251
+ self._notebook.SetSelection(2)
252
+ self._txt_oidc_client_id.SetValue(config.client_id)
253
+ self._txt_oidc_redirect.SetValue(config.redirect_uri)
254
+ self._txt_oidc_webfinger.SetValue(config.webfinger_path_prefix)
255
+ self._chk_oidc_insecure.SetValue(config.redirect_url_allow_insecure)
256
+
257
+ # ------------------------------------------------------------------
258
+ # Build ServerConfig from current form values
259
+ # ------------------------------------------------------------------
260
+
261
+ def _build_config(self) -> tuple[ServerConfig, str] | None:
262
+ """Return (config, secret) or None if validation fails."""
263
+ name = self._txt_name.GetValue().strip()
264
+ url = self._txt_url.GetValue().strip()
265
+ if not name:
266
+ wx.MessageBox("Name is required.", "Validation", wx.OK | wx.ICON_WARNING, self)
267
+ return None
268
+ if not url:
269
+ wx.MessageBox("URL is required.", "Validation", wx.OK | wx.ICON_WARNING, self)
270
+ return None
271
+
272
+ tab = self._notebook.GetSelection()
273
+ config_id = self._original_config.id if self._original_config else str(uuid.uuid4())
274
+ verify = self._chk_verify.GetValue()
275
+
276
+ if tab == 0: # Basic
277
+ username = self._txt_basic_user.GetValue().strip()
278
+ password = self._txt_basic_pass.GetValue()
279
+ if not username:
280
+ wx.MessageBox("Username is required.", "Validation", wx.OK | wx.ICON_WARNING, self)
281
+ return None
282
+ cfg = ServerConfig(
283
+ id=config_id,
284
+ name=name,
285
+ url=url,
286
+ auth_type=AuthType.BASIC,
287
+ username=username,
288
+ verify_certificate=verify,
289
+ )
290
+ return cfg, password
291
+
292
+ elif tab == 1: # M2M
293
+ token_ep = self._txt_m2m_token_ep.GetValue().strip()
294
+ client_id = self._txt_m2m_client_id.GetValue().strip()
295
+ secret = self._txt_m2m_secret.GetValue()
296
+ scope_str = self._txt_m2m_scope.GetValue().strip()
297
+ scope = scope_str.split() if scope_str else []
298
+ if not token_ep or not client_id:
299
+ wx.MessageBox(
300
+ "Token endpoint and Client ID are required.",
301
+ "Validation",
302
+ wx.OK | wx.ICON_WARNING,
303
+ self,
304
+ )
305
+ return None
306
+ cfg = ServerConfig(
307
+ id=config_id,
308
+ name=name,
309
+ url=url,
310
+ auth_type=AuthType.M2M,
311
+ token_endpoint=token_ep,
312
+ client_id=client_id,
313
+ scope=scope,
314
+ verify_certificate=verify,
315
+ )
316
+ return cfg, secret
317
+
318
+ else: # OIDC
319
+ client_id = self._txt_oidc_client_id.GetValue().strip()
320
+ redirect = self._txt_oidc_redirect.GetValue().strip()
321
+ webfinger = self._txt_oidc_webfinger.GetValue().strip()
322
+ insecure = self._chk_oidc_insecure.GetValue()
323
+ if not client_id or not redirect:
324
+ wx.MessageBox(
325
+ "Client ID and Redirect URI are required.",
326
+ "Validation",
327
+ wx.OK | wx.ICON_WARNING,
328
+ self,
329
+ )
330
+ return None
331
+ cfg = ServerConfig(
332
+ id=config_id,
333
+ name=name,
334
+ url=url,
335
+ auth_type=AuthType.OIDC,
336
+ client_id=client_id,
337
+ redirect_uri=redirect,
338
+ webfinger_path_prefix=webfinger,
339
+ redirect_url_allow_insecure=insecure,
340
+ verify_certificate=verify,
341
+ )
342
+ return cfg, "" # no secret stored for OIDC
343
+
344
+ return None # unreachable
345
+
346
+ # ------------------------------------------------------------------
347
+ # Save helper
348
+ # ------------------------------------------------------------------
349
+
350
+ def _save_config(self, config: ServerConfig, secret: str) -> None:
351
+ if self._original_config:
352
+ self._manager.update(config)
353
+ else:
354
+ self._manager.add(config)
355
+ if secret:
356
+ self._manager.save_secret(config, secret)
357
+
358
+ # ------------------------------------------------------------------
359
+ # Connect helper
360
+ # ------------------------------------------------------------------
361
+
362
+ def _do_connect(self, config: ServerConfig, secret: str): # type: ignore[return]
363
+ return do_connect(config, secret)
364
+
365
+ # ------------------------------------------------------------------
366
+ # Button handlers
367
+ # ------------------------------------------------------------------
368
+
369
+ def _on_save_only(self, _event: wx.Event) -> None:
370
+ result = self._build_config()
371
+ if result is None:
372
+ return
373
+ config, secret = result
374
+ self._save_config(config, secret)
375
+ self.EndModal(wx.ID_OK)
376
+
377
+ def _on_save_connect(self, _event: wx.Event) -> None:
378
+ result = self._build_config()
379
+ if result is None:
380
+ return
381
+ config, secret = result
382
+ self._save_config(config, secret)
383
+
384
+ try:
385
+ wx.BeginBusyCursor()
386
+ con_i = self._do_connect(config, secret)
387
+ except Exception as exc:
388
+ wx.EndBusyCursor()
389
+ wx.MessageBox(
390
+ f"Connection failed:\n\n{exc}",
391
+ "Connection Error",
392
+ wx.OK | wx.ICON_ERROR,
393
+ self,
394
+ )
395
+ return
396
+ finally:
397
+ with contextlib.suppress(Exception):
398
+ wx.EndBusyCursor()
399
+
400
+ self._con_i = con_i
401
+ self.EndModal(wx.ID_OK)
@@ -0,0 +1,107 @@
1
+ """ServerConfigManager: CRUD for saved ODS server configs.
2
+
3
+ Non-secret fields are persisted to ~/.ods-pilot/servers.json.
4
+ Secrets (passwords, client secrets) are stored in the OS keyring under
5
+ service "ods-pilot".
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import json
12
+ import uuid
13
+ from pathlib import Path
14
+
15
+ import keyring
16
+
17
+ from odsbox_pilot.models import SERVERS_FILE, ServerConfig
18
+
19
+ _KEYRING_SERVICE = "ods-pilot"
20
+
21
+
22
+ class ServerConfigManager:
23
+ """Load, save, and manage ODS server configurations."""
24
+
25
+ def __init__(self, path: Path = SERVERS_FILE) -> None:
26
+ self._path = path
27
+ self._configs: list[ServerConfig] = []
28
+ self._load()
29
+
30
+ # ------------------------------------------------------------------
31
+ # Public API
32
+ # ------------------------------------------------------------------
33
+
34
+ @property
35
+ def configs(self) -> list[ServerConfig]:
36
+ return list(self._configs)
37
+
38
+ def add(self, config: ServerConfig) -> None:
39
+ """Add a new server config. Raises ValueError if id already exists."""
40
+ if any(c.id == config.id for c in self._configs):
41
+ raise ValueError(f"Config with id {config.id!r} already exists.")
42
+ self._configs.append(config)
43
+ self._save()
44
+
45
+ def update(self, config: ServerConfig) -> None:
46
+ """Replace an existing config by id. Raises KeyError if not found."""
47
+ for i, c in enumerate(self._configs):
48
+ if c.id == config.id:
49
+ self._configs[i] = config
50
+ self._save()
51
+ return
52
+ raise KeyError(f"Config {config.id!r} not found.")
53
+
54
+ def remove(self, config_id: str) -> None:
55
+ """Remove a config and its associated keyring secret."""
56
+ config = self.get(config_id)
57
+ self._configs = [c for c in self._configs if c.id != config_id]
58
+ self._save()
59
+ # Best-effort cleanup of keyring secret
60
+ with contextlib.suppress(keyring.errors.PasswordDeleteError):
61
+ keyring.delete_password(_KEYRING_SERVICE, config.keyring_account)
62
+
63
+ def get(self, config_id: str) -> ServerConfig:
64
+ """Return a config by id. Raises KeyError if not found."""
65
+ for c in self._configs:
66
+ if c.id == config_id:
67
+ return c
68
+ raise KeyError(f"Config {config_id!r} not found.")
69
+
70
+ # ------------------------------------------------------------------
71
+ # Keyring helpers
72
+ # ------------------------------------------------------------------
73
+
74
+ def save_secret(self, config: ServerConfig, secret: str) -> None:
75
+ """Store the password or client secret in the OS keyring."""
76
+ keyring.set_password(_KEYRING_SERVICE, config.keyring_account, secret)
77
+
78
+ def load_secret(self, config: ServerConfig) -> str | None:
79
+ """Retrieve the password or client secret from the OS keyring."""
80
+ return keyring.get_password(_KEYRING_SERVICE, config.keyring_account)
81
+
82
+ # ------------------------------------------------------------------
83
+ # Factory helpers
84
+ # ------------------------------------------------------------------
85
+
86
+ @staticmethod
87
+ def new_id() -> str:
88
+ return str(uuid.uuid4())
89
+
90
+ # ------------------------------------------------------------------
91
+ # Persistence
92
+ # ------------------------------------------------------------------
93
+
94
+ def _load(self) -> None:
95
+ if not self._path.exists():
96
+ self._configs = []
97
+ return
98
+ try:
99
+ data = json.loads(self._path.read_text(encoding="utf-8"))
100
+ self._configs = [ServerConfig.from_dict(d) for d in data]
101
+ except Exception:
102
+ self._configs = []
103
+
104
+ def _save(self) -> None:
105
+ self._path.parent.mkdir(parents=True, exist_ok=True)
106
+ data = [c.to_dict() for c in self._configs]
107
+ self._path.write_text(json.dumps(data, indent=2), encoding="utf-8")