recce-cloud 1.32.0__py3-none-any.whl → 1.33.1__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,380 @@
1
+ """
2
+ Diagnostic service for checking Recce Cloud setup and configuration.
3
+
4
+ This service contains the business logic for health checks, separated from
5
+ CLI presentation concerns.
6
+ """
7
+
8
+ import logging
9
+ import os
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime, timezone
12
+ from enum import Enum
13
+ from typing import Optional
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class CheckStatus(Enum):
19
+ """Status of a diagnostic check."""
20
+
21
+ PASS = "pass"
22
+ FAIL = "fail"
23
+ SKIP = "skip" # When check cannot be performed due to missing prerequisites
24
+
25
+
26
+ @dataclass
27
+ class CheckResult:
28
+ """Result of a single diagnostic check."""
29
+
30
+ status: CheckStatus
31
+ message: Optional[str] = None
32
+ suggestion: Optional[str] = None
33
+ details: dict = field(default_factory=dict)
34
+
35
+ @property
36
+ def passed(self) -> bool:
37
+ return self.status == CheckStatus.PASS
38
+
39
+ def to_dict(self) -> dict:
40
+ """Convert to dictionary for JSON serialization."""
41
+ result = {
42
+ "status": self.status.value,
43
+ "message": self.message,
44
+ "suggestion": self.suggestion,
45
+ }
46
+ result.update(self.details)
47
+ return result
48
+
49
+
50
+ @dataclass
51
+ class DiagnosticResults:
52
+ """Aggregated results of all diagnostic checks."""
53
+
54
+ login: CheckResult
55
+ project_binding: CheckResult
56
+ production_metadata: CheckResult
57
+ dev_session: CheckResult
58
+
59
+ @property
60
+ def all_passed(self) -> bool:
61
+ return all(
62
+ [
63
+ self.login.passed,
64
+ self.project_binding.passed,
65
+ self.production_metadata.passed,
66
+ self.dev_session.passed,
67
+ ]
68
+ )
69
+
70
+ @property
71
+ def passed_count(self) -> int:
72
+ return sum(
73
+ 1
74
+ for check in [self.login, self.project_binding, self.production_metadata, self.dev_session]
75
+ if check.passed
76
+ )
77
+
78
+ @property
79
+ def total_count(self) -> int:
80
+ return 4
81
+
82
+ def to_dict(self) -> dict:
83
+ """Convert to dictionary for JSON serialization."""
84
+ return {
85
+ "login": self.login.to_dict(),
86
+ "project_binding": self.project_binding.to_dict(),
87
+ "production_metadata": self.production_metadata.to_dict(),
88
+ "dev_session": self.dev_session.to_dict(),
89
+ "all_passed": self.all_passed,
90
+ }
91
+
92
+
93
+ class DiagnosticService:
94
+ """
95
+ Service for performing Recce Cloud diagnostic checks.
96
+
97
+ This service checks:
98
+ 1. Login status - Is the user authenticated?
99
+ 2. Project binding - Is the project configured?
100
+ 3. Production metadata - Does a production session exist?
101
+ 4. Dev session - Does a development session exist?
102
+ """
103
+
104
+ def __init__(self):
105
+ self._token: Optional[str] = None
106
+ self._org: Optional[str] = None
107
+ self._project: Optional[str] = None
108
+
109
+ def run_all_checks(self) -> DiagnosticResults:
110
+ """
111
+ Run all diagnostic checks and return aggregated results.
112
+
113
+ Returns:
114
+ DiagnosticResults containing the status of all checks.
115
+ """
116
+ login_result = self._check_login()
117
+ project_result = self._check_project_binding()
118
+
119
+ # Session checks depend on login and project binding
120
+ if login_result.passed and project_result.passed:
121
+ prod_result, dev_result = self._check_sessions()
122
+ else:
123
+ skip_message = "Cannot check - requires login and project binding"
124
+ prod_result = CheckResult(
125
+ status=CheckStatus.SKIP,
126
+ message=skip_message,
127
+ )
128
+ dev_result = CheckResult(
129
+ status=CheckStatus.SKIP,
130
+ message=skip_message,
131
+ )
132
+
133
+ return DiagnosticResults(
134
+ login=login_result,
135
+ project_binding=project_result,
136
+ production_metadata=prod_result,
137
+ dev_session=dev_result,
138
+ )
139
+
140
+ def _check_login(self) -> CheckResult:
141
+ """Check if user is logged in with a valid token."""
142
+ from recce_cloud.auth.login import check_login_status
143
+ from recce_cloud.auth.profile import get_api_token
144
+
145
+ self._token = os.getenv("RECCE_API_TOKEN") or get_api_token()
146
+
147
+ if not self._token:
148
+ return CheckResult(
149
+ status=CheckStatus.FAIL,
150
+ message="Not logged in",
151
+ suggestion="Run 'recce-cloud login' to authenticate",
152
+ )
153
+
154
+ is_logged_in, email = check_login_status()
155
+
156
+ if is_logged_in:
157
+ return CheckResult(
158
+ status=CheckStatus.PASS,
159
+ details={"email": email},
160
+ )
161
+ else:
162
+ return CheckResult(
163
+ status=CheckStatus.FAIL,
164
+ message="Token invalid or expired",
165
+ suggestion="Run 'recce-cloud login' to authenticate",
166
+ )
167
+
168
+ def _check_project_binding(self) -> CheckResult:
169
+ """Check if project is bound to Recce Cloud."""
170
+ from recce_cloud.config.project_config import get_project_binding
171
+
172
+ binding = get_project_binding()
173
+
174
+ if binding:
175
+ self._org = binding.get("org")
176
+ self._project = binding.get("project")
177
+ return CheckResult(
178
+ status=CheckStatus.PASS,
179
+ details={
180
+ "org": self._org,
181
+ "project": self._project,
182
+ "source": "config_file",
183
+ },
184
+ )
185
+
186
+ # Check environment variables as fallback
187
+ env_org = os.environ.get("RECCE_ORG")
188
+ env_project = os.environ.get("RECCE_PROJECT")
189
+
190
+ if env_org and env_project:
191
+ self._org = env_org
192
+ self._project = env_project
193
+ return CheckResult(
194
+ status=CheckStatus.PASS,
195
+ details={
196
+ "org": self._org,
197
+ "project": self._project,
198
+ "source": "env_vars",
199
+ },
200
+ )
201
+
202
+ return CheckResult(
203
+ status=CheckStatus.FAIL,
204
+ message="No project binding found",
205
+ suggestion="Run 'recce-cloud init' to bind this directory to a project",
206
+ )
207
+
208
+ def _check_sessions(self) -> tuple[CheckResult, CheckResult]:
209
+ """
210
+ Check for production and development sessions.
211
+
212
+ Returns:
213
+ Tuple of (production_result, dev_result)
214
+ """
215
+ from recce_cloud.api.client import RecceCloudClient
216
+ from recce_cloud.api.exceptions import RecceCloudException
217
+
218
+ try:
219
+ client = RecceCloudClient(self._token)
220
+
221
+ # Get org and project IDs
222
+ org_info = client.get_organization(self._org)
223
+ if not org_info:
224
+ raise RecceCloudException(f"Organization '{self._org}' not found", 404)
225
+ org_id = org_info.get("id")
226
+ if not org_id:
227
+ raise RecceCloudException(f"Organization '{self._org}' response missing ID", 500)
228
+
229
+ project_info = client.get_project(org_id, self._project)
230
+ if not project_info:
231
+ raise RecceCloudException(f"Project '{self._project}' not found", 404)
232
+ project_id = project_info.get("id")
233
+ if not project_id:
234
+ raise RecceCloudException(f"Project '{self._project}' response missing ID", 500)
235
+
236
+ # List sessions
237
+ sessions = client.list_sessions(org_id, project_id)
238
+
239
+ prod_session = None
240
+ dev_sessions = []
241
+
242
+ for s in sessions:
243
+ if s.get("is_base"):
244
+ prod_session = s
245
+ elif not s.get("pr_link"): # dev = not base and no PR link
246
+ dev_sessions.append(s)
247
+
248
+ # Check production
249
+ prod_result = self._evaluate_production_session(prod_session)
250
+
251
+ # Check dev
252
+ dev_result = self._evaluate_dev_sessions(dev_sessions)
253
+
254
+ return prod_result, dev_result
255
+
256
+ except RecceCloudException as e:
257
+ error_result = CheckResult(
258
+ status=CheckStatus.FAIL,
259
+ message=f"Failed to fetch sessions: {e}",
260
+ )
261
+ return error_result, error_result
262
+
263
+ except Exception as e:
264
+ logger.debug("Unexpected error during session check: %s", e, exc_info=True)
265
+ error_result = CheckResult(
266
+ status=CheckStatus.FAIL,
267
+ message=f"Unexpected error: {e}",
268
+ suggestion="Check your network connection and try again.",
269
+ )
270
+ return error_result, error_result
271
+
272
+ def _evaluate_production_session(self, prod_session: Optional[dict]) -> CheckResult:
273
+ """Evaluate the production session check."""
274
+ if not prod_session:
275
+ return CheckResult(
276
+ status=CheckStatus.FAIL,
277
+ message="No production artifacts found",
278
+ suggestion=(
279
+ "To upload production metadata:\n"
280
+ " 1. Check out your main branch:\n"
281
+ " $ git checkout main\n"
282
+ " 2. Generate and upload production artifacts:\n"
283
+ " $ dbt docs generate --target prod\n"
284
+ " $ recce-cloud upload --type prod"
285
+ ),
286
+ )
287
+
288
+ # Check if the session has actual data (adapter_type is not null)
289
+ # An empty session created by default will have adapter_type=null
290
+ if not prod_session.get("adapter_type"):
291
+ return CheckResult(
292
+ status=CheckStatus.FAIL,
293
+ message="Production session exists but has no data",
294
+ suggestion=(
295
+ "To upload production metadata:\n"
296
+ " 1. Check out your main branch:\n"
297
+ " $ git checkout main\n"
298
+ " 2. Generate and upload production artifacts:\n"
299
+ " $ dbt docs generate --target prod\n"
300
+ " $ recce-cloud upload --type prod"
301
+ ),
302
+ )
303
+
304
+ session_name = prod_session.get("name") or "(unnamed)"
305
+ uploaded_at = prod_session.get("updated_at") or prod_session.get("created_at")
306
+
307
+ return CheckResult(
308
+ status=CheckStatus.PASS,
309
+ details={
310
+ "session_name": session_name,
311
+ "uploaded_at": uploaded_at,
312
+ "relative_time": self._format_relative_time(uploaded_at),
313
+ },
314
+ )
315
+
316
+ def _evaluate_dev_sessions(self, dev_sessions: list) -> CheckResult:
317
+ """Evaluate the dev session check."""
318
+ if not dev_sessions:
319
+ return CheckResult(
320
+ status=CheckStatus.FAIL,
321
+ message="No dev session found",
322
+ suggestion=(
323
+ "To create and upload a dev session:\n"
324
+ " 1. Check out a feature branch with your changes:\n"
325
+ " $ git checkout -b my-feature-branch\n"
326
+ " (make some changes to your dbt models)\n"
327
+ " 2. Generate and upload dev artifacts:\n"
328
+ " $ dbt docs generate\n"
329
+ " $ recce-cloud upload --session-name my-feature-branch"
330
+ ),
331
+ )
332
+
333
+ # Sort by updated_at/created_at to get most recent
334
+ dev_sessions.sort(
335
+ key=lambda x: x.get("updated_at") or x.get("created_at") or "",
336
+ reverse=True,
337
+ )
338
+ latest_dev = dev_sessions[0]
339
+ session_name = latest_dev.get("name") or "(unnamed)"
340
+ uploaded_at = latest_dev.get("updated_at") or latest_dev.get("created_at")
341
+
342
+ return CheckResult(
343
+ status=CheckStatus.PASS,
344
+ details={
345
+ "session_name": session_name,
346
+ "uploaded_at": uploaded_at,
347
+ "relative_time": self._format_relative_time(uploaded_at),
348
+ },
349
+ )
350
+
351
+ @staticmethod
352
+ def _format_relative_time(iso_timestamp: Optional[str]) -> Optional[str]:
353
+ """Format an ISO timestamp as a human-readable relative time."""
354
+ if not iso_timestamp:
355
+ return None
356
+
357
+ try:
358
+ # Parse ISO timestamp
359
+ if iso_timestamp.endswith("Z"):
360
+ dt = datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00"))
361
+ else:
362
+ dt = datetime.fromisoformat(iso_timestamp)
363
+
364
+ now = datetime.now(timezone.utc)
365
+ diff = now - dt
366
+
367
+ seconds = diff.total_seconds()
368
+ if seconds < 60:
369
+ return "just now"
370
+ elif seconds < 3600:
371
+ mins = int(seconds / 60)
372
+ return f"{mins}m ago"
373
+ elif seconds < 86400:
374
+ hours = int(seconds / 3600)
375
+ return f"{hours}h ago"
376
+ else:
377
+ days = int(seconds / 86400)
378
+ return f"{days}d ago"
379
+ except (ValueError, TypeError):
380
+ return None
recce_cloud/upload.py CHANGED
@@ -2,14 +2,19 @@
2
2
  Upload helper functions for recce-cloud CLI.
