recce-nightly 1.2.0.20250506__py3-none-any.whl → 1.26.0.20251124__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.

Potentially problematic release.


This version of recce-nightly might be problematic. Click here for more details.

Files changed (213) hide show
  1. recce/VERSION +1 -1
  2. recce/__init__.py +27 -22
  3. recce/adapter/base.py +11 -14
  4. recce/adapter/dbt_adapter/__init__.py +810 -480
  5. recce/adapter/dbt_adapter/dbt_version.py +3 -0
  6. recce/adapter/sqlmesh_adapter.py +24 -35
  7. recce/apis/check_api.py +39 -28
  8. recce/apis/check_func.py +33 -27
  9. recce/apis/run_api.py +25 -19
  10. recce/apis/run_func.py +29 -23
  11. recce/artifact.py +119 -51
  12. recce/cli.py +1299 -323
  13. recce/config.py +42 -33
  14. recce/connect_to_cloud.py +138 -0
  15. recce/core.py +55 -47
  16. recce/data/404.html +1 -1
  17. recce/data/__next.__PAGE__.txt +10 -0
  18. recce/data/__next._full.txt +23 -0
  19. recce/data/__next._head.txt +8 -0
  20. recce/data/__next._index.txt +8 -0
  21. recce/data/__next._tree.txt +5 -0
  22. recce/data/_next/static/52aV_JrNUZU6dMFgvTQEO/_buildManifest.js +11 -0
  23. recce/data/_next/static/52aV_JrNUZU6dMFgvTQEO/_clientMiddlewareManifest.json +1 -0
  24. recce/data/_next/static/chunks/02b996c7f6a29a06.js +4 -0
  25. recce/data/_next/static/chunks/19c10d219a6a21ff.js +1 -0
  26. recce/data/_next/static/chunks/2df9ec28a061971d.js +11 -0
  27. recce/data/_next/static/chunks/3098c987393bda15.js +1 -0
  28. recce/data/_next/static/chunks/393dc43e483f717a.css +2 -0
  29. recce/data/_next/static/chunks/399e8d91a7e45073.js +2 -0
  30. recce/data/_next/static/chunks/4d0186f631230245.js +1 -0
  31. recce/data/_next/static/chunks/5794ba9e10a9c060.js +11 -0
  32. recce/data/_next/static/chunks/715761c929a3f28b.js +110 -0
  33. recce/data/_next/static/chunks/71f88fcc615bf282.js +1 -0
  34. recce/data/_next/static/chunks/80d2a95eaf1201ea.js +1 -0
  35. recce/data/_next/static/chunks/9979c6109bbbee35.js +1 -0
  36. recce/data/_next/static/chunks/99d638224186c118.js +1 -0
  37. recce/data/_next/static/chunks/d003eb36240e92f3.js +1 -0
  38. recce/data/_next/static/chunks/d3167cdfec4fc351.js +1 -0
  39. recce/data/_next/static/chunks/e124bccf574a3361.css +1 -0
  40. recce/data/_next/static/chunks/f40141db1bdb46f0.css +6 -0
  41. recce/data/_next/static/chunks/fcc53a88741a52f9.js +1 -0
  42. recce/data/_next/static/chunks/turbopack-b1920d28cfb1f28d.js +3 -0
  43. recce/data/_next/static/media/favicon.a8d38d84.ico +0 -0
  44. recce/data/_next/static/media/montserrat-cyrillic-800-normal.d80d830d.woff2 +0 -0
  45. recce/data/_next/static/media/montserrat-cyrillic-800-normal.f9d58125.woff +0 -0
  46. recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.076c2a93.woff2 +0 -0
  47. recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.a4fa76b5.woff +0 -0
  48. recce/data/_next/static/media/montserrat-latin-800-normal.cde454cc.woff2 +0 -0
  49. recce/data/_next/static/media/montserrat-latin-800-normal.d5761935.woff +0 -0
  50. recce/data/_next/static/media/montserrat-latin-ext-800-normal.40ec0659.woff2 +0 -0
  51. recce/data/_next/static/media/montserrat-latin-ext-800-normal.b671449b.woff +0 -0
  52. recce/data/_next/static/media/montserrat-vietnamese-800-normal.9f7b8541.woff +0 -0
  53. recce/data/_next/static/media/montserrat-vietnamese-800-normal.f9eb854e.woff2 +0 -0
  54. recce/data/_next/static/media/reload-image.7aa931c7.svg +4 -0
  55. recce/data/_not-found/__next._full.txt +17 -0
  56. recce/data/_not-found/__next._head.txt +8 -0
  57. recce/data/_not-found/__next._index.txt +8 -0
  58. recce/data/_not-found/__next._not-found.__PAGE__.txt +5 -0
  59. recce/data/_not-found/__next._not-found.txt +4 -0
  60. recce/data/_not-found/__next._tree.txt +3 -0
  61. recce/data/_not-found.html +1 -0
  62. recce/data/_not-found.txt +17 -0
  63. recce/data/auth_callback.html +68 -0
  64. recce/data/imgs/reload-image.svg +4 -0
  65. recce/data/index.html +1 -27
  66. recce/data/index.txt +23 -7
  67. recce/diff.py +6 -12
  68. recce/event/__init__.py +86 -74
  69. recce/event/collector.py +33 -22
  70. recce/event/track.py +49 -27
  71. recce/exceptions.py +1 -1
  72. recce/git.py +7 -7
  73. recce/github.py +57 -53
  74. recce/mcp_server.py +716 -0
  75. recce/models/__init__.py +4 -1
  76. recce/models/check.py +6 -7
  77. recce/models/run.py +1 -0
  78. recce/models/types.py +131 -28
  79. recce/pull_request.py +27 -25
  80. recce/run.py +165 -121
  81. recce/server.py +303 -111
  82. recce/state/__init__.py +31 -0
  83. recce/state/cloud.py +632 -0
  84. recce/state/const.py +26 -0
  85. recce/state/local.py +56 -0
  86. recce/state/state.py +119 -0
  87. recce/state/state_loader.py +174 -0
  88. recce/summary.py +188 -143
  89. recce/tasks/__init__.py +19 -3
  90. recce/tasks/core.py +11 -13
  91. recce/tasks/dataframe.py +82 -18
  92. recce/tasks/histogram.py +69 -34
  93. recce/tasks/lineage.py +2 -2
  94. recce/tasks/profile.py +152 -86
  95. recce/tasks/query.py +139 -87
  96. recce/tasks/rowcount.py +37 -31
  97. recce/tasks/schema.py +18 -15
  98. recce/tasks/top_k.py +35 -35
  99. recce/tasks/valuediff.py +216 -152
  100. recce/util/__init__.py +3 -0
  101. recce/util/api_token.py +80 -0
  102. recce/util/breaking.py +87 -85
  103. recce/util/cll.py +274 -219
  104. recce/util/io.py +22 -17
  105. recce/util/lineage.py +65 -16
  106. recce/util/logger.py +1 -1
  107. recce/util/onboarding_state.py +45 -0
  108. recce/util/perf_tracking.py +85 -0
  109. recce/util/recce_cloud.py +322 -72
  110. recce/util/singleton.py +4 -4
  111. recce/yaml/__init__.py +7 -10
  112. recce_cloud/__init__.py +24 -0
  113. recce_cloud/api/__init__.py +17 -0
  114. recce_cloud/api/base.py +111 -0
  115. recce_cloud/api/client.py +150 -0
  116. recce_cloud/api/exceptions.py +26 -0
  117. recce_cloud/api/factory.py +63 -0
  118. recce_cloud/api/github.py +76 -0
  119. recce_cloud/api/gitlab.py +82 -0
  120. recce_cloud/artifact.py +57 -0
  121. recce_cloud/ci_providers/__init__.py +9 -0
  122. recce_cloud/ci_providers/base.py +82 -0
  123. recce_cloud/ci_providers/detector.py +147 -0
  124. recce_cloud/ci_providers/github_actions.py +136 -0
  125. recce_cloud/ci_providers/gitlab_ci.py +130 -0
  126. recce_cloud/cli.py +245 -0
  127. recce_cloud/upload.py +214 -0
  128. {recce_nightly-1.2.0.20250506.dist-info → recce_nightly-1.26.0.20251124.dist-info}/METADATA +68 -37
  129. recce_nightly-1.26.0.20251124.dist-info/RECORD +180 -0
  130. {recce_nightly-1.2.0.20250506.dist-info → recce_nightly-1.26.0.20251124.dist-info}/WHEEL +1 -1
  131. {recce_nightly-1.2.0.20250506.dist-info → recce_nightly-1.26.0.20251124.dist-info}/top_level.txt +1 -0
  132. tests/adapter/dbt_adapter/conftest.py +9 -5
  133. tests/adapter/dbt_adapter/dbt_test_helper.py +37 -22
  134. tests/adapter/dbt_adapter/test_dbt_adapter.py +0 -15
  135. tests/adapter/dbt_adapter/test_dbt_cll.py +656 -41
  136. tests/adapter/dbt_adapter/test_selector.py +22 -21
  137. tests/recce_cloud/__init__.py +0 -0
  138. tests/recce_cloud/test_ci_providers.py +351 -0
  139. tests/recce_cloud/test_cli.py +372 -0
  140. tests/recce_cloud/test_client.py +273 -0
  141. tests/recce_cloud/test_platform_clients.py +333 -0
  142. tests/tasks/conftest.py +1 -1
  143. tests/tasks/test_histogram.py +58 -66
  144. tests/tasks/test_lineage.py +36 -23
  145. tests/tasks/test_preset_checks.py +45 -31
  146. tests/tasks/test_profile.py +339 -15
  147. tests/tasks/test_query.py +46 -46
  148. tests/tasks/test_row_count.py +65 -46
  149. tests/tasks/test_schema.py +65 -42
  150. tests/tasks/test_top_k.py +22 -18
  151. tests/tasks/test_valuediff.py +43 -32
  152. tests/test_cli.py +174 -60
  153. tests/test_cli_mcp_optional.py +45 -0
  154. tests/test_cloud_listing_cli.py +324 -0
  155. tests/test_config.py +7 -9
  156. tests/test_connect_to_cloud.py +82 -0
  157. tests/test_core.py +151 -4
  158. tests/test_dbt.py +7 -7
  159. tests/test_mcp_server.py +332 -0
  160. tests/test_pull_request.py +1 -1
  161. tests/test_server.py +25 -19
  162. tests/test_summary.py +29 -17
  163. recce/data/_next/static/Kcbs3GEIyH2LxgLYat0es/_buildManifest.js +0 -1
  164. recce/data/_next/static/chunks/1f229bf6-d9fe92e56db8d93b.js +0 -1
  165. recce/data/_next/static/chunks/29e3cc0d-8c150e37dff9631b.js +0 -1
  166. recce/data/_next/static/chunks/368-7587b306577df275.js +0 -65
  167. recce/data/_next/static/chunks/36e1c10d-bb0210cbd6573a8d.js +0 -1
  168. recce/data/_next/static/chunks/3998a672-eaad84bdd88cc73e.js +0 -1
  169. recce/data/_next/static/chunks/3a92ee20-3b5d922d4157af5e.js +0 -1
  170. recce/data/_next/static/chunks/450c323b-1bb5db526e54435a.js +0 -1
  171. recce/data/_next/static/chunks/47d8844f-79a1b53c66a7d7ec.js +0 -1
  172. recce/data/_next/static/chunks/6dc81886-c94b9b91bc2c3caf.js +0 -1
  173. recce/data/_next/static/chunks/6ef81909-694dc38134099299.js +0 -1
  174. recce/data/_next/static/chunks/700-3b65fc3666820d00.js +0 -2
  175. recce/data/_next/static/chunks/7a8a3e83-d7fa409d97b38b2b.js +0 -1
  176. recce/data/_next/static/chunks/7f27ae6c-413f6b869a04183a.js +0 -1
  177. recce/data/_next/static/chunks/8d700b6a-f0b1f6b9e0d97ce2.js +0 -1
  178. recce/data/_next/static/chunks/9746af58-d74bef4d03eea6ab.js +0 -1
  179. recce/data/_next/static/chunks/a30376cd-7d806e1602f2dc3a.js +0 -1
  180. recce/data/_next/static/chunks/app/_not-found/page-8a886fa0855c3105.js +0 -1
  181. recce/data/_next/static/chunks/app/layout-9102e22cb73f74d6.js +0 -1
  182. recce/data/_next/static/chunks/app/page-cee661090afbd6aa.js +0 -1
  183. recce/data/_next/static/chunks/b63b1b3f-7395c74e11a14e95.js +0 -1
  184. recce/data/_next/static/chunks/c132bf7d-8102037f9ccf372a.js +0 -1
  185. recce/data/_next/static/chunks/c1ceaa8b-a1e442154d23515e.js +0 -1
  186. recce/data/_next/static/chunks/cd9f8d63-cf0d5a7b0f7a92e8.js +0 -54
  187. recce/data/_next/static/chunks/ce84277d-f42c2c58049cea2d.js +0 -1
  188. recce/data/_next/static/chunks/e24bf851-0f8cbc99656833e7.js +0 -1
  189. recce/data/_next/static/chunks/fee69bc6-f17d36c080742e74.js +0 -1
  190. recce/data/_next/static/chunks/framework-ded83d71b51ce901.js +0 -1
  191. recce/data/_next/static/chunks/main-a0859f1f36d0aa6c.js +0 -1
  192. recce/data/_next/static/chunks/main-app-0225a2255968e566.js +0 -1
  193. recce/data/_next/static/chunks/pages/_app-d5672bf3d8b6371b.js +0 -1
  194. recce/data/_next/static/chunks/pages/_error-ed75be3f25588548.js +0 -1
  195. recce/data/_next/static/chunks/webpack-567d72f0bc0820d5.js +0 -1
  196. recce/data/_next/static/css/c9ecb46a4b21c126.css +0 -14
  197. recce/data/_next/static/media/montserrat-cyrillic-800-normal.22628180.woff2 +0 -0
  198. recce/data/_next/static/media/montserrat-cyrillic-800-normal.31d693bb.woff +0 -0
  199. recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.7e2c1e62.woff +0 -0
  200. recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.94a63aea.woff2 +0 -0
  201. recce/data/_next/static/media/montserrat-latin-800-normal.6f8fa298.woff2 +0 -0
  202. recce/data/_next/static/media/montserrat-latin-800-normal.97e20d5e.woff +0 -0
  203. recce/data/_next/static/media/montserrat-latin-ext-800-normal.013b84f9.woff2 +0 -0
  204. recce/data/_next/static/media/montserrat-latin-ext-800-normal.aff52ab0.woff +0 -0
  205. recce/data/_next/static/media/montserrat-vietnamese-800-normal.5f21869b.woff +0 -0
  206. recce/data/_next/static/media/montserrat-vietnamese-800-normal.c0035377.woff2 +0 -0
  207. recce/state.py +0 -753
  208. recce_nightly-1.2.0.20250506.dist-info/RECORD +0 -142
  209. tests/test_state.py +0 -123
  210. /recce/data/_next/static/{Kcbs3GEIyH2LxgLYat0es → 52aV_JrNUZU6dMFgvTQEO}/_ssgManifest.js +0 -0
  211. /recce/data/_next/static/chunks/{polyfills-42372ed130431b0a.js → a6dad97d9634a72d.js} +0 -0
  212. {recce_nightly-1.2.0.20250506.dist-info → recce_nightly-1.26.0.20251124.dist-info}/entry_points.txt +0 -0
  213. {recce_nightly-1.2.0.20250506.dist-info → recce_nightly-1.26.0.20251124.dist-info}/licenses/LICENSE +0 -0
