caland 0.5.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.
Files changed (46) hide show
  1. caland/__init__.py +0 -0
  2. caland/__main__.py +4 -0
  3. caland/app.py +183 -0
  4. caland/application/__init__.py +22 -0
  5. caland/application/dotenv.py +52 -0
  6. caland/application/loading.py +138 -0
  7. caland/application/onboarding.py +106 -0
  8. caland/application/read_model.py +76 -0
  9. caland/application/workspace.py +177 -0
  10. caland/domain/__init__.py +55 -0
  11. caland/domain/errors.py +12 -0
  12. caland/domain/host.py +15 -0
  13. caland/domain/models.py +120 -0
  14. caland/domain/permissions.py +72 -0
  15. caland/domain/ports.py +55 -0
  16. caland/domain/secret_store.py +30 -0
  17. caland/formerly.py +50 -0
  18. caland/infrastructure/__init__.py +23 -0
  19. caland/infrastructure/bundle.py +84 -0
  20. caland/infrastructure/config.py +13 -0
  21. caland/infrastructure/connector.py +47 -0
  22. caland/infrastructure/databricks.py +159 -0
  23. caland/infrastructure/profiles.py +66 -0
  24. caland/infrastructure/settings.py +76 -0
  25. caland/interface/__init__.py +0 -0
  26. caland/interface/modals.py +932 -0
  27. caland/interface/screens/__init__.py +0 -0
  28. caland/interface/screens/login.py +245 -0
  29. caland/interface/screens/main.py +1107 -0
  30. caland/interface/sorting.py +59 -0
  31. caland/interface/theme.py +137 -0
  32. caland/interface/web/__init__.py +134 -0
  33. caland/interface/web/gate.py +107 -0
  34. caland/interface/web/opening.py +54 -0
  35. caland/interface/web/server.py +328 -0
  36. caland/interface/web/static/page.css +110 -0
  37. caland/interface/web/static/page.html +94 -0
  38. caland/interface/web/static/page.js +425 -0
  39. caland/interface/web/views.py +86 -0
  40. caland/interface/widgets.py +473 -0
  41. caland/styles.tcss +371 -0
  42. caland-0.5.0.dist-info/METADATA +219 -0
  43. caland-0.5.0.dist-info/RECORD +46 -0
  44. caland-0.5.0.dist-info/WHEEL +4 -0
  45. caland-0.5.0.dist-info/entry_points.txt +4 -0
  46. caland-0.5.0.dist-info/licenses/LICENSE +202 -0