3
3
  """
4
4
 
5
+ import logging
5
6
  import os
6
7
  import sys
7
8
 
9
+ import click
8
10
  import requests
9
11
 
10
12
  from recce_cloud.api.client import RecceCloudClient
11
13
  from recce_cloud.api.exceptions import RecceCloudException
12
14
  from recce_cloud.api.factory import create_platform_client
15
+ from recce_cloud.config.resolver import ConfigurationError, resolve_config
16
+
17
+ logger = logging.getLogger(__name__)
13
18
 
14
19
 
15
20
  def upload_to_existing_session(
@@ -225,3 +230,209 @@ def upload_with_platform_apis(
225
230
  console.print(f"Change request: {ci_info.cr_url}")
226
231
 
227
232
  sys.exit(0)
233
+
234
+
235
+ def upload_with_session_name(
236
+ console,
237
+ token: str,
238
+ session_name: str,
239
+ manifest_path: str,
240
+ catalog_path: str,
241
+ adapter_type: str,
242
+ target_path: str,
243
+ skip_confirmation: bool = False,
244
+ ):
245
+ """
246
+ Upload artifacts to a session identified by name.
247
+
248
+ If the session exists, uploads to it. If not, prompts to create a new session
249
+ (unless skip_confirmation is True, which auto-creates).
250
+
251
+ This workflow requires org/project configuration from either:
252
+ - Local config file (.recce/config) via 'recce-cloud init'
253
+ - Environment variables (RECCE_ORG, RECCE_PROJECT)
254
+ """
255
+ # 1. Resolve org/project configuration
256
+ console.rule("Session Name Resolution", style="blue")
257
+ try:
258
+ config = resolve_config()
259
+ org = config.org
260
+ project = config.project
261
+ console.print(f"[cyan]Organization:[/cyan] {org}")
262
+ console.print(f"[cyan]Project:[/cyan] {project}")
263
+ console.print(f"[cyan]Config Source:[/cyan] {config.source}")
264
+ except ConfigurationError as e:
265
+ console.print("[red]Error:[/red] Could not resolve org/project configuration")
266
+ console.print(f"Reason: {e}")
267
+ console.print()
268
+ console.print("To use --session-name, you need to either:")
269
+ console.print(" 1. Run 'recce-cloud init' to bind this directory to a project")
270
+ console.print(" 2. Set RECCE_ORG and RECCE_PROJECT environment variables")
271
+ sys.exit(2)
272
+
273
+ # 2. Initialize API client
274
+ try:
275
+ client = RecceCloudClient(token)
276
+ except Exception as e:
277
+ console.print("[red]Error:[/red] Failed to initialize API client")
278
+ console.print(f"Reason: {e}")
279
+ sys.exit(2)
280
+
281
+ # 3. Resolve org/project IDs (they might be slugs/names in config)
282
+ try:
283
+ org_info = client.get_organization(org)
284
+ if not org_info:
285
+ console.print(f"[red]Error:[/red] Organization '{org}' not found or you don't have access")
286
+ sys.exit(2)
287
+ org_id = org_info.get("id")
288
+ if not org_id:
289
+ console.print(f"[red]Error:[/red] Organization '{org}' response missing ID")
290
+ sys.exit(2)
291
+
292
+ project_info = client.get_project(org_id, project)
293
+ if not project_info:
294
+ console.print(f"[red]Error:[/red] Project '{project}' not found in organization '{org}'")
295
+ sys.exit(2)
296
+ project_id = project_info.get("id")
297
+ if not project_id:
298
+ console.print(f"[red]Error:[/red] Project '{project}' response missing ID")
299
+ sys.exit(2)
300
+ except RecceCloudException as e:
301
+ console.print("[red]Error:[/red] Failed to resolve organization/project")
302
+ console.print(f"Reason: {e.reason}")
303
+ sys.exit(2)
304
+ except Exception as e:
305
+ logger.debug("Failed to resolve organization/project: %s", e, exc_info=True)
306
+ console.print("[red]Error:[/red] Failed to resolve organization/project")
307
+ console.print(f" Reason: {e}")
308
+ console.print(" Check your authentication and network connection.")
309
+ sys.exit(2)
310
+
311
+ # 4. Look up session by name
312
+ console.print(f'Looking up session "{session_name}"...')
313
+ try:
314
+ existing_session = client.get_session_by_name(org_id, project_id, session_name)
315
+ except RecceCloudException as e:
316
+ console.print("[red]Error:[/red] Failed to look up session")
317
+ console.print(f"Reason: {e.reason}")
318
+ sys.exit(2)
319
+ except Exception as e:
320
+ logger.debug("Failed to look up session: %s", e, exc_info=True)
321
+ console.print("[red]Error:[/red] Failed to look up session")
322
+ console.print(f" Reason: {e}")
323
+ console.print(" Check your network connection and try again.")
324
+ sys.exit(2)
325
+
326
+ session_id = None
327
+ if existing_session:
328
+ # Session found, use it
329
+ session_id = existing_session.get("id")
330
+ console.print(f'[green]Found existing session:[/green] "{session_name}" (ID: {session_id})')
331
+ else:
332
+ # Session not found, prompt to create
333
+ console.print(f'[yellow]Session "{session_name}" not found[/yellow]')
334
+
335
+ if skip_confirmation:
336
+ # Auto-create with --yes flag
337
+ console.print("Creating new session (--yes flag specified)...")
338
+ else:
339
+ # Interactive confirmation
340
+ console.print()
341
+ if not click.confirm(f'Create new session "{session_name}"?', default=True):
342
+ console.print("[yellow]Upload cancelled[/yellow]")
343
+ sys.exit(0)
344
+
345
+ # Create the session
346
+ try:
347
+ new_session = client.create_session(
348
+ org_id=org_id,
349
+ project_id=project_id,
350
+ session_name=session_name,
351
+ adapter_type=adapter_type,
352
+ session_type="manual",
353
+ )
354
+ session_id = new_session.get("id")
355
+ console.print(f'[green]Created new session:[/green] "{session_name}" (ID: {session_id})')
356
+ except RecceCloudException as e:
357
+ console.print("[red]Error:[/red] Failed to create session")
358
+ console.print(f"Reason: {e.reason}")
359
+ sys.exit(4)
360
+ except Exception as e:
361
+ console.print("[red]Error:[/red] Failed to create session")
362
+ console.print(f"Reason: {e}")
363
+ sys.exit(4)
364
+
365
+ # 5. Get presigned URLs and upload
366
+ console.rule("Uploading Artifacts", style="blue")
367
+ try:
368
+ presigned_urls = client.get_upload_urls_by_session_id(org_id, project_id, session_id)
369
+ except RecceCloudException as e:
370
+ console.print("[red]Error:[/red] Failed to get upload URLs")
371
+ console.print(f"Reason: {e.reason}")
372
+ sys.exit(4)
373
+ except Exception as e:
374
+ console.print("[red]Error:[/red] Failed to get upload URLs")
375
+ console.print(f"Reason: {e}")
376
+ sys.exit(4)
377
+
378
+ # Upload manifest.json
379
+ console.print(f'Uploading manifest from path "{manifest_path}"')
380
+ try:
381
+ with open(manifest_path, "rb") as f:
382
+ response = requests.put(presigned_urls["manifest_url"], data=f.read())
383
+ if response.status_code not in [200, 204]:
384
+ raise Exception(f"Upload failed with status {response.status_code}: {response.text}")
385
+ except Exception as e:
386
+ console.print("[red]Error:[/red] Failed to upload manifest.json")
387
+ console.print(f"Reason: {e}")
388
+ sys.exit(4)
389
+
390
+ # Upload catalog.json
391
+ console.print(f'Uploading catalog from path "{catalog_path}"')
392
+ try:
393
+ with open(catalog_path, "rb") as f:
394
+ response = requests.put(presigned_urls["catalog_url"], data=f.read())
395
+ if response.status_code not in [200, 204]:
396
+ raise Exception(f"Upload failed with status {response.status_code}: {response.text}")
397
+ except Exception as e:
398
+ console.print("[red]Error:[/red] Failed to upload catalog.json")
399
+ console.print(f"Reason: {e}")
400
+ sys.exit(4)
401
+
402
+ # Update session metadata (if session already existed, update adapter_type)
403
+ if existing_session:
404
+ try:
405
+ client.update_session(org_id, project_id, session_id, adapter_type)
406
+ except RecceCloudException as e:
407
+ console.print("[yellow]Warning:[/yellow] Failed to update session metadata")
408
+ console.print(f"Reason: {e.reason}")
409
+ # Non-fatal for existing sessions
410
+ except Exception as e:
411
+ console.print("[yellow]Warning:[/yellow] Failed to update session metadata")
412
+ console.print(f"Reason: {e}")
413
+ # Non-fatal for existing sessions
414
+
415
+ # Notify upload completion
416
+ console.print("Notifying upload completion...")
417
+ try:
418
+ client.upload_completed(session_id)
419
+ except RecceCloudException as e:
420
+ console.print("[yellow]Warning:[/yellow] Failed to notify upload completion")
421
+ console.print(f"Reason: {e.reason}")
422
+ # Non-fatal, continue
423
+ except Exception as e:
424
+ console.print("[yellow]Warning:[/yellow] Failed to notify upload completion")
425
+ console.print(f"Reason: {e}")
426
+ # Non-fatal, continue
427
+
428
+ # Success!
429
+ console.rule("Uploaded Successfully", style="green")
430
+ console.print("Uploaded dbt artifacts to Recce Cloud")
431
+ console.print()
432
+ console.print(f"[cyan]Session Name:[/cyan] {session_name}")
433
+ console.print(f"[cyan]Session ID:[/cyan] {session_id}")
434
+ console.print(f"[cyan]Organization:[/cyan] {org}")
435
+ console.print(f"[cyan]Project:[/cyan] {project}")
436
+ console.print(f"[cyan]Artifacts from:[/cyan] {os.path.abspath(target_path)}")
437
+
438
+ sys.exit(0)