recce/server.py CHANGED
@@ -7,30 +7,66 @@ import uuid
7
7
  from contextlib import asynccontextmanager
8
8
  from dataclasses import dataclass
9
9
  from datetime import datetime, timedelta
10
+ from enum import Enum
10
11
  from pathlib import Path
11
- from typing import Optional, Any, Set, Annotated, Literal, Dict
12
-
13
- from fastapi import FastAPI, HTTPException, Request, WebSocket, UploadFile, Response, BackgroundTasks, Form
12
+ from typing import Annotated, Any, Literal, Optional, Set
13
+
14
+ from fastapi import (
15
+ BackgroundTasks,
16
+ FastAPI,
17
+ Form,
18
+ HTTPException,
19
+ Request,
20
+ Response,
21
+ UploadFile,
22
+ WebSocket,
23
+ )
14
24
  from fastapi.middleware.cors import CORSMiddleware
15
25
  from fastapi.responses import PlainTextResponse
16
26
  from fastapi.staticfiles import StaticFiles
17
- from pydantic import ValidationError, BaseModel
27
+ from pydantic import BaseModel, ValidationError
18
28
  from pytz import utc
19
29
  from starlette.middleware.gzip import GZipMiddleware
20
30
  from starlette.middleware.sessions import SessionMiddleware
