isolinear 0.1.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.
isolinear/__init__.py ADDED
File without changes
isolinear/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .app import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
isolinear/app.py ADDED
@@ -0,0 +1,71 @@
1
+ """Isolinear — 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 `isolinear.domain`, use-cases in
6
+ `isolinear.application`, adapters in `isolinear.infrastructure`; UI in
7
+ `isolinear.interface`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from textual.app import App
13
+ from textual.binding import Binding
14
+
15
+ from .application import OnboardingService, WorkspaceService
16
+ from .infrastructure import DatabricksCfgProfileStore, DatabricksConnector
17
+ from .interface.screens.main import MainScreen
18
+ from .interface.theme import ISOLINEAR_THEMES
19
+
20
+
21
+ class IsolinearApp(App[None]):
22
+ CSS_PATH = "styles.tcss"
23
+ TITLE = "Isolinear"
24
+ BINDINGS = [Binding("q", "quit", "Quit")]
25
+
26
+ def __init__(
27
+ self,
28
+ onboarding: OnboardingService | None = None,
29
+ session: WorkspaceService | None = None,
30
+ ) -> None:
31
+ super().__init__()
32
+ self._onboarding = onboarding
33
+ self._initial_session = session
34
+
35
+ def on_mount(self) -> None:
36
+ for theme in ISOLINEAR_THEMES:
37
+ self.register_theme(theme)
38
+ self.theme = "isolinear"
39
+ onboarding = self._onboarding or OnboardingService(
40
+ DatabricksConnector(), DatabricksCfgProfileStore()
41
+ )
42
+ self.push_screen(MainScreen(onboarding, self._initial_session))
43
+
44
+
45
+ _USAGE = """\
46
+ isolinear — a keyboard-driven terminal UI for managing Databricks secrets.
47
+
48
+ usage: isolinear [--version] [--help]
49
+
50
+ Run with no arguments to launch the TUI. Inside: ? for help, ctrl+p for the
51
+ command palette, q to quit.
52
+ """
53
+
54
+
55
+ def main() -> None:
56
+ import sys
57
+
58
+ args = sys.argv[1:]
59
+ if {"-V", "--version"} & set(args):
60
+ from importlib.metadata import version
61
+
62
+ print(f"isolinear {version('isolinear')}")
63
+ return
64
+ if {"-h", "--help"} & set(args):
65
+ print(_USAGE, end="")
66
+ return
67
+ IsolinearApp().run()
68
+
69
+
70
+ if __name__ == "__main__":
71
+ main()
@@ -0,0 +1,16 @@
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 .onboarding import Connection, OnboardingService
8
+ from .read_model import WorkspaceCache
9
+ from .workspace import WorkspaceService
10
+
11
+ __all__ = [
12
+ "Connection",
13
+ "OnboardingService",
14
+ "WorkspaceCache",
15
+ "WorkspaceService",
16
+ ]
@@ -0,0 +1,64 @@
1
+ """OnboardingService — connection/login use-cases.
2
+
3
+ Depends only on domain ports (`WorkspaceConnector`, `ProfileStore`); the
4
+ composition root injects concrete infrastructure adapters. Produces ready-to-use
5
+ `WorkspaceService` instances so the UI never touches the store or the SDK.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+ from ..domain import (
13
+ AccountSession,
14
+ AccountWorkspace,
15
+ ProfileStore,
16
+ Workspace,
17
+ WorkspaceConnector,
18
+ )
19
+ from .workspace import WorkspaceService
20
+
21
+
22
+ @dataclass
23
+ class Connection:
24
+ """A live workspace session plus what's needed to persist it as a profile."""
25
+
26
+ service: WorkspaceService
27
+ host: str = ""
28
+ account_id: str | None = None
29
+
30
+
31
+ class OnboardingService:
32
+ def __init__(self, connector: WorkspaceConnector, profiles: ProfileStore) -> None:
33
+ self._connector = connector
34
+ self._profiles = profiles
35
+
36
+ # -- saved profiles -------------------------------------------------
37
+ def saved_workspaces(self) -> list[Workspace]:
38
+ return self._profiles.discover()
39
+
40
+ def save_profile(self, name: str, host: str, account_id: str | None = None) -> None:
41
+ self._profiles.save(name, host, account_id)
42
+
43
+ # -- connection use-cases -------------------------------------------
44
+ def connect_profile(self, profile: str) -> Connection:
45
+ c = self._connector.connect_profile(profile)
46
+ return Connection(WorkspaceService(c.store, c.label))
47
+
48
+ def connect_url(self, host: str) -> Connection:
49
+ c = self._connector.connect_url(host)
50
+ return Connection(WorkspaceService(c.store, c.label), host=c.host)
51
+
52
+ def discover_account(self, cloud_key: str, account_id: str) -> AccountSession:
53
+ return self._connector.discover_account(cloud_key, account_id)
54
+
55
+ def connect_account_workspace(
56
+ self,
57
+ session: AccountSession,
58
+ ws: AccountWorkspace,
59
+ account_id: str | None = None,
60
+ ) -> Connection:
61
+ c = session.connect(ws)
62
+ return Connection(
63
+ WorkspaceService(c.store, c.label), host=c.host, account_id=account_id
64
+ )
@@ -0,0 +1,73 @@
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
+
31
+ scopes_loaded: bool = False
32
+ warm_error: str = ""
33
+
34
+ # -- lookups ------------------------------------------------------------
35
+ def secrets_for(self, scope: str) -> list[Secret]:
36
+ return self.secrets.get(scope, [])
37
+
38
+ def acls_for(self, scope: str) -> list[Acl]:
39
+ return self.acls.get(scope, [])
40
+
41
+ def cached_value(self, scope: str, key: str) -> str | None:
42
+ return self.values.get((scope, key))
43
+
44
+ def set_value(self, scope: str, key: str, value: str) -> None:
45
+ self.values[(scope, key)] = value
46
+
47
+ # -- mutation keeping the read model + UI consistent --------------------
48
+ def upsert_secret(self, secret: Secret) -> None:
49
+ rows = self.secrets.setdefault(secret.scope, [])
50
+ for i, existing in enumerate(rows):
51
+ if existing.key == secret.key:
52
+ rows[i] = secret
53
+ break
54
+ else:
55
+ rows.append(secret)
56
+ rows.sort(key=lambda s: s.key.lower())
57
+
58
+ def remove_secret(self, scope: str, key: str) -> None:
59
+ self.secrets[scope] = [s for s in self.secrets.get(scope, []) if s.key != key]
60
+ self.values.pop((scope, key), None)
61
+
62
+ def add_scope(self, scope: Scope) -> None:
63
+ if not any(s.name == scope.name for s in self.scopes):
64
+ self.scopes.append(scope)
65
+ self.scopes.sort(key=lambda s: s.name.lower())
66
+ self.secrets.setdefault(scope.name, [])
67
+ self.acls.setdefault(scope.name, [])
68
+
69
+ def remove_scope(self, name: str) -> None:
70
+ self.scopes = [s for s in self.scopes if s.name != name]
71
+ self.secrets.pop(name, None)
72
+ self.acls.pop(name, None)
73
+ self.values = {k: v for k, v in self.values.items() if k[0] != name}
@@ -0,0 +1,141 @@
1
+ """WorkspaceService — the application service / use-case layer.
2
+
3
+ Orchestrates a `SecretStore` (domain port) and the read model, exposing every
4
+ operation the UI needs as a plain, synchronous method. All store/cache
5
+ coordination lives here, so:
6
+
7
+ * the UI just calls a method and renders the result (no business logic), and
8
+ * the whole layer is unit-testable with a fake store and no event loop.
9
+
10
+ The UI runs these (blocking) methods in worker threads.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Iterator
16
+
17
+ from ..domain import (
18
+ Acl,
19
+ AuthSummary,
20
+ Identity,
21
+ Scope,
22
+ Secret,
23
+ SecretStore,
24
+ StoreError,
25
+ authorization_summary,
26
+ )
27
+ from .read_model import WorkspaceCache
28
+
29
+
30
+ class WorkspaceService:
31
+ def __init__(
32
+ self, store: SecretStore, label: str, cache: WorkspaceCache | None = None
33
+ ) -> None:
34
+ self._store = store
35
+ # The read model is injectable (decoupled), defaulting to a fresh one.
36
+ self.cache = cache or WorkspaceCache(label=label)
37
+
38
+ @property
39
+ def label(self) -> str:
40
+ return self.cache.label
41
+
42
+ @property
43
+ def identity(self) -> Identity:
44
+ return self.cache.identity
45
+
46
+ @property
47
+ def scopes(self) -> list[Scope]:
48
+ return self.cache.scopes
49
+
50
+ # -- connection / warming -------------------------------------------
51
+ def authenticate(self) -> Identity:
52
+ identity = self._store.whoami()
53
+ self.cache.identity = identity
54
+ return identity
55
+
56
+ def load_scopes(self) -> list[Scope]:
57
+ scopes = self._store.list_scopes()
58
+ self.cache.scopes = scopes
59
+ self.cache.scopes_loaded = True
60
+ return scopes
61
+
62
+ def warm_scope(self, scope: str) -> None:
63
+ """Pull secret metadata + ACLs for one scope into the read model."""
64
+ try:
65
+ self.cache.secrets[scope] = self._store.list_secrets(scope)
66
+ self.cache.acls[scope] = self._store.list_acls(scope)
67
+ except StoreError:
68
+ self.cache.secrets.setdefault(scope, [])
69
+ self.cache.acls.setdefault(scope, [])
70
+
71
+ def warm_all(self) -> Iterator[tuple[int, int, Scope]]:
72
+ """Warm every scope, yielding (index, total, scope) for progress."""
73
+ total = len(self.cache.scopes)
74
+ for i, scope in enumerate(self.cache.scopes, 1):
75
+ self.warm_scope(scope.name)
76
+ yield i, total, scope
77
+
78
+ # -- reads ----------------------------------------------------------
79
+ def secrets_for(self, scope: str) -> list[Secret]:
80
+ return self.cache.secrets_for(scope)
81
+
82
+ def acls_for(self, scope: str) -> list[Acl]:
83
+ return self.cache.acls_for(scope)
84
+
85
+ def scope(self, name: str) -> Scope | None:
86
+ return next((s for s in self.cache.scopes if s.name == name), None)
87
+
88
+ def secret(self, scope: str, key: str) -> Secret | None:
89
+ return next((s for s in self.cache.secrets_for(scope) if s.key == key), None)
90
+
91
+ def cached_value(self, scope: str, key: str) -> str | None:
92
+ return self.cache.cached_value(scope, key)
93
+
94
+ def reveal(self, scope: str, key: str) -> str:
95
+ """Return the secret value, fetching+caching it on first access."""
96
+ cached = self.cache.cached_value(scope, key)
97
+ if cached is not None:
98
+ return cached
99
+ value = self._store.get_secret_value(scope, key)
100
+ self.cache.set_value(scope, key, value)
101
+ return value
102
+
103
+ # -- mutations ------------------------------------------------------
104
+ def put_secret(self, scope: str, key: str, value: str) -> None:
105
+ self._store.put_secret(scope, key, value)
106
+ try: # refresh metadata so the timestamp is accurate
107
+ self.cache.secrets[scope] = self._store.list_secrets(scope)
108
+ except StoreError:
109
+ self.cache.upsert_secret(Secret(scope=scope, key=key))
110
+ self.cache.set_value(scope, key, value)
111
+
112
+ def delete_secret(self, scope: str, key: str) -> None:
113
+ self._store.delete_secret(scope, key)
114
+ self.cache.remove_secret(scope, key)
115
+
116
+ def create_scope(self, name: str) -> None:
117
+ self._store.create_scope(name)
118
+ self.cache.add_scope(Scope(name=name))
119
+
120
+ def delete_scope(self, name: str) -> None:
121
+ self._store.delete_scope(name)
122
+ self.cache.remove_scope(name)
123
+
124
+ def refresh_scope(self, scope: str) -> None:
125
+ self.warm_scope(scope)
126
+
127
+ # -- scope permissions / ACLs (US-11 update, US-12) -----------------
128
+ def set_acl(self, scope: str, principal: str, permission: str) -> None:
129
+ """Grant or change a principal's permission (put_acl is an upsert)."""
130
+ self._store.put_acl(scope, principal, permission)
131
+ self.cache.acls[scope] = self._store.list_acls(scope)
132
+
133
+ def remove_acl(self, scope: str, principal: str) -> None:
134
+ self._store.delete_acl(scope, principal)
135
+ self.cache.acls[scope] = self._store.list_acls(scope)
136
+
137
+ # -- authorization overview ----------------------------------------
138
+ def auth_summary(self) -> list[AuthSummary]:
139
+ return authorization_summary(
140
+ self.cache.identity, self.cache.scopes, self.cache.acls
141
+ )
@@ -0,0 +1,46 @@
1
+ """Domain layer — the model, the rules, and the ports. Pure: no Textual, no SDK,
2
+ no asyncio.
3
+
4
+ Everything here is exercisable in a plain unit test. Infrastructure implements
5
+ the ports defined here; the application orchestrates these pieces.
6
+ """
7
+
8
+ from .errors import AuthError, StoreError
9
+ from .host import normalize_host
10
+ from .models import (
11
+ CLOUDS,
12
+ AccountWorkspace,
13
+ Acl,
14
+ Cloud,
15
+ Identity,
16
+ Scope,
17
+ Secret,
18
+ Workspace,
19
+ cloud_by_key,
20
+ )
21
+ from .permissions import AuthSummary, authorization_summary, perm_rank
22
+ from .ports import AccountSession, Connected, ProfileStore, WorkspaceConnector
23
+ from .secret_store import SecretStore
24
+
25
+ __all__ = [
26
+ "CLOUDS",
27
+ "AccountSession",
28
+ "AccountWorkspace",
29
+ "Acl",
30
+ "AuthError",
31
+ "AuthSummary",
32
+ "Cloud",
33
+ "Connected",
34
+ "Identity",
35
+ "ProfileStore",
36
+ "Scope",
37
+ "Secret",
38
+ "SecretStore",
39
+ "StoreError",
40
+ "Workspace",
41
+ "WorkspaceConnector",
42
+ "authorization_summary",
43
+ "cloud_by_key",
44
+ "normalize_host",
45
+ "perm_rank",
46
+ ]
@@ -0,0 +1,12 @@
1
+ """Domain-level error types — abstractions the application catches without
2
+ knowing (or caring) that the backend is Databricks."""
3
+
4
+ from __future__ import annotations
5
+
6
+
7
+ class StoreError(Exception):
8
+ """A secret-store operation failed; carries a UI-friendly message."""
9
+
10
+
11
+ class AuthError(Exception):
12
+ """Login / workspace-discovery failure; carries a UI-friendly message."""
@@ -0,0 +1,15 @@
1
+ """Host — a small domain rule about workspace URLs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .errors import AuthError
6
+
7
+
8
+ def normalize_host(host: str) -> str:
9
+ """Canonicalize a workspace URL; reject an empty one."""
10
+ host = (host or "").strip()
11
+ if not host:
12
+ raise AuthError("Workspace URL is required.")
13
+ if not host.startswith(("http://", "https://")):
14
+ host = "https://" + host
15
+ return host.rstrip("/")
@@ -0,0 +1,102 @@
1
+ """Domain model — the ubiquitous language of Isolinear as plain value objects.
2
+
3
+ No Textual, no Databricks SDK, no I/O. These are the nouns the whole app speaks:
4
+ workspaces, scopes, secrets, ACLs, identity.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from datetime import UTC, datetime
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Workspace:
15
+ """A connection target — one profile from ~/.databrickscfg."""
16
+
17
+ profile: str
18
+ host: str
19
+
20
+ @property
21
+ def label(self) -> str:
22
+ host = self.host.replace("https://", "").replace("http://", "").rstrip("/")
23
+ return f"{self.profile} · {host}" if host else self.profile
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Cloud:
28
+ key: str # aws | azure | gcp
29
+ label: str
30
+ account_host: str
31
+
32
+
33
+ CLOUDS: list[Cloud] = [
34
+ Cloud("aws", "AWS", "https://accounts.cloud.databricks.com"),
35
+ Cloud("azure", "Azure", "https://accounts.azuredatabricks.net"),
36
+ Cloud("gcp", "GCP", "https://accounts.gcp.databricks.com"),
37
+ ]
38
+
39
+
40
+ def cloud_by_key(key: str) -> Cloud:
41
+ return next(c for c in CLOUDS if c.key == key)
42
+
43
+
44
+ @dataclass
45
+ class AccountWorkspace:
46
+ """A workspace discovered via the account-level API (US-1, discovery)."""
47
+
48
+ workspace_id: int
49
+ name: str
50
+ deployment_name: str = ""
51
+ status: str = ""
52
+ cloud: str = ""
53
+ raw: object = None # original SDK provisioning.Workspace, for get_workspace_client
54
+
55
+ @property
56
+ def label(self) -> str:
57
+ status = f" · {self.status}" if self.status else ""
58
+ return f"{self.name} · id {self.workspace_id}{status}"
59
+
60
+
61
+ @dataclass
62
+ class Scope:
63
+ name: str
64
+ backend_type: str = "DATABRICKS"
65
+
66
+ @property
67
+ def is_keyvault(self) -> bool:
68
+ return self.backend_type == "AZURE_KEYVAULT"
69
+
70
+ @property
71
+ def icon(self) -> str:
72
+ return "☁" if self.is_keyvault else "🔒"
73
+
74
+
75
+ @dataclass
76
+ class Secret:
77
+ scope: str
78
+ key: str
79
+ last_updated_ms: int | None = None
80
+
81
+ @property
82
+ def last_updated(self) -> str:
83
+ if not self.last_updated_ms:
84
+ return "—"
85
+ dt = datetime.fromtimestamp(self.last_updated_ms / 1000, tz=UTC)
86
+ return dt.strftime("%Y-%m-%d %H:%M")
87
+
88
+
89
+ @dataclass
90
+ class Acl:
91
+ principal: str
92
+ permission: str # READ | WRITE | MANAGE
93
+
94
+
95
+ @dataclass
96
+ class Identity:
97
+ """Who we are authenticated as in a workspace."""
98
+
99
+ user_name: str = ""
100
+ display_name: str = ""
101
+ authenticated: bool = False
102
+ error: str = ""
@@ -0,0 +1,58 @@
1
+ """Authorization rules — a domain service.
2
+
3
+ The "what can this principal do" logic that used to live on the cache. It's a
4
+ business rule about scopes + ACLs + identity, so it belongs in the domain.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Mapping
10
+ from dataclasses import dataclass
11
+
12
+ from .models import Acl, Identity, Scope
13
+
14
+ _PERM_RANK = {"READ": 1, "WRITE": 2, "MANAGE": 3}
15
+
16
+
17
+ def perm_rank(permission: str) -> int:
18
+ return _PERM_RANK.get(permission, 0)
19
+
20
+
21
+ @dataclass
22
+ class AuthSummary:
23
+ """Per-scope authorization picture for the authorization overview screen."""
24
+
25
+ scope: str
26
+ effective: str = "—" # current user's effective permission
27
+ acl_count: int = 0
28
+ can_write: bool = False
29
+ can_manage: bool = False
30
+
31
+
32
+ def authorization_summary(
33
+ identity: Identity,
34
+ scopes: list[Scope],
35
+ acls_by_scope: Mapping[str, list[Acl]],
36
+ ) -> list[AuthSummary]:
37
+ """Compute the current user's effective permission on each scope (US-13)."""
38
+ me = identity.user_name
39
+ summaries: list[AuthSummary] = []
40
+ for scope in scopes:
41
+ acls = acls_by_scope.get(scope.name, [])
42
+ effective = "—"
43
+ best = 0
44
+ for acl in acls:
45
+ is_mine = acl.principal in (me, "users", "admins")
46
+ if is_mine and perm_rank(acl.permission) > best:
47
+ best = perm_rank(acl.permission)
48
+ effective = acl.permission
49
+ summaries.append(
50
+ AuthSummary(
51
+ scope=scope.name,
52
+ effective=effective,
53
+ acl_count=len(acls),
54
+ can_write=best >= perm_rank("WRITE"),
55
+ can_manage=best >= perm_rank("MANAGE"),
56
+ )
57
+ )
58
+ return summaries
@@ -0,0 +1,50 @@
1
+ """Ports — the domain's outbound contracts for all I/O.
2
+
3
+ Infrastructure implements these; the application depends only on them. Together
4
+ with `SecretStore` (in secret_store.py) they are every door out of the domain.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import Protocol, runtime_checkable
11
+
12
+ from .models import AccountWorkspace, Workspace
13
+ from .secret_store import SecretStore
14
+
15
+
16
+ @dataclass
17
+ class Connected:
18
+ """The result of establishing a connection: a store plus how to label and
19
+ (optionally) persist it."""
20
+
21
+ store: SecretStore
22
+ label: str
23
+ host: str = ""
24
+
25
+
26
+ @runtime_checkable
27
+ class AccountSession(Protocol):
28
+ """A live account-discovery session — lists workspaces and connects to one,
29
+ reusing a single authenticated session."""
30
+
31
+ @property
32
+ def workspaces(self) -> list[AccountWorkspace]: ...
33
+ def connect(self, ws: AccountWorkspace) -> Connected: ...
34
+
35
+
36
+ @runtime_checkable
37
+ class WorkspaceConnector(Protocol):
38
+ """Establishes connections to a secret backend (login / discovery)."""
39
+
40
+ def connect_profile(self, profile: str) -> Connected: ...
41
+ def connect_url(self, host: str) -> Connected: ...
42
+ def discover_account(self, cloud_key: str, account_id: str) -> AccountSession: ...
43
+
44
+
45
+ @runtime_checkable
46
+ class ProfileStore(Protocol):
47
+ """Reads and writes saved connection profiles."""
48
+
49
+ def discover(self) -> list[Workspace]: ...
50
+ def save(self, name: str, host: str, account_id: str | None = None) -> None: ...