clue2app-cli 0.2.7__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,316 @@
1
+ """Reusable data fetching layer for TUI.
2
+
3
+ Provides async-compatible data fetching functions that can be used
4
+ by both Textual and ncurses TUI implementations.
5
+ """
6
+
7
+ from typing import Any, Dict, List, Optional, Generator
8
+ from dataclasses import dataclass
9
+ from enum import Enum
10
+
11
+
12
+ class AppStatus(Enum):
13
+ """Application runtime status."""
14
+ RUNNING = "Running"
15
+ SCALED_TO_ZERO = "Scaled to Zero"
16
+ STOPPED = "Stopped"
17
+ BUILDING = "Building"
18
+ FAILED = "Failed"
19
+ UNKNOWN = "Unknown"
20
+
21
+
22
+ class BuildStatus(Enum):
23
+ """Application build status."""
24
+ READY = "Ready"
25
+ BUILDING = "Building"
26
+ FAILED = "Failed"
27
+ UNKNOWN = "Unknown"
28
+
29
+
30
+ @dataclass
31
+ class AppInfo:
32
+ """Unified app information combining build and runtime data."""
33
+ name: str
34
+ display_name: str
35
+ namespace: str
36
+ project_id: Optional[str]
37
+ project_name: Optional[str]
38
+
39
+ # Build info
40
+ build_status: BuildStatus
41
+ build_count: int
42
+ build_reason: Optional[str]
43
+ stack: Optional[str]
44
+
45
+ # Source info
46
+ git_url: Optional[str]
47
+ git_revision: Optional[str]
48
+
49
+ # Runtime info
50
+ runtime_status: AppStatus
51
+ pod_count: int
52
+ url: Optional[str]
53
+
54
+ # Image info
55
+ latest_image: Optional[str]
56
+
57
+
58
+ @dataclass
59
+ class ProjectInfo:
60
+ """Project information."""
61
+ id: str
62
+ name: str
63
+ namespace: str
64
+ app_count: int
65
+
66
+
67
+ class DataProvider:
68
+ """Reusable data fetching for TUI.
69
+
70
+ This class provides methods to fetch and transform data from the SDK
71
+ into formats suitable for TUI display.
72
+ """
73
+
74
+ UNLINKED_PROJECT_ID = "00000000-0000-0000-0000-000000000000"
75
+
76
+ def __init__(self):
77
+ from clue2app_cli.sdk import Clue2AppSDK
78
+ self.sdk = Clue2AppSDK()
79
+ self._project_cache: Dict[str, ProjectInfo] = {}
80
+
81
+ def get_projects(self) -> List[ProjectInfo]:
82
+ """Get all projects."""
83
+ # Use suppress_errors=False so we get exceptions we can catch
84
+ projects = self.sdk.list_projects(suppress_errors=False)
85
+
86
+ result = []
87
+ for p in projects:
88
+ info = ProjectInfo(
89
+ id=p.get('id', ''),
90
+ name=p.get('name', 'Unknown'),
91
+ namespace=p.get('namespace', ''),
92
+ app_count=0 # Will be populated when we fetch apps
93
+ )
94
+ self._project_cache[info.id] = info
95
+ result.append(info)
96
+ return result
97
+
98
+ def get_project(self, project_id: str) -> Optional[ProjectInfo]:
99
+ """Get a single project by ID."""
100
+ if project_id in self._project_cache:
101
+ return self._project_cache[project_id]
102
+
103
+ try:
104
+ project = self.sdk.get_project(project_id, quiet=True)
105
+ if project:
106
+ info = ProjectInfo(
107
+ id=project.get('id', ''),
108
+ name=project.get('name', 'Unknown'),
109
+ namespace=project.get('namespace', ''),
110
+ app_count=0
111
+ )
112
+ self._project_cache[info.id] = info
113
+ return info
114
+ except Exception:
115
+ # Silently fail - project lookup is non-critical
116
+ pass
117
+ return None
118
+
119
+ def get_apps(self, project_id: Optional[str] = None, include_unlinked: bool = False) -> List[AppInfo]:
120
+ """Get apps with merged build and runtime info.
121
+
122
+ Args:
123
+ project_id: Filter by project ID (None for all projects in context)
124
+ include_unlinked: Include apps not linked to any project
125
+ """
126
+ # Fetch apps from SDK (suppress_errors=False to get exceptions we can catch)
127
+ if include_unlinked:
128
+ apps = self.sdk.list_apps(use_context=False, unlinked_only=True, suppress_errors=False)
129
+ elif project_id:
130
+ apps = self.sdk.list_apps(project_id=project_id, use_context=True, suppress_errors=False)
131
+ else:
132
+ apps = self.sdk.list_apps(use_context=False, suppress_errors=False)
133
+
134
+ # Fetch runtime metrics
135
+ metrics = self.sdk.get_app_runtime_metrics(project_id=project_id)
136
+ metrics_map = {}
137
+ for m in metrics:
138
+ svc_name = m.get('serviceName', '')
139
+ metrics_map[svc_name] = m
140
+
141
+ # Merge and transform
142
+ result = []
143
+ for app in apps:
144
+ info = self._transform_app(app, metrics_map)
145
+ result.append(info)
146
+
147
+ return result
148
+
149
+ def _transform_app(self, app: Dict[str, Any], metrics_map: Dict[str, Any]) -> AppInfo:
150
+ """Transform raw app data into AppInfo."""
151
+ meta = app.get('metadata', {})
152
+ spec = app.get('spec', {})
153
+ status = app.get('status', {})
154
+ labels = meta.get('labels', {})
155
+ git = spec.get('source', {}).get('git', {})
156
+
157
+ name = meta.get('name', '')
158
+ display_name = meta.get('displayName', name)
159
+
160
+ # Get project info
161
+ app_group_id = labels.get('appGroupId', '')
162
+ project_name = None
163
+ if app_group_id and app_group_id != self.UNLINKED_PROJECT_ID:
164
+ project = self.get_project(app_group_id)
165
+ if project:
166
+ project_name = project.name
167
+
168
+ # Determine build status
169
+ conditions = status.get('conditions', [])
170
+ build_status = BuildStatus.UNKNOWN
171
+ for cond in conditions:
172
+ if cond.get('type') == 'Ready':
173
+ if cond.get('status') == 'True':
174
+ build_status = BuildStatus.READY
175
+ elif cond.get('status') == 'False':
176
+ build_status = BuildStatus.FAILED
177
+ break
178
+
179
+ if status.get('buildCounter', 0) > 0 and build_status == BuildStatus.UNKNOWN:
180
+ # Check if currently building
181
+ latest_build = status.get('latestBuildRef', '')
182
+ if latest_build:
183
+ build_status = BuildStatus.BUILDING
184
+
185
+ # Get runtime info from metrics
186
+ runtime_status = AppStatus.UNKNOWN
187
+ pod_count = 0
188
+ url = None
189
+
190
+ # Try to match by name (strip timestamp prefix if present)
191
+ base_name = name
192
+ if '-' in name:
193
+ parts = name.split('-', 1)
194
+ if parts[0].isdigit():
195
+ base_name = parts[1]
196
+
197
+ metric = metrics_map.get(name) or metrics_map.get(base_name) or metrics_map.get(display_name)
198
+ if metric:
199
+ ready_replicas = metric.get('readyReplicas', 0)
200
+ desired_replicas = metric.get('desiredReplicas', 0)
201
+
202
+ if ready_replicas > 0:
203
+ runtime_status = AppStatus.RUNNING
204
+ elif desired_replicas == 0:
205
+ runtime_status = AppStatus.SCALED_TO_ZERO
206
+ else:
207
+ runtime_status = AppStatus.STOPPED
208
+
209
+ pod_count = ready_replicas
210
+ url = metric.get('url')
211
+
212
+ return AppInfo(
213
+ name=name,
214
+ display_name=display_name,
215
+ namespace=meta.get('namespace', ''),
216
+ project_id=app_group_id if app_group_id != self.UNLINKED_PROJECT_ID else None,
217
+ project_name=project_name,
218
+ build_status=build_status,
219
+ build_count=status.get('buildCounter', 0),
220
+ build_reason=status.get('latestBuildReason', ''),
221
+ stack=status.get('stack', {}).get('id', ''),
222
+ git_url=git.get('url'),
223
+ git_revision=git.get('revision', 'main'),
224
+ runtime_status=runtime_status,
225
+ pod_count=pod_count,
226
+ url=url,
227
+ latest_image=status.get('latestImage', '')
228
+ )
229
+
230
+ def get_app_details(self, app_name: str) -> Optional[AppInfo]:
231
+ """Get detailed info for a single app."""
232
+ app = self.sdk.get_app(app_name)
233
+ if not app:
234
+ return None
235
+
236
+ # Get runtime metrics for this app
237
+ meta = app.get('metadata', {})
238
+ labels = meta.get('labels', {})
239
+ project_id = labels.get('appGroupId')
240
+
241
+ metrics = self.sdk.get_app_runtime_metrics(project_id=project_id)
242
+ metrics_map = {m.get('serviceName', ''): m for m in metrics}
243
+
244
+ return self._transform_app(app, metrics_map)
245
+
246
+ def stream_logs(self, app_name: str, lines: int = 100) -> Generator[str, None, None]:
247
+ """Stream instance logs for an app.
248
+
249
+ Yields log lines as they become available.
250
+ """
251
+ yield from self.sdk.get_instance_logs(app_name, lines=lines, follow=True)
252
+
253
+ def get_instance_logs(self, app_name: str, lines: int = 100) -> str:
254
+ """Get recent instance (runtime) logs for an app."""
255
+ try:
256
+ return self.sdk.get_instance_logs(app_name, lines=lines, follow=False)
257
+ except Exception as e:
258
+ return f"Error getting instance logs: {e}"
259
+
260
+ def get_build_logs(self, app_name: str) -> str:
261
+ """Get build logs for the latest build of an app."""
262
+ return self.sdk.get_latest_build_logs(app_name)
263
+
264
+ def get_logs(self, app_name: str, lines: int = 100, log_type: str = "auto") -> str:
265
+ """Get logs for an app.
266
+
267
+ Args:
268
+ app_name: The application name
269
+ lines: Number of lines for instance logs
270
+ log_type: "instance", "build", or "auto" (tries instance first, falls back to build)
271
+
272
+ Returns:
273
+ Log content string
274
+ """
275
+ if log_type == "build":
276
+ return self.get_build_logs(app_name)
277
+ elif log_type == "instance":
278
+ return self.get_instance_logs(app_name, lines)
279
+ else:
280
+ # Auto mode: try instance logs first, fall back to build logs
281
+ instance_logs = self.get_instance_logs(app_name, lines)
282
+ if instance_logs and not instance_logs.startswith("Error") and len(instance_logs.strip()) > 0:
283
+ return instance_logs
284
+ # No instance logs (probably scaled to zero), try build logs
285
+ build_logs = self.get_build_logs(app_name)
286
+ if build_logs:
287
+ return f"[No running instances - showing build logs]\n\n{build_logs}"
288
+ return "No logs available (no running instances and no build logs found)"
289
+
290
+ def rebuild_app(self, app_name: str) -> Optional[Dict[str, Any]]:
291
+ """Trigger app rebuild."""
292
+ return self.sdk.rebuild_app(app_name)
293
+
294
+ def link_app(self, app_name: str, project_id: str) -> Optional[Dict[str, Any]]:
295
+ """Link app to project."""
296
+ return self.sdk.link_app_to_project(app_name, project_id)
297
+
298
+ def delete_app(self, app_name: str) -> bool:
299
+ """Delete an app."""
300
+ return self.sdk.delete_app(app_name)
301
+
302
+ def restart_service(self, app_name: str, namespace: Optional[str] = None) -> Dict[str, Any]:
303
+ """Restart/wake up a service (useful for scaled-to-zero apps).
304
+
305
+ Returns:
306
+ Dict with 'success' bool and 'error' message if failed
307
+ """
308
+ return self.sdk.restart_service(app_name, namespace=namespace)
309
+
310
+ def get_current_project(self) -> Optional[ProjectInfo]:
311
+ """Get the currently selected project from context."""
312
+ from clue2app_cli.config import get_config_value
313
+ project_id = get_config_value("context.project.id")
314
+ if project_id:
315
+ return self.get_project(project_id)
316
+ return None
@@ -0,0 +1 @@
1
+ """TUI screens."""