21
31
  from starlette.websockets import WebSocketDisconnect
22
32
 
23
- from . import __version__, event, __latest_version__
33
+ from . import __latest_version__, __version__, event, is_recce_cloud_instance
24
34
  from .apis.check_api import check_router
25
35
  from .apis.run_api import run_router
26
36
  from .config import RecceConfig
27
- from .core import load_context, default_context, RecceContext
28
- from .event import log_api_event, log_single_env_event
37
+ from .connect_to_cloud import (
38
+ connect_to_cloud_background_task,
39
+ generate_key_pair,
40
+ get_connection_url,
41
+ is_callback_server_running,
42
+ prepare_connection_url,
43
+ )
44
+ from .core import RecceContext, default_context, load_context
45
+ from .event import get_recce_api_token, log_api_event, log_single_env_event
29
46
  from .exceptions import RecceException
47
+ from .github import is_github_codespace
48
+ from .models.types import CllData
30
49
  from .run import load_preset_checks
31
- from .state import RecceStateLoader, RecceShareStateManager
50
+ from .state import RecceShareStateManager, RecceStateLoader
51
+
52
+ logger = logging.getLogger("uvicorn")
53
+
54
+ # Idle timeout check interval bounds (in seconds)
55
+ MAX_CHECK_INTERVAL = 30
56
+ MIN_CHECK_INTERVAL = 1
57
+
32
58
 