caland/__init__.py ADDED
File without changes
caland/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .app import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
caland/app.py ADDED
@@ -0,0 +1,183 @@
1
+ """Caland — a terminal Databricks secret manager.
2
+
3
+ The App is the composition root: it builds the infrastructure adapters, wires
4
+ them into the `OnboardingService`, installs the theme, and hands off to
5
+ `MainScreen`. Domain logic lives in `caland.domain`, use-cases in
6
+ `caland.application`, adapters in `caland.infrastructure`; UI in
7
+ `caland.interface`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from textual.app import App
13
+
14
+ from .application import OnboardingService, WorkspaceService
15
+ from .domain import Settings, SettingsStore
16
+ from .infrastructure import (
17
+ DatabricksBundleStore,
18
+ DatabricksCfgProfileStore,
19
+ DatabricksConnector,
20
+ JsonSettingsStore,
21
+ )
22
+ from .interface.screens.main import MainScreen
23
+ from .interface.theme import CALAND_THEMES
24
+
25
+
26
+ class EphemeralSettingsStore:
27
+ """Fallback `SettingsStore`: keeps preferences for this process only.
28
+
29
+ The default for programmatic use (tests, embedding) so constructing the app
30
+ never touches the user's real config; `main()` passes the JSON store.
31
+ """
32
+
33
+ def __init__(self) -> None:
34
+ self._settings = Settings()
35
+
36
+ def load(self) -> Settings:
37
+ return self._settings
38
+
39
+ def save(self, settings: Settings) -> None:
40
+ self._settings = settings
41
+
42
+
43
+ class CalandApp(App[None]):
44
+ CSS_PATH = "styles.tcss"
45
+ TITLE = "Caland"
46
+ # `q` quits from the browse/login screens only (they bind it themselves) —
47
+ # an app-level binding would bubble up from dialogs and quit mid-confirm.
48
+
49
+ def __init__(
50
+ self,
51
+ onboarding: OnboardingService | None = None,
52
+ session: WorkspaceService | None = None,
53
+ read_only: bool = False,
54
+ settings_store: SettingsStore | None = None,
55
+ profile: str | None = None,
56
+ ) -> None:
57
+ super().__init__()
58
+ self._onboarding = onboarding
59
+ self._initial_session = session
60
+ self.read_only = read_only
61
+ self._settings_store = settings_store or EphemeralSettingsStore()
62
+ self.settings = self._settings_store.load()
63
+ self._profile = profile # workspace to auto-connect to (--profile)
64
+
65
+ def get_theme_variable_defaults(self) -> dict[str, str]:
66
+ # The stylesheet is parsed under Textual's default theme before ours is
67
+ # applied, so the per-section accents must resolve there too. Each
68
+ # Caland theme overrides these with its own values.
69
+ return {
70
+ **super().get_theme_variable_defaults(),
71
+ "scopes-color": "#8b7cff",
72
+ "secrets-color": "#4ec9e0",
73
+ "detail-color": "#e0b24a",
74
+ "value-color": "#5fd39a",
75
+ }
76
+
77
+ def save_settings(self) -> None:
78
+ self._settings_store.save(self.settings)
79
+
80
+ def _remember_theme(self, theme: str) -> None:
81
+ self.settings.theme = theme
82
+ self.save_settings()
83
+
84
+ def on_mount(self) -> None:
85
+ for theme in CALAND_THEMES:
86
+ self.register_theme(theme)
87
+ # apply the persisted theme (fall back if it no longer exists), then
88
+ # remember every change the user makes via the palette
89
+ wanted = self.settings.theme
90
+ self.theme = wanted if wanted in self.available_themes else "caland"
91
+ self.watch(self, "theme", self._remember_theme, init=False)
92
+ onboarding = self._onboarding or OnboardingService(
93
+ DatabricksConnector(),
94
+ DatabricksCfgProfileStore(),
95
+ DatabricksBundleStore(),
96
+ )
97
+ self.push_screen(
98
+ MainScreen(
99
+ onboarding,
100
+ self._initial_session,
101
+ read_only=self.read_only,
102
+ settings=self.settings,
103
+ save_settings=self.save_settings,
104
+ auto_connect=self._profile,
105
+ )
106
+ )
107
+
108
+
109
+ _USAGE = """\
110
+ caland — a keyboard-driven terminal UI for managing Databricks secrets.
111
+
112
+ usage: caland [WORKSPACE] [--profile NAME] [--read-only] [--version] [--help]
113
+ caland --page [WORKSPACE] [--no-open]
114
+
115
+ WORKSPACE / --profile NAME
116
+ connect straight to a discovered workspace (a
117
+ ~/.databrickscfg profile or bundle target) and skip
118
+ the picker
119
+ --read-only browse, reveal, and copy — but disable every mutation
120
+ (safe for poking around production)
121
+ --page a preview: caland as a page in your browser, served from
122
+ this machine only. It browses, shows and copies; it does
123
+ not change anything yet
124
+ --no-open with --page: print the link instead of opening a browser
125
+
126
+ Run with no arguments to launch the TUI. Inside: ? for help, ctrl+p for the
127
+ command palette, q to quit.
128
+ """
129
+
130
+
131
+ def main() -> None:
132
+ import sys
133
+
134
+ args = sys.argv[1:]
135
+ if {"-V", "--version"} & set(args):
136
+ from importlib.metadata import version
137
+
138
+ print(f"caland {version('caland')}")
139
+ return
140
+ if {"-h", "--help"} & set(args):
141
+ print(_USAGE, end="")
142
+ return
143
+ profile: str | None = None
144
+ if "--profile" in args:
145
+ i = args.index("--profile")
146
+ if i + 1 >= len(args):
147
+ print("error: --profile needs a workspace name", file=sys.stderr)
148
+ raise SystemExit(2)
149
+ profile = args[i + 1]
150
+ else: # a bare positional is the workspace name
151
+ positional = [a for a in args if not a.startswith("-")]
152
+ if positional:
153
+ profile = positional[0]
154
+ if "--page" in args:
155
+ raise SystemExit(_page(profile, args))
156
+ CalandApp(
157
+ read_only="--read-only" in args,
158
+ settings_store=JsonSettingsStore(),
159
+ profile=profile,
160
+ ).run()
161
+
162
+
163
+ def _page(profile: str | None, args: list[str]) -> int:
164
+ """`caland --page`: the same workspace, as a page in the browser."""
165
+ from importlib.metadata import version
166
+
167
+ from .interface import web
168
+
169
+ onboarding = OnboardingService(
170
+ DatabricksConnector(), DatabricksCfgProfileStore(), DatabricksBundleStore()
171
+ )
172
+ return web.run(
173
+ onboarding,
174
+ profile,
175
+ read_only="--read-only" in args,
176
+ settings=JsonSettingsStore().load(),
177
+ version=version("caland"),
178
+ open_browser="--no-open" not in args,
179
+ )
180
+
181
+
182
+ if __name__ == "__main__":
183
+ main()
@@ -0,0 +1,22 @@
1
+ """Application layer — use-cases that orchestrate the domain.
2
+
3
+ Depends only on `domain`; never on infrastructure or the UI. The read model
4
+ (`WorkspaceCache`) is pure in-memory app state, so it lives here too.
5
+ """
6
+
7
+ from .dotenv import format_dotenv, parse_dotenv
8
+ from .loading import Loader, Progress
9
+ from .onboarding import Connection, OnboardingService
10
+ from .read_model import WorkspaceCache
11
+ from .workspace import WorkspaceService
12
+
13
+ __all__ = [
14
+ "Connection",
15
+ "Loader",
16
+ "OnboardingService",
17
+ "Progress",
18
+ "WorkspaceCache",
19
+ "WorkspaceService",
20
+ "format_dotenv",
21
+ "parse_dotenv",
22
+ ]
@@ -0,0 +1,52 @@
1
+ """Parse and format .env content for bulk secret import/export.
2
+
3
+ Pure text transforms — no I/O. Values with newlines or other awkward characters
4
+ are written JSON-quoted and read back the same way, so multiline secrets
5
+ (PEM keys etc.) round-trip.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+
13
+ # values that can be written bare, without quoting
14
+ _BARE = re.compile(r"^[A-Za-z0-9_@%+=:,./-]*$")
15
+
16
+
17
+ def parse_dotenv(text: str) -> dict[str, str]:
18
+ """KEY=VALUE lines. Blank lines and #-comments are ignored, an `export `
19
+ prefix is dropped, and single/double/JSON-style quotes are stripped."""
20
+ entries: dict[str, str] = {}
21
+ for line in text.splitlines():
22
+ line = line.strip()
23
+ if not line or line.startswith("#") or "=" not in line:
24
+ continue
25
+ key, _, value = line.partition("=")
26
+ key = key.strip()
27
+ if key.startswith("export "):
28
+ key = key.removeprefix("export ").strip()
29
+ value = value.strip()
30
+ if value[:1] == '"' and value[-1:] == '"' and len(value) >= 2:
31
+ try:
32
+ value = json.loads(value) # unescape \n etc.
33
+ except ValueError:
34
+ value = value[1:-1]
35
+ elif value[:1] == "'" and value[-1:] == "'" and len(value) >= 2:
36
+ value = value[1:-1]
37
+ if key:
38
+ entries[key] = value
39
+ return entries
40
+
41
+
42
+ def format_dotenv(pairs: list[tuple[str, str]], *, redact: bool = False) -> str:
43
+ """Render pairs as .env lines; `redact` writes keys only (a template)."""
44
+ lines = []
45
+ for key, value in pairs:
46
+ if redact:
47
+ lines.append(f"{key}=")
48
+ elif _BARE.match(value):
49
+ lines.append(f"{key}={value}")
50
+ else:
51
+ lines.append(f"{key}={json.dumps(value)}")
52
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,138 @@
1
+ """Loader — connect to a workspace and warm it, in the background.
2
+
3
+ The use-case behind "the page is there before the workspace is": something that
4
+ shows a workspace starts a `Loader`, draws at once, and asks `progress()` how far
5
+ it is. Scopes are warmed eight at a time — enough that hundreds are ready in
6
+ seconds, few enough not to hammer the API.
7
+
8
+ It does no I/O of its own: `connect` hands it a `WorkspaceService`, and every
9
+ call goes through that.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import threading
15
+ from collections.abc import Callable
16
+ from concurrent.futures import ThreadPoolExecutor
17
+ from dataclasses import dataclass, replace
18
+
19
+ from ..domain import AuthError, StoreError
20
+ from .workspace import WorkspaceService
21
+
22
+ #: How many scopes are asked for at the same time.
23
+ WORKERS = 8
24
+
25
+ CONNECTING = "connecting"
26
+ LOADING = "loading"
27
+ READY = "ready"
28
+ FAILED = "failed"
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Progress:
33
+ """How far a `Loader` is. `version` goes up with every change, so that who
34
+ asks can tell whether there is anything new."""
35
+
36
+ phase: str = CONNECTING
37
+ done: int = 0
38
+ total: int = 0
39
+ error: str = ""
40
+ version: int = 0
41
+
42
+
43
+ class Loader:
44
+ def __init__(
45
+ self, connect: Callable[[], WorkspaceService], workers: int = WORKERS
46
+ ) -> None:
47
+ self._connect = connect
48
+ self._workers = workers
49
+ self._lock = threading.Lock()
50
+ self._progress = Progress()
51
+ self._service: WorkspaceService | None = None
52
+ # one run at a time: a refresh asked for during a load waits its turn
53
+ self._turn = threading.Lock()
54
+
55
+ @property
56
+ def service(self) -> WorkspaceService | None:
57
+ """The workspace, once connected."""
58
+ return self._service
59
+
60
+ def progress(self) -> Progress:
61
+ with self._lock:
62
+ return self._progress
63
+
64
+ # -- starting work --------------------------------------------------
65
+ def start(self) -> threading.Thread:
66
+ """Connect, sign in and warm every scope. Returns at once."""
67
+ return self._run(self._load)
68
+
69
+ def refresh(self, scope: str | None = None) -> threading.Thread | None:
70
+ """Read one scope again, or with no name the whole workspace. Returns at
71
+ once; nothing happens when there is no workspace to read."""
72
+ if self._service is None:
73
+ return None
74
+ if scope is None:
75
+ return self._run(self._warm)
76
+ return self._run(lambda: self._warm_one(scope))
77
+
78
+ def _run(self, work: Callable[[], None]) -> threading.Thread:
79
+ def in_turn() -> None:
80
+ with self._turn:
81
+ work()
82
+
83
+ thread = threading.Thread(target=in_turn, name="caland-loader", daemon=True)
84
+ thread.start()
85
+ return thread
86
+
87
+ # -- the work -------------------------------------------------------
88
+ def _set(self, **changes: object) -> None:
89
+ with self._lock:
90
+ now = self._progress
91
+ self._progress = replace(now, **changes, version=now.version + 1)
92
+
93
+ def _one_more(self) -> None:
94
+ with self._lock:
95
+ now = self._progress
96
+ self._progress = replace(now, done=now.done + 1, version=now.version + 1)
97
+
98
+ def _load(self) -> None:
99
+ self._set(phase=CONNECTING, done=0, total=0, error="")
100
+ try:
101
+ service = self._connect()
102
+ identity = service.authenticate()
103
+ except (AuthError, StoreError) as exc:
104
+ self._set(phase=FAILED, error=str(exc))
105
+ return
106
+ if not identity.authenticated:
107
+ self._set(phase=FAILED, error=identity.error or "Access denied.")
108
+ return
109
+ self._service = service
110
+ self._warm()
111
+
112
+ def _warm(self) -> None:
113
+ service = self._service
114
+ if service is None:
115
+ return
116
+ self._set(phase=LOADING, done=0, total=0, error="")
117
+ try:
118
+ scopes = service.load_scopes()
119
+ except StoreError as exc:
120
+ self._set(phase=FAILED, error=str(exc))
121
+ return
122
+ self._set(total=len(scopes))
123
+
124
+ def warm(name: str) -> None:
125
+ service.warm_scope(name) # never raises: a scope it can't read is empty
126
+ self._one_more()
127
+
128
+ # each scope is written under its own key, so the writes don't race
129
+ with ThreadPoolExecutor(max_workers=self._workers) as pool:
130
+ list(pool.map(warm, [scope.name for scope in scopes]))
131
+ self._set(phase=READY)
132
+
133
+ def _warm_one(self, scope: str) -> None:
134
+ service = self._service
135
+ if service is None or service.scope(scope) is None:
136
+ return
137
+ service.refresh_scope(scope)
138
+ self._set() # nothing but the version: there is something new to read
@@ -0,0 +1,106 @@
1
+ """OnboardingService — connection/login use-cases.
2
+
3
+ Depends only on domain ports (`WorkspaceConnector`, `ProfileStore`, `BundleStore`);
4
+ the composition root injects concrete infrastructure adapters. Produces
5
+ ready-to-use `WorkspaceService` instances so the UI never touches the store or
6
+ the SDK.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+
13
+ from ..domain import (
14
+ SOURCE_PROFILE,
15
+ AuthError,
16
+ BundleStore,
17
+ ProfileStore,
18
+ Workspace,
19
+ WorkspaceConnector,
20
+ )
21
+ from .workspace import WorkspaceService
22
+
23
+
24
+ @dataclass
25
+ class Connection:
26
+ """A live workspace session plus the host needed to persist it as a profile."""
27
+
28
+ service: WorkspaceService
29
+ host: str = ""
30
+
31
+
32
+ def _on_offer(workspaces: list[Workspace]) -> str:
33
+ names = ", ".join(workspace.name for workspace in workspaces)
34
+ return f"There is: {names}." if names else "There is none to choose from."
35
+
36
+
37
+ class OnboardingService:
38
+ def __init__(
39
+ self,
40
+ connector: WorkspaceConnector,
41
+ profiles: ProfileStore,
42
+ bundle: BundleStore | None = None,
43
+ ) -> None:
44
+ self._connector = connector
45
+ self._profiles = profiles
46
+ self._bundle = bundle
47
+
48
+ # -- discovery ------------------------------------------------------
49
+ def available_workspaces(self) -> list[Workspace]:
50
+ """Every workspace we can offer, each tagged with where it came from: the
51
+ bundle target (default) first, then the ~/.databrickscfg profiles."""
52
+ workspaces: list[Workspace] = []
53
+ seen: set[str] = set()
54
+ bundle = self._bundle.discover() if self._bundle else None
55
+ if bundle:
56
+ workspaces.append(bundle)
57
+ seen.add(bundle.host_label)
58
+ for ws in self._profiles.discover():
59
+ if ws.host_label and ws.host_label in seen:
60
+ continue # already offered by the bundle
61
+ seen.add(ws.host_label)
62
+ workspaces.append(ws)
63
+ return workspaces
64
+
65
+ def choose(self, name: str | None = None) -> Workspace:
66
+ """The workspace that was asked for by name — or, with no name, the one
67
+ there is no doubt about: the bundle's default, or the only one there is.
68
+
69
+ Raises `AuthError` saying what there is to choose from otherwise.
70
+ """
71
+ workspaces = self.available_workspaces()
72
+ if name:
73
+ for workspace in workspaces:
74
+ if workspace.name == name:
75
+ return workspace
76
+ raise AuthError(f"No workspace “{name}” found. {_on_offer(workspaces)}")
77
+ default = next((w for w in workspaces if w.default), None)
78
+ if default:
79
+ return default
80
+ if len(workspaces) == 1:
81
+ return workspaces[0]
82
+ if not workspaces:
83
+ raise AuthError(
84
+ "No workspace found: there is no databricks.yml here and no profile "
85
+ "in ~/.databrickscfg."
86
+ )
87
+ raise AuthError(f"Which workspace? {_on_offer(workspaces)}")
88
+
89
+ def save_profile(self, name: str, host: str) -> None:
90
+ self._profiles.save(name, host)
91
+
92
+ # -- connection use-cases -------------------------------------------
93
+ def connect(self, workspace: Workspace) -> Connection:
94
+ """Connect to a chosen workspace. A saved profile uses its stored auth; a
95
+ bundle target or manual URL signs in through the browser (OAuth)."""
96
+ if workspace.source == SOURCE_PROFILE and workspace.profile:
97
+ return self.connect_profile(workspace.profile)
98
+ return self.connect_url(workspace.host)
99
+
100
+ def connect_profile(self, profile: str) -> Connection:
101
+ c = self._connector.connect_profile(profile)
102
+ return Connection(WorkspaceService(c.store, c.label))
103
+
104
+ def connect_url(self, host: str) -> Connection:
105
+ c = self._connector.connect_url(host)
106
+ return Connection(WorkspaceService(c.store, c.label), host=c.host)
@@ -0,0 +1,76 @@
1
+ """WorkspaceCache — the in-memory read model the UI renders from.
2
+
3
+ A projection that the application service warms up front (US-14) and keeps in
4
+ sync on writes. Pure data + bookkeeping; it holds no business rules (those live
5
+ in the domain) and does no I/O.
6
+
7
+ Strategy (US-14/16):
8
+ * On connect the service warms scopes -> secret metadata -> ACLs in the
9
+ background.
10
+ * Secret *values* are NOT bulk-loaded; they are fetched lazily on reveal and
11
+ cached thereafter, so sensitive material isn't pulled into memory needlessly.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+
18
+ from ..domain import Acl, Identity, Scope, Secret
19
+
20
+
21
+ @dataclass
22
+ class WorkspaceCache:
23
+ label: str
24
+ identity: Identity = field(default_factory=Identity)
25
+
26
+ scopes: list[Scope] = field(default_factory=list)
27
+ secrets: dict[str, list[Secret]] = field(default_factory=dict)
28
+ acls: dict[str, list[Acl]] = field(default_factory=dict)
29
+ values: dict[tuple[str, str], str] = field(default_factory=dict)
30
+ # scopes whose secrets we could list ⇒ the user holds at least READ on them
31
+ readable: set[str] = field(default_factory=set)
32
+
33
+ scopes_loaded: bool = False
34
+ warm_error: str = ""
35
+
36
+ # -- lookups ------------------------------------------------------------
37
+ def secrets_for(self, scope: str) -> list[Secret]:
38
+ return self.secrets.get(scope, [])
39
+
40
+ def acls_for(self, scope: str) -> list[Acl]:
41
+ return self.acls.get(scope, [])
42
+
43
+ def cached_value(self, scope: str, key: str) -> str | None:
44
+ return self.values.get((scope, key))
45
+
46
+ def set_value(self, scope: str, key: str, value: str) -> None:
47
+ self.values[(scope, key)] = value
48
+
49
+ # -- mutation keeping the read model + UI consistent --------------------
50
+ def upsert_secret(self, secret: Secret) -> None:
51
+ rows = self.secrets.setdefault(secret.scope, [])
52
+ for i, existing in enumerate(rows):
53
+ if existing.key == secret.key:
54
+ rows[i] = secret
55
+ break
56
+ else:
57
+ rows.append(secret)
58
+ rows.sort(key=lambda s: s.key.lower())
59
+
60
+ def remove_secret(self, scope: str, key: str) -> None:
61
+ self.secrets[scope] = [s for s in self.secrets.get(scope, []) if s.key != key]
62
+ self.values.pop((scope, key), None)
63
+
64
+ def add_scope(self, scope: Scope) -> None:
65
+ if not any(s.name == scope.name for s in self.scopes):
66
+ self.scopes.append(scope)
67
+ self.scopes.sort(key=lambda s: s.name.lower())
68
+ self.secrets.setdefault(scope.name, [])
69
+ self.acls.setdefault(scope.name, [])
70
+
71
+ def remove_scope(self, name: str) -> None:
72
+ self.scopes = [s for s in self.scopes if s.name != name]
73
+ self.secrets.pop(name, None)
74
+ self.acls.pop(name, None)
75
+ self.readable.discard(name)
76
+ self.values = {k: v for k, v in self.values.items() if k[0] != name}