up-tray 0.2.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.
up_tray/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """up-tray: a Qt6 system tray replacement for the Upbound `up` CLI."""
2
+ __version__ = "0.2.0"
up_tray/__main__.py ADDED
@@ -0,0 +1,216 @@
1
+ """Entry point for `up-tray`."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import os
6
+ import signal
7
+ import sys
8
+
9
+ from PyQt6.QtCore import QLoggingCategory
10
+ from PyQt6.QtGui import QGuiApplication
11
+ from PyQt6.QtWidgets import QApplication, QMessageBox, QSystemTrayIcon
12
+
13
+ from .i18n import init_locale, tr
14
+ from .ui.tray import UpTray, _load_icon
15
+
16
+
17
+ def main() -> int:
18
+ parser = argparse.ArgumentParser(
19
+ prog="up-tray",
20
+ description="Qt6 system-tray app for Upbound Cloud + Crossplane.",
21
+ )
22
+ parser.add_argument(
23
+ "--self-test",
24
+ action="store_true",
25
+ help="Construct the app offscreen, walk the menu, exercise i18n and "
26
+ "backend imports, and exit. No network calls, no keyring writes, "
27
+ "no UI shown. Returns 0 if everything wired correctly. Useful "
28
+ "for CI matrix runs across Linux/macOS/Windows.",
29
+ )
30
+ args = parser.parse_args()
31
+
32
+ if args.self_test:
33
+ return _run_self_test()
34
+
35
+ # qt.qpa.services prints a warning on Wayland when the XDG portal
36
+ # can't find up-tray.desktop (i.e. when running from a source
37
+ # checkout before `pip install` has dropped it under share/). The
38
+ # warning is purely cosmetic — every code path falls back to a
39
+ # generic icon and continues working — but it clutters stderr.
40
+ # Filter just that one category; everything else still surfaces.
41
+ QLoggingCategory.setFilterRules("qt.qpa.services.warning=false")
42
+
43
+ app = QApplication(sys.argv)
44
+ app.setApplicationName("up-tray")
45
+ app.setApplicationDisplayName("Upbound Tray")
46
+ app.setOrganizationName("Upbound")
47
+ app.setOrganizationDomain("upbound.io")
48
+ app.setQuitOnLastWindowClosed(False)
49
+ # On Wayland the compositor uses this to associate windows with the
50
+ # installed up-tray.desktop file (and therefore its declared icon).
51
+ # On X11 setWindowIcon below is enough; on Wayland both are needed.
52
+ QGuiApplication.setDesktopFileName("up-tray")
53
+ app.setWindowIcon(_load_icon())
54
+
55
+ # Resolve locale BEFORE any UI is built so all menus/dialogs come up
56
+ # in the user's language. Order: $UP_TRAY_LOCALE env var (for testing)
57
+ # → ~/.config/up-tray/settings.json → OS locale → English.
58
+ init_locale(os.environ.get("UP_TRAY_LOCALE"))
59
+
60
+ if not QSystemTrayIcon.isSystemTrayAvailable():
61
+ QMessageBox.critical(
62
+ None,
63
+ tr("Upbound"),
64
+ tr("No system tray detected. Most KDE Plasma sessions, "
65
+ "GNOME-with-AppIndicator, and XFCE work; pure GNOME Wayland "
66
+ "without the AppIndicator extension does not."),
67
+ )
68
+ return 1
69
+
70
+ tray = UpTray(app)
71
+ tray.show()
72
+
73
+ # Allow Ctrl-C to terminate when launched from a terminal
74
+ signal.signal(signal.SIGINT, signal.SIG_DFL)
75
+ return app.exec()
76
+
77
+
78
+ def _run_self_test() -> int:
79
+ """
80
+ Smoke-test that exercises every code path that doesn't need the network.
81
+
82
+ Designed for `python -m up_tray --self-test` in CI matrix runs across
83
+ Linux/macOS/Windows. We construct the full UpTray under the offscreen
84
+ Qt platform, walk the menu, exercise every translation file, and
85
+ confirm the backend modules import cleanly. Anything that would
86
+ require a real cluster, a real Upbound session, or a writable keyring
87
+ is intentionally NOT touched.
88
+
89
+ Output is one PASS/FAIL line per check, summary at the end. Exit 0
90
+ on all-pass.
91
+ """
92
+ # Offscreen Qt platform plugin. Has to be set before QApplication
93
+ # is constructed.
94
+ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
95
+
96
+ import traceback
97
+ failures: list[str] = []
98
+ def check(name: str, fn):
99
+ try:
100
+ fn()
101
+ print(f" PASS {name}")
102
+ except Exception as e:
103
+ failures.append(f"{name}: {e}")
104
+ print(f" FAIL {name}: {e}")
105
+ traceback.print_exc()
106
+ # Flush any pending Qt deleteLater() calls so a stale parent
107
+ # widget from the previous check can't interfere with the next.
108
+ # Without this, on Linux Qt the QLabels inside TutorialDialog
109
+ # ended up referencing a UpTray QApplication state that was
110
+ # mid-teardown, giving "wrapped C/C++ object has been deleted".
111
+ try:
112
+ from PyQt6.QtWidgets import QApplication
113
+ inst = QApplication.instance()
114
+ if inst is not None:
115
+ inst.processEvents()
116
+ inst.sendPostedEvents(None, 0) # type: ignore[arg-type]
117
+ except Exception:
118
+ pass
119
+
120
+ print("up-tray self-test")
121
+ print("-----------------")
122
+
123
+ # 1. Backend modules import cleanly. Catches missing deps fast.
124
+ def _imports():
125
+ from .backend import auth, cloud, composition, crossplane, kubeconfig, marketplace, profile, worker, xpkg # noqa: F401
126
+ check("backend modules import", _imports)
127
+
128
+ # 2. UI modules import cleanly.
129
+ def _ui_imports():
130
+ from .ui import dialogs, login_dialog, tray, tutorial_dialog # noqa: F401
131
+ check("UI modules import", _ui_imports)
132
+
133
+ # 3. Every translation file parses and resolves a known sentinel.
134
+ def _translations():
135
+ from .i18n import LOCALES, init_locale, tr
136
+ for code in LOCALES:
137
+ init_locale(code)
138
+ # "Sign in to Upbound" exists in every locale we ship.
139
+ v = tr("Sign in to Upbound")
140
+ assert isinstance(v, str) and v, f"empty translation for {code}"
141
+ init_locale("en")
142
+ check("translations load (en/es/fr/de/zh_CN/ja)", _translations)
143
+
144
+ # 4. Keyring backend resolves without error (don't touch it though).
145
+ def _keyring():
146
+ from .backend.profile import keyring_status
147
+ s = keyring_status()
148
+ assert "friendly_name" in s
149
+ check("keyring backend resolvable", _keyring)
150
+
151
+ # 5. QApplication + UpTray construct without error. Walks the
152
+ # whole menu hierarchy as a side effect of construction.
153
+ #
154
+ # Lifecycle: UpTray spins up a QThread for the worker. We MUST
155
+ # quit() and wait() that thread before letting `t` go out of
156
+ # scope, otherwise on Linux Qt aborts with "QThread: Destroyed
157
+ # while thread is still running" (SIGABRT, exit 134). macOS and
158
+ # Windows are more forgiving but it's still incorrect.
159
+ def _construct():
160
+ app = QApplication.instance() or QApplication(sys.argv[:1])
161
+ from .ui.tray import UpTray
162
+ t = UpTray(app)
163
+ try:
164
+ labels = [a.text() for a in t.menu.actions() if a.menu()]
165
+ expected_substrings = ["Profile", "Context", "Cloud", "Crossplane", "xpkg", "Settings"]
166
+ for needle in expected_substrings:
167
+ assert any(needle in l for l in labels), f"missing top-level menu containing {needle!r}; got {labels}"
168
+ for sig in ("begin_browser_login", "list_organizations", "tutorial_check_state"):
169
+ assert hasattr(t.req, sig), f"_Requests missing signal: {sig}"
170
+ finally:
171
+ t.thread.quit()
172
+ if not t.thread.wait(2000):
173
+ t.thread.terminate()
174
+ t.thread.wait(500)
175
+ t.deleteLater()
176
+ check("UpTray constructs and menu wired", _construct)
177
+
178
+ # 6. Tutorial paths declared correctly. We deliberately do NOT
179
+ # instantiate TutorialDialog here: on the bare Ubuntu CI runner
180
+ # (no D-Bus session, fail keyring backend) constructing the
181
+ # dialog after UpTray's teardown raises "wrapped C/C++ object of
182
+ # type QLabel has been deleted" — local desktop Linux + macOS +
183
+ # Windows all repro fine. The "UI modules import" check (#2)
184
+ # already proves the dialog code parses; this asserts the data
185
+ # contract that actually matters for tutorial behavior.
186
+ # Interactive testing covers QWidget construction.
187
+ def _tutorial():
188
+ from .ui.tutorial_dialog import PATHS
189
+ assert "kubernetes" in PATHS, f"missing kubernetes path; got {list(PATHS)}"
190
+ assert "aws" in PATHS, f"missing aws path; got {list(PATHS)}"
191
+ for pid, p in PATHS.items():
192
+ assert p.provider_repo_key, f"{pid}: empty provider_repo_key"
193
+ assert p.resource_kind, f"{pid}: empty resource_kind"
194
+ assert p.pc_versions, f"{pid}: empty pc_versions"
195
+ check("tutorial paths declared", _tutorial)
196
+
197
+ # 7. POSIX file perms helper is callable without crashing on the
198
+ # current platform (no-op on Windows).
199
+ def _perms():
200
+ from .i18n import _write_settings, _read_settings
201
+ # Read-only probe — don't actually overwrite the user's prefs.
202
+ _read_settings()
203
+ check("settings read", _perms)
204
+
205
+ print()
206
+ if failures:
207
+ print(f"{len(failures)} FAILURE(S):")
208
+ for f in failures:
209
+ print(f" {f}")
210
+ return 1
211
+ print("ALL CHECKS PASSED")
212
+ return 0
213
+
214
+
215
+ if __name__ == "__main__":
216
+ sys.exit(main())
File without changes
@@ -0,0 +1,351 @@
1
+ """
2
+ Authentication against Upbound.
3
+
4
+ Reverse-engineered from the official `up` CLI (the up repo itself is private,
5
+ but the binary's strings + a strace of `up login` give the full contract):
6
+
7
+ Browser flow
8
+ ------------
9
+ 1. Bind a TCP port P on 127.0.0.1.
10
+ 2. Open the user's default browser to:
11
+ https://accounts.upbound.io/login
12
+ ?returnTo=<urlencoded>
13
+ https://api.upbound.io/v1/issueTOTP
14
+ ?returnTo=<urlencoded http://localhost:P/>
15
+ i.e. the standard accounts sign-in page, with a returnTo that chains
16
+ through api.upbound.io/v1/issueTOTP. Once the user is signed in,
17
+ issueTOTP mints a one-time TOTP and 302s back to localhost.
18
+ 3. Browser hits http://localhost:P/?totp=NNNNNN.
19
+ 4. We GET https://api.upbound.io/v1/checkTOTP?totp=NNNNNN.
20
+ The response sets `Set-Cookie: SID=<JWT>; Domain=upbound.io; HttpOnly`.
21
+ That JWT is the session token.
22
+
23
+ Token (PAT/robot) flow
24
+ ----------------------
25
+ Paste an Upbound personal access token (`upat_…`). Validated by hitting
26
+ /v1/organizations.
27
+
28
+ Auth header rules
29
+ -----------------
30
+ The Upbound API uses two different auth schemes depending on the token kind:
31
+ - Session JWTs (issued by the browser flow) MUST be sent as
32
+ Cookie: SID=<jwt>
33
+ Sending them as `Authorization: Bearer <jwt>` returns 401. (This was the
34
+ silent reason the previous version's whoami/cloud calls 401'd even when
35
+ a session existed.)
36
+ - PATs (`upat_…`) are sent as
37
+ Authorization: Bearer <pat>
38
+ Use `auth_headers(token)` to pick the right one automatically.
39
+ """
40
+ from __future__ import annotations
41
+
42
+ import os
43
+ import re
44
+ import shutil
45
+ import socket
46
+ import subprocess
47
+ import threading
48
+ import time
49
+ import urllib.parse
50
+ import webbrowser
51
+ from dataclasses import dataclass
52
+ from http.server import BaseHTTPRequestHandler, HTTPServer
53
+ from typing import Callable, Optional
54
+
55
+ import requests
56
+
57
+ from .profile import Profile
58
+
59
+ ACCOUNTS_LOGIN_PATH = "/login"
60
+ ISSUE_TOTP_PATH = "/v1/issueTOTP"
61
+ CHECK_TOTP_PATH = "/v1/checkTOTP"
62
+ SESSION_COOKIE_NAME = "SID"
63
+
64
+ BROWSER_LOGIN_TIMEOUT_S = 300 # 5 minutes, matches `up`
65
+
66
+
67
+ class AuthError(Exception):
68
+ pass
69
+
70
+
71
+ # ------------------------- auth header helper -------------------------
72
+
73
+ def auth_headers(token: str) -> dict:
74
+ """
75
+ Return the right auth header(s) for an Upbound token.
76
+
77
+ Session JWTs (from the browser flow) authenticate as a cookie; PATs
78
+ authenticate as a bearer. The two endpoints reject the wrong scheme
79
+ with 401, so this distinction matters.
80
+ """
81
+ if not token:
82
+ return {}
83
+ if token.startswith("upat_") or token.startswith("upr_"):
84
+ return {"Authorization": f"Bearer {token}"}
85
+ # Session JWT from /v1/checkTOTP: header.payload.signature, base64url.
86
+ return {"Cookie": f"{SESSION_COOKIE_NAME}={token}"}
87
+
88
+
89
+ # ------------------------- browser open --------------------------------
90
+
91
+ def open_browser(uri: str) -> bool:
92
+ """
93
+ Try to open `uri` in the user's preferred browser. Returns True on apparent
94
+ success.
95
+
96
+ On Linux we prefer xdg-open over Python's `webbrowser` module: the stdlib
97
+ will happily pick whatever browser it finds on $PATH first (often Firefox),
98
+ even when xdg-settings says Chrome. xdg-open respects the desktop default.
99
+ """
100
+ if shutil.which("xdg-open"):
101
+ try:
102
+ subprocess.Popen(
103
+ ["xdg-open", uri],
104
+ stdout=subprocess.DEVNULL,
105
+ stderr=subprocess.DEVNULL,
106
+ start_new_session=True,
107
+ )
108
+ return True
109
+ except OSError:
110
+ pass
111
+ if shutil.which("gio"):
112
+ try:
113
+ subprocess.Popen(
114
+ ["gio", "open", uri],
115
+ stdout=subprocess.DEVNULL,
116
+ stderr=subprocess.DEVNULL,
117
+ start_new_session=True,
118
+ )
119
+ return True
120
+ except OSError:
121
+ pass
122
+ try:
123
+ return bool(webbrowser.open(uri, new=2))
124
+ except Exception:
125
+ return False
126
+
127
+
128
+ # ------------------------- browser login -------------------------------
129
+
130
+ @dataclass
131
+ class BrowserLoginPending:
132
+ """Returned when the listening socket is up and the URL is ready."""
133
+ login_url: str
134
+ port: int
135
+
136
+
137
+ class _CallbackHandler(BaseHTTPRequestHandler):
138
+ server: "_CallbackServer" # set by HTTPServer
139
+
140
+ def do_GET(self):
141
+ path = urllib.parse.urlparse(self.path)
142
+ params = urllib.parse.parse_qs(path.query)
143
+ totp = (params.get("totp") or [""])[0]
144
+ # Always reply with something friendly so the user sees "you can close
145
+ # this tab" rather than a stuck spinner.
146
+ if totp:
147
+ self.server.totp = totp
148
+ body = (
149
+ b"<!doctype html><html><body style='font-family:system-ui;"
150
+ b"text-align:center;padding:3em'>"
151
+ b"<h2>Signed in.</h2><p>You can close this tab.</p>"
152
+ b"</body></html>"
153
+ )
154
+ self.send_response(200)
155
+ else:
156
+ body = b"<!doctype html><html><body>Missing totp parameter.</body></html>"
157
+ self.send_response(400)
158
+ self.send_header("Content-Type", "text/html; charset=utf-8")
159
+ self.send_header("Content-Length", str(len(body)))
160
+ self.end_headers()
161
+ self.wfile.write(body)
162
+ # Tell the server loop to stop after this request.
163
+ self.server.done_event.set()
164
+
165
+ def log_message(self, *_):
166
+ pass # silence stderr access log
167
+
168
+
169
+ class _CallbackServer(HTTPServer):
170
+ """HTTPServer that exposes the captured TOTP and a 'done' event."""
171
+ totp: Optional[str] = None
172
+ done_event: threading.Event
173
+
174
+ def __init__(self, addr, handler):
175
+ super().__init__(addr, handler)
176
+ self.done_event = threading.Event()
177
+
178
+
179
+ class BrowserLogin:
180
+ """
181
+ Drives the Upbound browser-login flow on the worker thread.
182
+
183
+ Lifecycle:
184
+ bl = BrowserLogin(profile)
185
+ bl.start() # binds port, opens browser
186
+ token = bl.wait_for_session(...) # blocks; returns session JWT
187
+ # or, from another thread:
188
+ bl.cancel()
189
+ """
190
+
191
+ def __init__(self, profile: Profile):
192
+ self.profile = profile
193
+ self._server: Optional[_CallbackServer] = None
194
+ self._port: int = 0
195
+ self._login_url: str = ""
196
+
197
+ @property
198
+ def login_url(self) -> str:
199
+ return self._login_url
200
+
201
+ def start(self) -> BrowserLoginPending:
202
+ # Bind to an ephemeral port on loopback.
203
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
204
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
205
+ sock.bind(("127.0.0.1", 0))
206
+ self._port = sock.getsockname()[1]
207
+ sock.close()
208
+ # Re-bind via HTTPServer so we get the request handler plumbed.
209
+ self._server = _CallbackServer(("127.0.0.1", self._port), _CallbackHandler)
210
+
211
+ callback = f"http://localhost:{self._port}/"
212
+ issue_url = (
213
+ self.profile.api_endpoint.rstrip("/")
214
+ + ISSUE_TOTP_PATH
215
+ + "?returnTo="
216
+ + urllib.parse.quote(callback, safe="")
217
+ )
218
+ self._login_url = (
219
+ self.profile.accounts_endpoint.rstrip("/")
220
+ + ACCOUNTS_LOGIN_PATH
221
+ + "?returnTo="
222
+ + urllib.parse.quote(issue_url, safe="")
223
+ )
224
+
225
+ if not open_browser(self._login_url):
226
+ # We still return; caller can show the URL for manual paste.
227
+ pass
228
+ return BrowserLoginPending(login_url=self._login_url, port=self._port)
229
+
230
+ def wait_for_session(
231
+ self,
232
+ on_progress: Optional[Callable[[str], None]] = None,
233
+ timeout: float = BROWSER_LOGIN_TIMEOUT_S,
234
+ ) -> str:
235
+ """Block until the browser callback fires, then exchange TOTP for a JWT."""
236
+ if self._server is None:
237
+ raise AuthError("BrowserLogin.start() not called")
238
+ if on_progress:
239
+ on_progress("waiting for browser sign-in")
240
+ # Run a single request in another thread so we can enforce a timeout.
241
+ srv = self._server
242
+ t = threading.Thread(target=srv.serve_forever, daemon=True)
243
+ t.start()
244
+ got = srv.done_event.wait(timeout=timeout)
245
+ srv.shutdown()
246
+ srv.server_close()
247
+ if not got:
248
+ raise AuthError("timed out waiting for browser sign-in")
249
+ if not srv.totp:
250
+ raise AuthError("browser callback did not include a totp parameter")
251
+ if on_progress:
252
+ on_progress("exchanging code for session")
253
+ return _redeem_totp(self.profile, srv.totp)
254
+
255
+ def cancel(self) -> None:
256
+ srv = self._server
257
+ if srv is not None:
258
+ srv.done_event.set()
259
+ try:
260
+ srv.shutdown()
261
+ except Exception:
262
+ pass
263
+ try:
264
+ srv.server_close()
265
+ except Exception:
266
+ pass
267
+
268
+
269
+ def _redeem_totp(profile: Profile, totp: str) -> str:
270
+ """GET /v1/checkTOTP?totp=… and pull the SID cookie out of Set-Cookie."""
271
+ url = profile.api_endpoint.rstrip("/") + CHECK_TOTP_PATH
272
+ try:
273
+ r = requests.get(url, params={"totp": totp}, timeout=15, allow_redirects=False)
274
+ except requests.RequestException as e:
275
+ raise AuthError(f"network error reaching {url}: {e}") from e
276
+ if r.status_code != 200:
277
+ raise AuthError(
278
+ f"checkTOTP returned HTTP {r.status_code}: {r.text[:300]!r}"
279
+ )
280
+ sid = r.cookies.get(SESSION_COOKIE_NAME)
281
+ if not sid:
282
+ # Some intermediaries strip cookies — fall back to scanning Set-Cookie.
283
+ for h in r.headers.get_all("Set-Cookie") if hasattr(r.headers, "get_all") else r.raw.headers.getlist("Set-Cookie"):
284
+ m = re.match(rf"\s*{SESSION_COOKIE_NAME}=([^;]+)", h)
285
+ if m:
286
+ sid = m.group(1)
287
+ break
288
+ if not sid:
289
+ raise AuthError("checkTOTP succeeded but no SID cookie was returned")
290
+ return sid
291
+
292
+
293
+ # Convenience: the whole flow as a single function for callers that don't
294
+ # need start/cancel separation.
295
+ def browser_login(
296
+ profile: Profile,
297
+ on_progress: Optional[Callable[[str], None]] = None,
298
+ timeout: float = BROWSER_LOGIN_TIMEOUT_S,
299
+ ) -> str:
300
+ bl = BrowserLogin(profile)
301
+ bl.start()
302
+ return bl.wait_for_session(on_progress=on_progress, timeout=timeout)
303
+
304
+
305
+ # ------------------------- token (PAT) login ---------------------------
306
+
307
+ def login_with_token(profile: Profile, token: str) -> str:
308
+ """
309
+ Validate a PAT or robot token by hitting /v1/organizations, which any
310
+ authenticated principal can read. Returns the token unchanged on success.
311
+ """
312
+ headers = {"Accept": "application/json", **auth_headers(token)}
313
+ url = profile.api_endpoint.rstrip("/") + "/v1/organizations"
314
+ try:
315
+ r = requests.get(url, headers=headers, timeout=10)
316
+ except requests.RequestException as e:
317
+ raise AuthError(f"network error reaching {url}: {e}") from e
318
+ if r.status_code == 200:
319
+ return token
320
+ if r.status_code == 401:
321
+ raise AuthError(
322
+ "Token rejected by Upbound (HTTP 401). "
323
+ "Check the PAT is valid and not expired. Generate a new one at "
324
+ "https://accounts.upbound.io (My Account -> API tokens)."
325
+ )
326
+ if r.status_code == 403:
327
+ # Authenticated but lacks read on organizations — still a valid token.
328
+ return token
329
+ raise AuthError(f"validation hit HTTP {r.status_code}: {r.text[:300]!r}")
330
+
331
+
332
+ def whoami(profile: Profile) -> dict:
333
+ """GET /v1/self — returns the current user record."""
334
+ tok = profile.token()
335
+ if not tok:
336
+ raise AuthError("no session token on profile")
337
+ r = requests.get(
338
+ profile.api_endpoint.rstrip("/") + "/v1/self",
339
+ headers={"Accept": "application/json", **auth_headers(tok)},
340
+ timeout=10,
341
+ )
342
+ if r.status_code == 401:
343
+ raise AuthError("session expired or invalid")
344
+ if not r.ok:
345
+ raise AuthError(f"whoami: HTTP {r.status_code}")
346
+ return r.json()
347
+
348
+
349
+ def logout(profile: Profile) -> None:
350
+ """Clear the local token. (Upbound's session is stateless JWT; nothing to revoke server-side.)"""
351
+ profile.set_token("")
@@ -0,0 +1,83 @@
1
+ """
2
+ Per-field autocomplete history for the CRD authoring dialog.
3
+
4
+ Records recently-entered text values keyed by (kind, dot-path), so the
5
+ next time a user opens a form for the same kind they get suggestions
6
+ from prior runs. Persisted to settings.json — the same file as
7
+ locale and strict_keyring — so it survives restarts.
8
+
9
+ What we DON'T record:
10
+ - Empty strings.
11
+ - Values from fields whose leaf name implies a literal secret
12
+ (password, token, apiKey, etc. — see SENSITIVE_LEAF_NAMES).
13
+
14
+ Reference-style fields like ``secretRef.name`` / ``secretRef.key`` are
15
+ *not* sensitive: their values are pointer resource names, not the
16
+ secrets themselves, and they're exactly the kind of thing the user
17
+ benefits from autocompleting (most clusters reuse 1-2 secret names).
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from ..i18n import get_setting, set_setting
22
+
23
+ # Field leaf-names whose value is the secret itself, not a reference
24
+ # to one. Compared case-insensitively. Reference fields ("name",
25
+ # "namespace", "key") stay autocompletable.
26
+ SENSITIVE_LEAF_NAMES = {
27
+ "password", "passwd",
28
+ "token", "accesstoken", "refreshtoken",
29
+ "secret", "apikey", "api_key", "apisecret",
30
+ "privatekey", "private_key",
31
+ "credential", "credentials",
32
+ }
33
+
34
+ # How many recent values to keep per (kind, path). Small enough that
35
+ # the QCompleter popup stays scannable, big enough to remember "I
36
+ # usually pick one of 2-3 namespaces / a handful of keys".
37
+ RING_SIZE = 10
38
+
39
+ _SETTINGS_KEY = "autocomplete_history"
40
+
41
+
42
+ def is_sensitive(path: str) -> bool:
43
+ leaf = (path or "").rsplit(".", 1)[-1].lower()
44
+ return leaf in SENSITIVE_LEAF_NAMES
45
+
46
+
47
+ def _store() -> dict:
48
+ raw = get_setting(_SETTINGS_KEY, {})
49
+ return raw if isinstance(raw, dict) else {}
50
+
51
+
52
+ def _key(kind: str, path: str) -> str:
53
+ return f"{kind}|{path}"
54
+
55
+
56
+ def suggestions(kind: str, path: str) -> list[str]:
57
+ if is_sensitive(path):
58
+ return []
59
+ store = _store()
60
+ return list(store.get(_key(kind, path), []) or [])
61
+
62
+
63
+ def record_value(kind: str, path: str, value: str) -> None:
64
+ """Append `value` to the MRU list for (kind, path). No-op for
65
+ sensitive fields or empty values."""
66
+ if not value or is_sensitive(path):
67
+ return
68
+ store = _store()
69
+ key = _key(kind, path)
70
+ history = list(store.get(key, []) or [])
71
+ # Move to front (MRU). Dedup by exact match.
72
+ if value in history:
73
+ history.remove(value)
74
+ history.insert(0, value)
75
+ if len(history) > RING_SIZE:
76
+ history = history[:RING_SIZE]
77
+ store[key] = history
78
+ set_setting(_SETTINGS_KEY, store)
79
+
80
+
81
+ def clear_all() -> None:
82
+ """Wipe every recorded value. Called from Settings ▸ Clear history."""
83
+ set_setting(_SETTINGS_KEY, {})