33
- logger = logging.getLogger('uvicorn')
59
+ class RecceServerMode(str, Enum):
60
+ server = "server"
61
+ preview = "preview"
62
+ read_only = "read-only"
63
+
64
+ def __str__(self):
65
+ return self.value
66
+
67
+ @staticmethod
68
+ def available_members() -> Set[str]:
69
+ return ["server", "preview", "read-only"]
34
70
 
35
71
 
36
72
  @dataclass
@@ -42,24 +78,82 @@ class AppState:
42
78
  auth_options: Optional[dict] = None
43
79
  lifetime: Optional[int] = None
44
80
  lifetime_expired_at: Optional[datetime] = None
81
+ idle_timeout: Optional[int] = None
82
+ last_activity: Optional[dict] = None
83
+ share_url: Optional[str] = None
84
+ organization_name: Optional[str] = None
85
+ web_url: Optional[str] = None
86
+ host: Optional[str] = None
87
+ port: Optional[int] = None
45
88
 
46
89
 
47
90
  def schedule_lifetime_termination(app_state):
48
91
  def terminating_server():
49
92
  pid = os.getpid()
50
- logger.info(f"Terminating server process [{pid}] manually")
93
+ logger.info(f"Terminating server process [{pid}] manually due to lifetime expiration")
51
94
  os.kill(pid, signal.SIGINT)
52
95
 
53
96
  # Terminate the server process after the specified lifetime
54
- logger.info(f'[Configuration] The lifetime of the server is {app_state.lifetime} seconds')
97
+ logger.info(f"[Configuration] The lifetime of the server is {app_state.lifetime} seconds")
55
98
  app.state.lifetime_expired_at = datetime.now(utc) + timedelta(seconds=app_state.lifetime)
56
99
  asyncio.get_running_loop().call_later(app_state.lifetime, terminating_server)
57
100
 
58
101
 
102
+ def schedule_idle_timeout_check(app_state):
103
+ """
104
+ Schedule periodic checks for idle timeout.
105
+ If the server has been idle for longer than idle_timeout, terminate it.
106
+ """
107
+ # Track last activity time in app_state
108
+ app_state.last_activity = {"time": datetime.now(utc)}
109
+
110
+ def terminating_server_idle():
111
+ pid = os.getpid()
112
+ logger.info(f"Terminating server process [{pid}] manually due to idle timeout")
113
+ os.kill(pid, signal.SIGINT)
114
+
115
+ async def check_idle_timeout():
116
+ """Periodically check if the server has been idle for too long"""
117
+ # Use smaller check interval if idle_timeout is very short
118
+ # Check at least every MAX_CHECK_INTERVAL seconds, but also check when idle_timeout is approaching
119
+ check_interval = min(MAX_CHECK_INTERVAL, max(MIN_CHECK_INTERVAL, app_state.idle_timeout // 3))
120
+
121
+ logger.debug(f"[Idle Timeout] Starting idle timeout checker with {check_interval}s check interval")
122
+
123
+ while True:
124
+ await asyncio.sleep(check_interval)
125
+
126
+ idle_seconds = (datetime.now(utc) - app_state.last_activity["time"]).total_seconds()
127
+ remaining_seconds = app_state.idle_timeout - idle_seconds
128
+
129
+ # Always log the countdown for debugging
130
+ if remaining_seconds > 0:
131
+ logger.debug(
132
+ f"[Idle Timeout] Server idle for {idle_seconds:.1f}s / {app_state.idle_timeout}s "
133
+ f"(remaining: {remaining_seconds:.1f}s)"
134
+ )
135
+
136
+ if idle_seconds >= app_state.idle_timeout:
137
+ logger.info(
138
+ f"[Idle Timeout] Threshold reached! Server has been idle for {idle_seconds:.0f} seconds "
139
+ f"(threshold: {app_state.idle_timeout} seconds)"
140
+ )
141
+ terminating_server_idle()
142
+ break
143
+
144
+ # Start the idle timeout check task
145
+ logger.info(f"[Configuration] The idle timeout of the server is {app_state.idle_timeout} seconds")
146
+
147
+ # Create task using asyncio.create_task which works in async context
148
+ task = asyncio.create_task(check_idle_timeout())
149
+ logger.debug(f"[Idle Timeout] Background task created: {task}")
150
+
151
+
59
152
  def setup_server(app_state: AppState) -> RecceContext:
60
- from .core import load_context
61
153
  from rich.console import Console
62
154
 
155
+ from .core import load_context
156
+
63
157
  console = Console()
64
158
  state_loader = app_state.state_loader
65
159
  kwargs = app_state.kwargs
@@ -73,56 +167,87 @@ def setup_server(app_state: AppState) -> RecceContext:
73
167
  log_single_env_event()
74
168
 
75
169
  # Initialize Recce Config
76
- config = RecceConfig(config_file=kwargs.get('config'))
170
+ config = RecceConfig(config_file=kwargs.get("config"))
77
171
  if state_loader.state is None:
78
- preset_checks = config.get('checks', [])
172
+ preset_checks = config.get("checks", [])
79
173
  if preset_checks and len(preset_checks) > 0:
80
174
  console.rule("Loading Preset Checks")
81
175
  load_preset_checks(preset_checks)
82
176
 
83
177
  from recce.event import log_load_state
84
- log_load_state(command='server', single_env=single_env)
85
178
 
86
- if app_state.lifetime is not None and app_state.lifetime > 0:
87
- schedule_lifetime_termination(app_state)
179
+ log_load_state(command="server", single_env=single_env)
88
180
 
89
181
  return ctx
90
182
 
91
183
 
92
184
  def teardown_server(app_state: AppState, ctx: RecceContext):
93
- state_loader = app_state.state_loader
185
+ # pull latest state, merge runs/checks and pick the newer artifacts
186
+ state_loader = ctx.state_loader
187
+ state_loader.refresh()
188
+ if state_loader.state:
189
+ ctx.import_state(state_loader.state, merge=True)
94
190
  state_loader.export(ctx.export_state())
95
-
96
191
  ctx.stop_monitor_artifacts()
97
192
  if app_state.flag.get("single_env_onboarding", False):
98
193
  ctx.stop_monitor_base_env()
99
194
 
100
195
 
101
196
  def setup_ready_only(app_state: AppState):
102
- if app_state.lifetime is not None and app_state.lifetime > 0:
103
- schedule_lifetime_termination(app_state)
197
+ pass
104
198
 
105
199
 
106
200
  def teardown_ready_only(app_state: AppState):
107
201
  pass
108
202
 
109
203
 
204
+ def setup_preview(app_state: AppState):
205
+ state_loader = app_state.state_loader
206
+ kwargs = app_state.kwargs
207
+ ctx = load_context(**kwargs, state_loader=state_loader)
208
+ return ctx
209
+
210
+
211
+ def teardown_preview(app_state: AppState, ctx: RecceContext):
212
+ state_loader = app_state.state_loader
213
+ state_loader.export(ctx.export_state())
214
+ pass
215
+
216
+
110
217
  @asynccontextmanager
111
218
  async def lifespan(fastapi: FastAPI):
112
219
  ctx = None
113
220
  app_state: AppState = app.state
114
221
 
115
- if app_state.command == 'server':
222
+ # Ensure logger is at DEBUG level if debug mode is enabled
223
+ # This is needed because uvicorn might reset logger configuration
224
+ if hasattr(app_state, "kwargs") and app_state.kwargs.get("debug"):
225
+ logger.setLevel(logging.DEBUG)
226
+ logger.debug("Debug mode enabled - logger set to DEBUG level")
227
+
228
+ if app_state.command == "server":
116
229
  ctx = setup_server(app_state)
117
- elif app_state.command == 'read_only':
230
+ elif app_state.command == "read-only":
118
231
  setup_ready_only(app_state)
232
+ elif app_state.command == "preview":
233
+ ctx = setup_preview(app_state)
234
+
235
+ if app_state.lifetime is not None and app_state.lifetime > 0:
236
+ schedule_lifetime_termination(app_state)
237
+
238
+ # Schedule idle timeout check if idle_timeout is set
239
+ if app_state.idle_timeout is not None and app_state.idle_timeout > 0:
240
+ logger.debug(f"[Idle Timeout] Scheduling idle timeout check with {app_state.idle_timeout} seconds")
241
+ schedule_idle_timeout_check(app_state)
119
242
 
120
243
  yield
121
244
 
122
- if app_state.command == 'server':
245
+ if app_state.command == "server":
123
246
  teardown_server(app_state, ctx)
124
- elif app_state.command == 'read_only':
247
+ elif app_state.command == "read_only":
125
248
  teardown_ready_only(app_state)
249
+ elif app_state.command == "preview":
250
+ teardown_preview(app_state, ctx)
126
251
 
127
252
 
128
253
  app = FastAPI(lifespan=lifespan)
@@ -130,7 +255,7 @@ app = FastAPI(lifespan=lifespan)
130
255
 
131
256
  def verify_json_file(file_path: str) -> bool:
132
257
  try:
133
- with open(file_path, 'r') as f:
258
+ with open(file_path, "r", encoding="utf-8") as f:
134
259
  json.load(f)
135
260
  except Exception:
136
261
  return False
@@ -143,19 +268,15 @@ def dbt_artifacts_updated_callback(file_changed_event: Any):
143
268
  file_name = src_path.name
144
269
 
145
270
  if not verify_json_file(file_changed_event.src_path):
146
- logger.debug('Skip to refresh the artifacts because the file is not updated completely.')
271
+ logger.debug("Skip to refresh the artifacts because the file is not updated completely.")
147
272
  return
148
273
 
149
- logger.info(
150
- f'Detect {target_type} file {file_changed_event.event_type}: {file_name}')
274
+ logger.info(f"Detect {target_type} file {file_changed_event.event_type}: {file_name}")
151
275
  ctx = load_context()
152
276
  ctx.refresh_manifest(file_changed_event.src_path)
153
277
  broadcast_command = {
154
- 'command': 'refresh',
155
- 'event': {
156
- 'eventType': file_changed_event.event_type,
157
- 'srcPath': file_changed_event.src_path
158
- }
278
+ "command": "refresh",
279
+ "event": {"eventType": file_changed_event.event_type, "srcPath": file_changed_event.src_path},
159
280
  }
160
281
  payload = json.dumps(broadcast_command)
161
282
  asyncio.run(broadcast(payload))
@@ -164,7 +285,7 @@ def dbt_artifacts_updated_callback(file_changed_event: Any):
164
285
  def dbt_env_updated_callback():
165
286
  logger.info("Detect 'manifest.json' and 'catalog.json' are generated under 'target-base' directory")
166
287
  broadcast_command = {
167
- 'command': 'relaunch',
288
+ "command": "relaunch",
168
289
  }
169
290
  payload = json.dumps(broadcast_command)
170
291
  asyncio.run(broadcast(payload))
@@ -191,11 +312,32 @@ app.add_middleware(
191
312
  )
192
313
 
193
314
 
315
+ @app.middleware("http")
316
+ async def track_activity_for_idle_timeout(request: Request, call_next):
317
+ """Track activity time for idle timeout check"""
318
+ # Exclude paths that should not reset idle timer
319
+ # Health checks and monitoring endpoints don't count as user activity
320
+ excluded_paths = ["/api/health", "/api/ws"]
321
+
322
+ # Update last activity time BEFORE processing request if idle timeout is enabled
323
+ # This ensures long-running requests don't get terminated mid-execution
324
+ app_state: AppState = app.state
325
+ if app_state.last_activity is not None:
326
+ if request.url.path not in excluded_paths:
327
+ app_state.last_activity["time"] = datetime.now(utc)
328
+ logger.debug(f"[Idle Timeout] ✓ Activity detected: {request.method} {request.url.path} - Timer reset")
329
+ else:
330
+ logger.debug(f"[Idle Timeout] Excluded path (no timer reset): {request.method} {request.url.path}")
331
+
332
+ response = await call_next(request)
333
+ return response
334
+
335
+
194
336
  @app.middleware("http")
195
337
  async def set_context_by_cookie(request: Request, call_next):
196
338
  response = await call_next(request)
197
339
 
198
- user_id_in_cookie = request.cookies.get('recce_user_id')
340
+ user_id_in_cookie = request.cookies.get("recce_user_id")
199
341
  user_id = event.get_user_id()
200
342
 
201
343
  if event.is_anonymous_tracking() is False:
@@ -203,7 +345,7 @@ async def set_context_by_cookie(request: Request, call_next):
203
345
  user_id = None
204
346
 
205
347
  if user_id_in_cookie is None or user_id_in_cookie != user_id:
206
- response.set_cookie(key='recce_user_id', value=user_id)
348
+ response.set_cookie(key="recce_user_id", value=user_id)
207
349
  return response
208
350
 
209
351
 
@@ -212,8 +354,8 @@ async def disable_cache(request: Request, call_next):
212
354
  response = await call_next(request)
213
355
 
214
356
  # disable cache for '/' and '/index.html'
215
- if request.url.path in ['/', '/index.html']:
216
- response.headers['Cache-Control'] = 'no-store'
357
+ if request.url.path in ["/", "/index.html"]:
358
+ response.headers["Cache-Control"] = "no-store"
217
359
 
218
360
  return response
219
361
 
@@ -224,24 +366,40 @@ async def health_check(request: Request):
224
366
 
225
367
 
226
368
  class RecceInstanceInfoOut(BaseModel):
369
+ server_mode: RecceServerMode
227
370
  read_only: bool
371
+ preview: bool
372
+ single_env: bool
228
373
  authed: bool
374
+ cloud_instance: bool
229
375
  lifetime_expired_at: Optional[datetime] = None
376
+ share_url: Optional[str] = None
377
+ session_id: Optional[str] = None
378
+ organization_name: Optional[str] = None
379
+ web_url: Optional[str] = None
230
380
 
231
381
 
232
382
  @app.get("/api/instance-info", response_model=RecceInstanceInfoOut, response_model_exclude_none=True)
233
383
  async def recce_instance_info():
234
384
  app_state: AppState = app.state
235
385
  flag = app_state.flag
236
- read_only = flag.get('read_only', False)
386
+ read_only = flag.get("read_only", False)
387
+ single_env = flag.get("single_env_onboarding", False)
237
388
 
238
- auth_options = app_state.auth_options or {}
239
- api_token = auth_options.get('api_token')
389
+ api_token = get_recce_api_token()
240
390
 
241
391
  return {
392
+ "server_mode": app_state.command,
242
393
  "read_only": read_only,
394
+ "preview": flag.get("preview", False),
395
+ "single_env": single_env,
243
396
  "authed": True if api_token else False,
397
+ "cloud_instance": is_recce_cloud_instance(),
244
398
  "lifetime_expired_at": app_state.lifetime_expired_at, # UTC timezone
399
+ "share_url": app_state.share_url,
400
+ "session_id": app_state.state_loader.session_id if app_state.state_loader else None,
401
+ "organization_name": app_state.organization_name,
402
+ "web_url": app_state.web_url,
245
403
  # TODO: Add more instance info which won't change during the instance lifecycle
246
404
  # review_mode
247
405
  # cloud_mode
@@ -257,16 +415,9 @@ async def config_flag():
257
415
  return flag
258
416
 
259
417
 
260
- @app.post("/api/onboarding/completed", status_code=204)
261
- async def mark_onboarding_completed():
262
- context = default_context()
263
- context.mark_onboarding_completed()
264
- app.state.flag['show_onboarding_guide'] = False
265
-
266
-
267
418
  @app.post("/api/relaunch-hint/completed", status_code=204)
268
419
  async def mark_relaunch_hint_completed():
269
- app.state.flag['show_relaunch_hint'] = False
420
+ app.state.flag["show_relaunch_hint"] = False
270
421
 
271
422
 
272
423
  @app.get("/api/info")
@@ -275,7 +426,8 @@ async def get_info():
275
426
  Get the information of the current context.
276
427
  """
277
428
  context = default_context()
278
- demo = os.environ.get('DEMO', False)
429
+ demo = os.environ.get("DEMO", False)
430
+ is_codespace = is_github_codespace()
279
431
 
280
432
  if demo:
281
433
  state = context.export_demo_state()
@@ -293,25 +445,27 @@ async def get_info():
293
445
 
294
446
  try:
295
447
  info = {
296
- 'state_metadata': state_metadata,
297
- 'adapter_type': context.adapter_type,
298
- 'review_mode': context.review_mode,
299
- 'git': state.git.to_dict() if state.git else None,
300
- 'pull_request': state.pull_request.to_dict() if state.pull_request else None,
301
- 'lineage': lineage_diff,
302
- 'demo': bool(demo),
303
- 'cloud_mode': context.state_loader.cloud_mode,
304
- 'file_mode': context.state_loader.state_file is not None,
305
- 'filename': filename,
306
- 'support_tasks': support_tasks,
448
+ "state_metadata": state_metadata,
449
+ "adapter_type": context.adapter_type,
450
+ "review_mode": context.review_mode,
451
+ "git": state.git.to_dict() if state.git else None,
452
+ "pull_request": state.pull_request.to_dict() if state.pull_request else None,
453
+ "lineage": lineage_diff,
454
+ "demo": bool(demo),
455
+ "codespace": bool(is_codespace),
456
+ "cloud_mode": context.state_loader.cloud_mode,
457
+ "file_mode": context.state_loader.state_file is not None,
458
+ "filename": filename,
459
+ "support_tasks": support_tasks,
307
460
  }
308
461
 
309
- if context.adapter_type == 'sqlmesh':
462
+ if context.adapter_type == "sqlmesh":
310
463
  from recce.adapter.sqlmesh_adapter import SqlmeshAdapter
464
+
311
465
  sqlmesh_adapter: SqlmeshAdapter = context.adapter
312
- info['sqlmesh'] = {
313
- 'base_env': sqlmesh_adapter.base_env.name,
314
- 'current_env': sqlmesh_adapter.curr_env.name,
466
+ info["sqlmesh"] = {
467
+ "base_env": sqlmesh_adapter.base_env.name,
468
+ "current_env": sqlmesh_adapter.curr_env.name,
315
469
  }
316
470
 
317
471
  return info
@@ -320,32 +474,40 @@ async def get_info():
320
474
 
321
475
 
322
476
  class CllIn(BaseModel):
323
- params: Dict
477
+ node_id: Optional[str] = None
478
+ column: Optional[str] = None
479
+ change_analysis: Optional[bool] = False
480
+ no_cll: Optional[bool] = False
481
+ no_upstream: Optional[bool] = False
482
+ no_downstream: Optional[bool] = False
324
483
 
325
484
 
326
485
  class CllOutput(BaseModel):
327
- current: Dict
486
+ current: CllData
328
487
 
329
488
 
330
489
  @app.post("/api/cll", response_model=CllOutput)
331
490
  async def column_level_lineage_by_node(cll_input: CllIn):
332
491
  from recce.adapter.dbt_adapter import DbtAdapter
333
- dbt_adapter: DbtAdapter = default_context().adapter
334
492
 
335
- try:
336
- # TODO: Add support for by the node and column
337
- result = dbt_adapter.get_cll_by_node_id(cll_input.params.get('node_id'))
338
- except Exception as e:
339
- raise HTTPException(status_code=400, detail=str(e))
493
+ dbt_adapter: DbtAdapter = default_context().adapter
494
+ cll = dbt_adapter.get_cll(
495
+ node_id=cll_input.node_id,
496
+ column=cll_input.column,
497
+ change_analysis=cll_input.change_analysis,
498
+ no_upstream=cll_input.no_upstream,
499
+ no_downstream=cll_input.no_downstream,
500
+ no_cll=cll_input.no_cll,
501
+ )
340
502
 
341
- return CllOutput(current=result)
503
+ return CllOutput(current=cll)
342
504
 
343
505
 
344
506
  class SelectNodesInput(BaseModel):
345
507
  select: Optional[str] = None
346
508
  exclude: Optional[str] = None
347
509
  packages: Optional[list[str]] = None
348
- view_mode: Optional[Literal['all', 'changed_models']] = None
510
+ view_mode: Optional[Literal["all", "changed_models"]] = None
349
511
 
350
512
 
351
513
  class SelectNodesOutput(BaseModel):
@@ -356,8 +518,8 @@ class SelectNodesOutput(BaseModel):
356
518
  async def select_nodes(input: SelectNodesInput):
357
519
  context = default_context()
358
520
 
359
- if context.adapter_type != 'dbt':
360
- raise HTTPException(status_code=400, detail='Only dbt adapter is supported')
521
+ if context.adapter_type != "dbt":
522
+ raise HTTPException(status_code=400, detail="Only dbt adapter is supported")
361
523
 
362
524
  try:
363
525
  nodes = context.adapter.select_nodes(
@@ -366,7 +528,7 @@ async def select_nodes(input: SelectNodesInput):
366
528
  packages=input.packages,
367
529
  view_mode=input.view_mode,
368
530
  )
369
- nodes = [node for node in nodes if not node.startswith('test.')]
531
+ nodes = [node for node in nodes if not node.startswith("test.")]
370
532
  return SelectNodesOutput(nodes=nodes)
371
533
  except Exception as e:
372
534
  raise HTTPException(status_code=400, detail=str(e))
@@ -377,9 +539,9 @@ async def get_columns(model_id: str):
377
539
  context = default_context()
378
540
  try:
379
541
  return {
380
- 'model': {
381
- 'base': context.get_model(model_id, base=True),
382
- 'current': context.get_model(model_id, base=False)
542
+ "model": {
543
+ "base": context.get_model(model_id, base=True),
544
+ "current": context.get_model(model_id, base=False),
383
545
  }
384
546
  }
385
547
  except Exception as e:
@@ -394,12 +556,12 @@ async def save_handler():
394
556
  try:
395
557
  # Sync the state file
396
558
  context = default_context()
397
- log_api_event('save', dict(state_loader_mode=context.state_loader_mode()))
559
+ log_api_event("save", dict(state_loader_mode=context.state_loader_mode()))
398
560
  state_loader = context.state_loader
399
561
  if not state_loader.cloud_mode and state_loader.state_file is None:
400
- raise RecceException('Not file mode or cloud mode')
562
+ raise RecceException("Not file mode or cloud mode")
401
563
 
402
- context.sync_state('overwrite')
564
+ context.sync_state("overwrite")
403
565
  except RecceException as e:
404
566
  raise HTTPException(status_code=400, detail=e.message)
405
567
 
@@ -415,33 +577,33 @@ def saveas_or_rename(input: SaveAsOrRenameInput, rename: bool = False):
415
577
  context = default_context()
416
578
  state_loader = context.state_loader
417
579
  if state_loader.cloud_mode:
418
- raise RecceException('Cloud mode does not support rename')
580
+ raise RecceException("Cloud mode does not support rename")
419
581
 
420
582
  new_filename = input.filename
421
583
  if os.path.dirname(new_filename):
422
- raise RecceException('The new filename should not contain directory')
423
- if not new_filename.endswith('.json'):
424
- raise RecceException('The new filename should end with .json')
584
+ raise RecceException("The new filename should not contain directory")
585
+ if not new_filename.endswith(".json"):
586
+ raise RecceException("The new filename should end with .json")
425
587
 
426
588
  old_path = state_loader.state_file
427
589
  if old_path:
428
590
  old_dir = os.path.dirname(state_loader.state_file)
429
591
  old_filename = os.path.basename(state_loader.state_file)
430
592
  if old_filename == new_filename:
431
- raise RecceException('The new filename is the same as the current filename')
593
+ raise RecceException("The new filename is the same as the current filename")
432
594
  new_path = os.path.join(old_dir, new_filename)
433
595
  else:
434
596
  new_path = new_filename
435
597
 
436
598
  if os.path.exists(new_path):
437
599
  if os.path.isdir(new_path):
438
- raise HTTPException(status_code=400, detail=f'The file {new_path} exists and is a directory')
600
+ raise HTTPException(status_code=400, detail=f"The file {new_path} exists and is a directory")
439
601
 
440
602
  if not input.overwrite:
441
- raise HTTPException(status_code=409, detail=f'The file {new_filename} already exists')
603
+ raise HTTPException(status_code=409, detail=f"The file {new_filename} already exists")
442
604
 
443
605
  state_loader.state_file = new_path
444
- context.sync_state('overwrite')
606
+ context.sync_state("overwrite")
445
607
  if rename and os.path.exists(old_path):
446
608
  os.remove(old_path)
447
609
 
@@ -453,7 +615,7 @@ async def save_as_handler(input: SaveAsOrRenameInput):
453
615
  """
454
616
  context = default_context()
455
617
  try:
456
- log_api_event('saveas', dict(state_loader_mode=context.state_loader_mode()))
618
+ log_api_event("saveas", dict(state_loader_mode=context.state_loader_mode()))
457
619
  saveas_or_rename(input, rename=False)
458
620
  except RecceException as e:
459
621
  raise HTTPException(status_code=400, detail=e.message)
@@ -466,7 +628,7 @@ async def rename_handler(input: SaveAsOrRenameInput):
466
628
  """
467
629
  context = default_context()
468
630
  try:
469
- log_api_event('rename', dict(state_loader_mode=context.state_loader_mode()))
631
+ log_api_event("rename", dict(state_loader_mode=context.state_loader_mode()))
470
632
  saveas_or_rename(input, rename=True)
471
633
  except RecceException as e:
472
634
  raise HTTPException(status_code=400, detail=e.message)
@@ -479,7 +641,7 @@ async def export_handler():
479
641
  """
480
642
  context = default_context()
481
643
  try:
482
- log_api_event('export', dict(state_loader_mode=context.state_loader_mode()))
644
+ log_api_event("export", dict(state_loader_mode=context.state_loader_mode()))
483
645
  return context.export_state().to_json()
484
646
  except RecceException as e:
485
647
  raise HTTPException(status_code=400, detail=e.message)
@@ -487,17 +649,16 @@ async def export_handler():
487
649
 
488
650
  @app.post("/api/import", status_code=200)
489
651
  async def import_handler(
490
- file: Annotated[UploadFile, Form()],
491
- checks_only: Annotated[bool, Form()],
492
- background_tasks: BackgroundTasks
652
+ file: Annotated[UploadFile, Form()], checks_only: Annotated[bool, Form()], background_tasks: BackgroundTasks
493
653
  ):
494
654
  """
495
655
  Import the recce state from the client.
496
656
  """
497
657
  from recce.state import RecceState
658
+
498
659
  context = default_context()
499
660
  try:
500
- log_api_event('import', dict(state_loader_mode=context.state_loader_mode()))
661
+ log_api_event("import", dict(state_loader_mode=context.state_loader_mode()))
501
662
  content = await file.read()
502
663
  state = RecceState.from_json(content)
503
664
 
@@ -531,16 +692,19 @@ async def sync_handler(input: SyncStateInput, response: Response, background_tas
531
692
  context = default_context()
532
693
  state_loader = context.state_loader
533
694
  method = input.method
534
- log_api_event('sync', dict(
535
- state_loader_mode=context.state_loader_mode(),
536
- method=method,
537
- ))
695
+ log_api_event(
696
+ "sync",
697
+ dict(
698
+ state_loader_mode=context.state_loader_mode(),
699
+ method=method,
700
+ ),
701
+ )
538
702
 
539
703
  if not method:
540
704
  is_conflict = state_loader.check_conflict()
541
705
  if is_conflict:
542
- raise HTTPException(status_code=409, detail='Conflict detected')
543
- method = 'overwrite'
706
+ raise HTTPException(status_code=409, detail="Conflict detected")
707
+ method = "overwrite"
544
708
 
545
709
  is_syncing = state_loader.state_lock.locked()
546
710
  if is_syncing:
@@ -590,7 +754,7 @@ async def share_state():
590
754
  context = default_context()
591
755
  state_loader = context.state_loader
592
756
 
593
- file_name = 'recce_state.json'
757
+ file_name = "recce_state.json"
594
758
  if state_loader.state_file:
595
759
  file_name = os.path.basename(state_loader.state_file)
596
760
 
@@ -626,8 +790,8 @@ async def websocket_endpoint(websocket: WebSocket):
626
790
  try:
627
791
  while True:
628
792
  data = await websocket.receive_text()
629
- if data == 'ping':
630
- await websocket.send_text('pong')
793
+ if data == "ping":
794
+ await websocket.send_text("pong")
631
795
  except WebSocketDisconnect:
632
796
  clients.remove(websocket)
633
797
 
@@ -637,9 +801,37 @@ async def broadcast(data: str):
637
801
  await client.send_text(data)
638
802
 
639
803
 
640
- api_prefix = '/api'
804
+ @app.post("/api/connect")
805
+ async def generate_connect_to_cloud_url(background_tasks: BackgroundTasks):
806
+ if is_callback_server_running():
807
+ return {"connection_url": get_connection_url()}
808
+
809
+ private_key, public_key = generate_key_pair()
810
+ connection_url, callback_port = prepare_connection_url(public_key)
811
+
812
+ background_tasks.add_task(connect_to_cloud_background_task, private_key, callback_port, connection_url)
813
+ return {
814
+ "connection_url": connection_url,
815
+ }
816
+
817
+
818
+ @app.get("/api/users")
819
+ async def get_user_info():
820
+ from recce.connect_to_cloud import RecceCloud
821
+
822
+ context = default_context()
823
+ user_token = get_recce_api_token() or context.state_loader.token
824
+ cloud = RecceCloud(user_token)
825
+ try:
826
+ user_info = cloud.get_user_info()
827
+ return user_info
828
+ except Exception as e:
829
+ raise HTTPException(status_code=400, detail=str(e))
830
+
831
+
832
+ api_prefix = "/api"
641
833
  app.include_router(check_router, prefix=api_prefix)
642
834
  app.include_router(run_router, prefix=api_prefix)
643
835
 
644
- static_folder_path = Path(__file__).parent / 'data'
836
+ static_folder_path = Path(__file__).parent / "data"
645
837
  app.mount("/", StaticFiles(directory=static_folder_path, html=True), name="static")