recce-nightly 0.62.0.20250417__py3-none-any.whl → 1.30.0.20251221__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 (245) 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 +845 -461
  5. recce/adapter/dbt_adapter/dbt_version.py +3 -0
  6. recce/adapter/sqlmesh_adapter.py +24 -35
  7. recce/apis/check_api.py +59 -42
  8. recce/apis/check_events_api.py +353 -0
  9. recce/apis/check_func.py +41 -35
  10. recce/apis/run_api.py +25 -19
  11. recce/apis/run_func.py +64 -25
  12. recce/artifact.py +119 -51
  13. recce/cli.py +1301 -324
  14. recce/config.py +43 -34
  15. recce/connect_to_cloud.py +138 -0
  16. recce/core.py +55 -47
  17. recce/data/404/index.html +2 -0
  18. recce/data/404.html +2 -1
  19. recce/data/__next.@lineage.!KHNsb3Qp.__PAGE__.txt +7 -0
  20. recce/data/__next.@lineage.!KHNsb3Qp.txt +4 -0
  21. recce/data/__next.__PAGE__.txt +6 -0
  22. recce/data/__next._full.txt +32 -0
  23. recce/data/__next._head.txt +8 -0
  24. recce/data/__next._index.txt +14 -0
  25. recce/data/__next._tree.txt +8 -0
  26. recce/data/_next/static/chunks/025a7e3e3f9f40ae.js +1 -0
  27. recce/data/_next/static/chunks/0ce56d67ef5779ca.js +4 -0
  28. recce/data/_next/static/chunks/1a6a78780155dac7.js +48 -0
  29. recce/data/_next/static/chunks/1de8485918b9182a.css +2 -0
  30. recce/data/_next/static/chunks/1e4b1b50d1e34993.js +1 -0
  31. recce/data/_next/static/chunks/206d5d181e4c738e.js +1 -0
  32. recce/data/_next/static/chunks/2c357efc34c5b859.js +25 -0
  33. recce/data/_next/static/chunks/2e9d95d2d48c479c.js +1 -0
  34. recce/data/_next/static/chunks/2f016dc4a3edad2e.js +2 -0
  35. recce/data/_next/static/chunks/313251962d698f7c.js +1 -0
  36. recce/data/_next/static/chunks/3a9f021f38eb5574.css +1 -0
  37. recce/data/_next/static/chunks/40079da8d2b8f651.js +1 -0
  38. recce/data/_next/static/chunks/4599182bffb64661.js +38 -0
  39. recce/data/_next/static/chunks/4e62f6e184173580.js +1 -0
  40. recce/data/_next/static/chunks/5c4dfb0d09eaa401.js +1 -0
  41. recce/data/_next/static/chunks/69e4f06ccfdfc3ac.js +1 -0
  42. recce/data/_next/static/chunks/6b206cb4707d6bee.js +1 -0
  43. recce/data/_next/static/chunks/6d8557f062aa4386.css +1 -0
  44. recce/data/_next/static/chunks/7fbe3650bd83b6b5.js +1 -0
  45. recce/data/_next/static/chunks/83fa823a825674f6.js +1 -0
  46. recce/data/_next/static/chunks/848a6c9b5f55f7ed.js +1 -0
  47. recce/data/_next/static/chunks/859462b0858aef88.css +2 -0
  48. recce/data/_next/static/chunks/923964f18c87d0f1.css +1 -0
  49. recce/data/_next/static/chunks/939390f911895d7c.js +48 -0
  50. recce/data/_next/static/chunks/99a9817237a07f43.js +1 -0
  51. recce/data/_next/static/chunks/9fed8b4b2b924054.js +5 -0
  52. recce/data/_next/static/chunks/b6949f6c5892110c.js +1 -0
  53. recce/data/_next/static/chunks/b851a1d3f8149828.js +1 -0
  54. recce/data/_next/static/chunks/c734f9ad957de0b4.js +1 -0
  55. recce/data/_next/static/chunks/cdde321b0ec75717.js +2 -0
  56. recce/data/_next/static/chunks/d0f91117d77ff844.css +1 -0
  57. recce/data/_next/static/chunks/d6c8667911c2500f.js +1 -0
  58. recce/data/_next/static/chunks/da8dab68c02752cf.js +74 -0
  59. recce/data/_next/static/chunks/dc074049c9d12d97.js +109 -0
  60. recce/data/_next/static/chunks/ee7f1a8227342421.js +1 -0
  61. recce/data/_next/static/chunks/fa2f4e56c2fccc73.js +1 -0
  62. recce/data/_next/static/chunks/turbopack-1fad664f62979b93.js +3 -0
  63. recce/data/_next/static/media/favicon.a8d38d84.ico +0 -0
  64. recce/data/_next/static/media/montserrat-cyrillic-800-normal.d80d830d.woff2 +0 -0
  65. recce/data/_next/static/media/montserrat-cyrillic-800-normal.f9d58125.woff +0 -0
  66. recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.076c2a93.woff2 +0 -0
  67. recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.a4fa76b5.woff +0 -0
  68. recce/data/_next/static/media/montserrat-latin-800-normal.cde454cc.woff2 +0 -0
  69. recce/data/_next/static/media/montserrat-latin-800-normal.d5761935.woff +0 -0
  70. recce/data/_next/static/media/montserrat-latin-ext-800-normal.40ec0659.woff2 +0 -0
  71. recce/data/_next/static/media/montserrat-latin-ext-800-normal.b671449b.woff +0 -0
  72. recce/data/_next/static/media/montserrat-vietnamese-800-normal.9f7b8541.woff +0 -0
  73. recce/data/_next/static/media/montserrat-vietnamese-800-normal.f9eb854e.woff2 +0 -0
  74. recce/data/_next/static/nX-Uz0AH6Tc6hIQUFGqaB/_buildManifest.js +11 -0
  75. recce/data/_next/static/nX-Uz0AH6Tc6hIQUFGqaB/_clientMiddlewareManifest.json +1 -0
  76. recce/data/_not-found/__next._full.txt +24 -0
  77. recce/data/_not-found/__next._head.txt +8 -0
  78. recce/data/_not-found/__next._index.txt +13 -0
  79. recce/data/_not-found/__next._not-found.__PAGE__.txt +5 -0
  80. recce/data/_not-found/__next._not-found.txt +4 -0
  81. recce/data/_not-found/__next._tree.txt +6 -0
  82. recce/data/_not-found/index.html +2 -0
  83. recce/data/_not-found/index.txt +24 -0
  84. recce/data/auth_callback.html +68 -0
  85. recce/data/checks/__next.@lineage.__DEFAULT__.txt +7 -0
  86. recce/data/checks/__next._full.txt +39 -0
  87. recce/data/checks/__next._head.txt +8 -0
  88. recce/data/checks/__next._index.txt +14 -0
  89. recce/data/checks/__next._tree.txt +8 -0
  90. recce/data/checks/__next.checks.__PAGE__.txt +10 -0
  91. recce/data/checks/__next.checks.txt +4 -0
  92. recce/data/checks/index.html +2 -0
  93. recce/data/checks/index.txt +39 -0
  94. recce/data/imgs/reload-image.svg +4 -0
  95. recce/data/index.html +2 -27
  96. recce/data/index.txt +32 -7
  97. recce/data/lineage/__next.@lineage.__DEFAULT__.txt +7 -0
  98. recce/data/lineage/__next._full.txt +39 -0
  99. recce/data/lineage/__next._head.txt +8 -0
  100. recce/data/lineage/__next._index.txt +14 -0
  101. recce/data/lineage/__next._tree.txt +8 -0
  102. recce/data/lineage/__next.lineage.__PAGE__.txt +10 -0
  103. recce/data/lineage/__next.lineage.txt +4 -0
  104. recce/data/lineage/index.html +2 -0
  105. recce/data/lineage/index.txt +39 -0
  106. recce/data/query/__next.@lineage.__DEFAULT__.txt +7 -0
  107. recce/data/query/__next._full.txt +37 -0
  108. recce/data/query/__next._head.txt +8 -0
  109. recce/data/query/__next._index.txt +14 -0
  110. recce/data/query/__next._tree.txt +8 -0
  111. recce/data/query/__next.query.__PAGE__.txt +9 -0
  112. recce/data/query/__next.query.txt +4 -0
  113. recce/data/query/index.html +2 -0
  114. recce/data/query/index.txt +37 -0
  115. recce/diff.py +6 -12
  116. recce/event/CONFIG.bak +1 -0
  117. recce/event/__init__.py +86 -74
  118. recce/event/collector.py +33 -22
  119. recce/event/track.py +49 -27
  120. recce/exceptions.py +1 -1
  121. recce/git.py +7 -7
  122. recce/github.py +57 -53
  123. recce/mcp_server.py +725 -0
  124. recce/models/__init__.py +4 -1
  125. recce/models/check.py +438 -21
  126. recce/models/run.py +1 -0
  127. recce/models/types.py +134 -28
  128. recce/pull_request.py +27 -25
  129. recce/run.py +179 -122
  130. recce/server.py +394 -104
  131. recce/state/__init__.py +31 -0
  132. recce/state/cloud.py +644 -0
  133. recce/state/const.py +26 -0
  134. recce/state/local.py +56 -0
  135. recce/state/state.py +119 -0
  136. recce/state/state_loader.py +174 -0
  137. recce/summary.py +196 -149
  138. recce/tasks/__init__.py +19 -3
  139. recce/tasks/core.py +11 -13
  140. recce/tasks/dataframe.py +82 -18
  141. recce/tasks/histogram.py +69 -34
  142. recce/tasks/lineage.py +2 -2
  143. recce/tasks/profile.py +152 -86
  144. recce/tasks/query.py +180 -89
  145. recce/tasks/rowcount.py +37 -31
  146. recce/tasks/schema.py +18 -15
  147. recce/tasks/top_k.py +35 -35
  148. recce/tasks/utils.py +147 -0
  149. recce/tasks/valuediff.py +247 -155
  150. recce/util/__init__.py +3 -0
  151. recce/util/api_token.py +80 -0
  152. recce/util/breaking.py +105 -100
  153. recce/util/cll.py +274 -219
  154. recce/util/cloud/__init__.py +15 -0
  155. recce/util/cloud/base.py +115 -0
  156. recce/util/cloud/check_events.py +190 -0
  157. recce/util/cloud/checks.py +242 -0
  158. recce/util/io.py +22 -17
  159. recce/util/lineage.py +65 -16
  160. recce/util/logger.py +1 -1
  161. recce/util/onboarding_state.py +45 -0
  162. recce/util/perf_tracking.py +85 -0
  163. recce/util/recce_cloud.py +347 -72
  164. recce/util/singleton.py +4 -4
  165. recce/util/startup_perf.py +121 -0
  166. recce/yaml/__init__.py +7 -10
  167. recce_nightly-1.30.0.20251221.dist-info/METADATA +195 -0
  168. recce_nightly-1.30.0.20251221.dist-info/RECORD +183 -0
  169. {recce_nightly-0.62.0.20250417.dist-info → recce_nightly-1.30.0.20251221.dist-info}/WHEEL +1 -2
  170. recce/data/_next/static/chunks/1f229bf6-d9fe92e56db8d93b.js +0 -1
  171. recce/data/_next/static/chunks/29e3cc0d-8c150e37dff9631b.js +0 -1
  172. recce/data/_next/static/chunks/36e1c10d-bb0210cbd6573a8d.js +0 -1
  173. recce/data/_next/static/chunks/3998a672-eaad84bdd88cc73e.js +0 -1
  174. recce/data/_next/static/chunks/450c323b-1bb5db526e54435a.js +0 -1
  175. recce/data/_next/static/chunks/47d8844f-79a1b53c66a7d7ec.js +0 -1
  176. recce/data/_next/static/chunks/500-e51c92a025a51234.js +0 -65
  177. recce/data/_next/static/chunks/6dc81886-c94b9b91bc2c3caf.js +0 -1
  178. recce/data/_next/static/chunks/700-3b65fc3666820d00.js +0 -2
  179. recce/data/_next/static/chunks/7a8a3e83-d7fa409d97b38b2b.js +0 -1
  180. recce/data/_next/static/chunks/7f27ae6c-413f6b869a04183a.js +0 -1
  181. recce/data/_next/static/chunks/9746af58-d74bef4d03eea6ab.js +0 -1
  182. recce/data/_next/static/chunks/a30376cd-7d806e1602f2dc3a.js +0 -1
  183. recce/data/_next/static/chunks/app/_not-found/page-8a886fa0855c3105.js +0 -1
  184. recce/data/_next/static/chunks/app/layout-9102e22cb73f74d6.js +0 -1
  185. recce/data/_next/static/chunks/app/page-9adc25782272ed2e.js +0 -1
  186. recce/data/_next/static/chunks/b63b1b3f-7395c74e11a14e95.js +0 -1
  187. recce/data/_next/static/chunks/c132bf7d-8102037f9ccf372a.js +0 -1
  188. recce/data/_next/static/chunks/c1ceaa8b-a1e442154d23515e.js +0 -1
  189. recce/data/_next/static/chunks/cd9f8d63-cf0d5a7b0f7a92e8.js +0 -54
  190. recce/data/_next/static/chunks/ce84277d-f42c2c58049cea2d.js +0 -1
  191. recce/data/_next/static/chunks/e24bf851-0f8cbc99656833e7.js +0 -1
  192. recce/data/_next/static/chunks/fee69bc6-f17d36c080742e74.js +0 -1
  193. recce/data/_next/static/chunks/framework-ded83d71b51ce901.js +0 -1
  194. recce/data/_next/static/chunks/main-a0859f1f36d0aa6c.js +0 -1
  195. recce/data/_next/static/chunks/main-app-0225a2255968e566.js +0 -1
  196. recce/data/_next/static/chunks/pages/_app-d5672bf3d8b6371b.js +0 -1
  197. recce/data/_next/static/chunks/pages/_error-ed75be3f25588548.js +0 -1
  198. recce/data/_next/static/chunks/webpack-567d72f0bc0820d5.js +0 -1
  199. recce/data/_next/static/css/c9ecb46a4b21c126.css +0 -14
  200. recce/data/_next/static/media/montserrat-cyrillic-800-normal.22628180.woff2 +0 -0
  201. recce/data/_next/static/media/montserrat-cyrillic-800-normal.31d693bb.woff +0 -0
  202. recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.7e2c1e62.woff +0 -0
  203. recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.94a63aea.woff2 +0 -0
  204. recce/data/_next/static/media/montserrat-latin-800-normal.6f8fa298.woff2 +0 -0
  205. recce/data/_next/static/media/montserrat-latin-800-normal.97e20d5e.woff +0 -0
  206. recce/data/_next/static/media/montserrat-latin-ext-800-normal.013b84f9.woff2 +0 -0
  207. recce/data/_next/static/media/montserrat-latin-ext-800-normal.aff52ab0.woff +0 -0
  208. recce/data/_next/static/media/montserrat-vietnamese-800-normal.5f21869b.woff +0 -0
  209. recce/data/_next/static/media/montserrat-vietnamese-800-normal.c0035377.woff2 +0 -0
  210. recce/data/_next/static/qiyFlux77VkhxiceAJe_F/_buildManifest.js +0 -1
  211. recce/state.py +0 -753
  212. recce_nightly-0.62.0.20250417.dist-info/METADATA +0 -311
  213. recce_nightly-0.62.0.20250417.dist-info/RECORD +0 -139
  214. recce_nightly-0.62.0.20250417.dist-info/top_level.txt +0 -2
  215. tests/__init__.py +0 -0
  216. tests/adapter/__init__.py +0 -0
  217. tests/adapter/dbt_adapter/__init__.py +0 -0
  218. tests/adapter/dbt_adapter/conftest.py +0 -13
  219. tests/adapter/dbt_adapter/dbt_test_helper.py +0 -283
  220. tests/adapter/dbt_adapter/test_dbt_adapter.py +0 -40
  221. tests/adapter/dbt_adapter/test_dbt_cll.py +0 -102
  222. tests/adapter/dbt_adapter/test_selector.py +0 -177
  223. tests/tasks/__init__.py +0 -0
  224. tests/tasks/conftest.py +0 -4
  225. tests/tasks/test_histogram.py +0 -137
  226. tests/tasks/test_lineage.py +0 -42
  227. tests/tasks/test_preset_checks.py +0 -50
  228. tests/tasks/test_profile.py +0 -73
  229. tests/tasks/test_query.py +0 -151
  230. tests/tasks/test_row_count.py +0 -116
  231. tests/tasks/test_schema.py +0 -99
  232. tests/tasks/test_top_k.py +0 -73
  233. tests/tasks/test_valuediff.py +0 -74
  234. tests/test_cli.py +0 -122
  235. tests/test_config.py +0 -45
  236. tests/test_core.py +0 -27
  237. tests/test_dbt.py +0 -36
  238. tests/test_pull_request.py +0 -130
  239. tests/test_server.py +0 -98
  240. tests/test_state.py +0 -123
  241. tests/test_summary.py +0 -57
  242. /recce/data/_next/static/chunks/{polyfills-42372ed130431b0a.js → a6dad97d9634a72d.js} +0 -0
  243. /recce/data/_next/static/{qiyFlux77VkhxiceAJe_F → nX-Uz0AH6Tc6hIQUFGqaB}/_ssgManifest.js +0 -0
  244. {recce_nightly-0.62.0.20250417.dist-info → recce_nightly-1.30.0.20251221.dist-info}/entry_points.txt +0 -0
  245. {recce_nightly-0.62.0.20250417.dist-info → recce_nightly-1.30.0.20251221.dist-info}/licenses/LICENSE +0 -0
@@ -2,6 +2,7 @@ class DbtVersion:
2
2
 
3
3
  def __init__(self):
4
4
  from dbt import version as dbt_version
5
+
5
6
  dbt_version = self.parse(dbt_version.__version__)
6
7
  if dbt_version.is_prerelease:
7
8
  dbt_version = self.parse(dbt_version.base_version)
@@ -10,10 +11,12 @@ class DbtVersion:
10
11
  @staticmethod
11
12
  def parse(version: str):
12
13
  from packaging import version as v
14
+
13
15
  return v.parse(version)
14
16
 
15
17
  def as_version(self, other):
16
18
  from packaging.version import Version
19
+
17
20
  if isinstance(other, Version):
18
21
  return other
19
22
  if isinstance(other, str):
@@ -1,9 +1,9 @@
1
1
  import typing as t
2
2
  from dataclasses import dataclass
3
- from typing import Optional, Dict, Type
3
+ from typing import Dict, Optional, Type
4
4
 
5
5
  import pandas as pd
6
- from sqlglot import parse_one, Expression, select
6
+ from sqlglot import Expression, parse_one, select
7
7
  from sqlglot.optimizer import traverse_scope
8
8
  from sqlmesh.core.context import Context as SqlmeshContext
9
9
  from sqlmesh.core.environment import Environment
@@ -11,7 +11,7 @@ from sqlmesh.core.state_sync import StateReader
11
11
 
12
12
  from recce.adapter.base import BaseAdapter
13
13
  from recce.models import RunType
14
- from recce.tasks import Task, QueryTask, QueryDiffTask, RowCountDiffTask
14
+ from recce.tasks import QueryDiffTask, QueryTask, RowCountDiffTask, Task
15
15
 
16
16
  sqlmesh_supported_registry: Dict[RunType, Type[Task]] = {
17
17
  RunType.QUERY: QueryTask,
@@ -37,7 +37,7 @@ class SqlmeshAdapter(BaseAdapter):
37
37
 
38
38
  for snapshot in state_reader.get_snapshots(env.snapshots, hydrate_seeds=True).values():
39
39
 
40
- if snapshot.node_type.lower() != 'model':
40
+ if snapshot.node_type.lower() != "model":
41
41
  continue
42
42
 
43
43
  model = snapshot.model
@@ -45,16 +45,16 @@ class SqlmeshAdapter(BaseAdapter):
45
45
  continue
46
46
 
47
47
  node = {
48
- 'unique_id': model.name,
49
- 'name': model.name,
50
- 'resource_type': snapshot.node_type.lower(),
51
- 'checksum': {'checksum': snapshot.fingerprint.data_hash},
48
+ "unique_id": model.name,
49
+ "name": model.name,
50
+ "resource_type": snapshot.node_type.lower(),
51
+ "checksum": {"checksum": snapshot.fingerprint.data_hash},
52
52
  }
53
53
 
54
54
  columns = {}
55
55
  for column, type in model.columns_to_types.items():
56
- columns[column] = {'name': column, 'type': str(type)}
57
- node['columns'] = columns
56
+ columns[column] = {"name": column, "type": str(type)}
57
+ node["columns"] = columns
58
58
 
59
59
  nodes[snapshot.name] = node
60
60
  parents = [snapshotId.name for snapshotId in snapshot.parents]
@@ -81,15 +81,15 @@ class SqlmeshAdapter(BaseAdapter):
81
81
 
82
82
  @classmethod
83
83
  def load(cls, **kwargs):
84
- sqlmesh_envs = kwargs.get('sqlmesh_envs')
84
+ sqlmesh_envs = kwargs.get("sqlmesh_envs")
85
85
  if sqlmesh_envs is None:
86
- raise Exception('\'--sqlmesh-envs SOURCE:TARGET\' is required')
86
+ raise Exception("'--sqlmesh-envs SOURCE:TARGET' is required")
87
87
 
88
- envs = sqlmesh_envs.split(':')
88
+ envs = sqlmesh_envs.split(":")
89
89
  if len(envs) != 2:
90
90
  raise Exception('sqlmesh_envs must be in the format of "SOURCE:TARGET"')
91
91
 
92
- sqlmesh_config = kwargs.get('sqlmesh_config', None)
92
+ sqlmesh_config = kwargs.get("sqlmesh_config", None)
93
93
  context = SqlmeshContext(config=sqlmesh_config)
94
94
  base_env = context.state_reader.get_environment(envs[0])
95
95
  curr_env = context.state_reader.get_environment(envs[1])
@@ -100,18 +100,14 @@ class SqlmeshAdapter(BaseAdapter):
100
100
 
101
101
  return cls(context=context, base_env=base_env, curr_env=curr_env)
102
102
 
103
- def replace_virtual_tables(
104
- self,
105
- sql: t.Union[Expression, str],
106
- base: bool = None
107
- ) -> Expression:
108
- '''
103
+ def replace_virtual_tables(self, sql: t.Union[Expression, str], base: bool = None) -> Expression:
104
+ """
109
105
  Replace virtual tables based on the env name.
110
106
 
111
107
  Args:
112
108
  sql: SQL expression to replace virtual tables
113
109
  base: True: replace virtual tables with base env, False: replace virtual tables with current env, None: no replacement
114
- '''
110
+ """
115
111
  if isinstance(sql, str):
116
112
  expression = parse_one(sql, dialect=self.context.default_dialect)
117
113
  else:
@@ -119,30 +115,23 @@ class SqlmeshAdapter(BaseAdapter):
119
115
 
120
116
  if base is not None:
121
117
  env = self.base_env if base else self.curr_env
122
- if env.name != 'prod':
118
+ if env.name != "prod":
123
119
  model_names = [model.name for model in self.context.models.values()]
124
120
  for scope in traverse_scope(expression):
125
121
  for table in scope.tables:
126
- if f'{table.db}.{table.name}' in model_names:
127
- table.args['db'] = f"{table.args['db']}__{env.name}"
122
+ if f"{table.db}.{table.name}" in model_names:
123
+ table.args["db"] = f"{table.args['db']}__{env.name}"
128
124
 
129
125
  return expression
130
126
 
131
127
  def fetchdf_with_limit(
132
- self,
133
- sql: t.Union[Expression, str],
134
- base: Optional[bool] = None,
135
- limit: Optional[int] = None
128
+ self, sql: t.Union[Expression, str], base: Optional[bool] = None, limit: Optional[int] = None
136
129
  ) -> (pd.DataFrame, bool):
137
130
  expression = self.replace_virtual_tables(sql, base=base)
138
131
  if limit:
139
- expression = select(
140
- '*'
141
- ).from_(
142
- '__QUERY'
143
- ).with_(
144
- '__QUERY', as_=expression
145
- ).limit(limit + 1 if limit else None)
132
+ expression = (
133
+ select("*").from_("__QUERY").with_("__QUERY", as_=expression).limit(limit + 1 if limit else None)
134
+ )
146
135
  df = self.context.fetchdf(expression)
147
136
  if limit and len(df) > limit:
148
137
  df = df.head(limit)
recce/apis/check_api.py CHANGED
@@ -1,24 +1,27 @@
1
- from datetime import datetime, timezone
2
1
  from typing import Optional
3
2
  from uuid import UUID
4
3
 
5
- from fastapi import APIRouter, HTTPException, BackgroundTasks
4
+ from fastapi import APIRouter, BackgroundTasks, HTTPException
6
5
  from pydantic import BaseModel
7
6
 
8
- from recce.apis.check_func import create_check_without_run, create_check_from_run, export_persistent_state
7
+ from recce.apis.check_func import (
8
+ create_check_from_run,
9
+ create_check_without_run,
10
+ export_persistent_state,
11
+ )
9
12
  from recce.apis.run_func import submit_run
10
13
  from recce.event import log_api_event
11
14
  from recce.exceptions import RecceException
12
- from recce.models import RunType, RunDAO, Check, CheckDAO, Run
15
+ from recce.models import Check, CheckDAO, Run, RunDAO, RunType
13
16
 
14
- check_router = APIRouter(tags=['check'])
17
+ check_router = APIRouter(tags=["check"])
15
18
 
16
19
 
17
20
  class CreateCheckIn(BaseModel):
18
21
  name: Optional[str] = None
19
- description: str = ''
22
+ description: str = ""
20
23
  run_id: Optional[str] = None
21
- type: Optional[RunType] = None,
24
+ type: Optional[RunType] = (None,)
22
25
  params: Optional[dict] = None
23
26
  view_options: Optional[dict] = None
24
27
  track_props: Optional[dict] = None
@@ -39,16 +42,17 @@ class CheckOut(BaseModel):
39
42
  def from_check(cls, check: Check):
40
43
  check_related_runs = RunDAO().list_by_check_id(check.check_id)
41
44
  last_run = check_related_runs[-1] if len(check_related_runs) > 0 else None
42
- return CheckOut(check_id=check.check_id,
43
- name=check.name,
44
- description=check.description,
45
- type=check.type.value,
46
- params=check.params,
47
- view_options=check.view_options,
48
- is_checked=check.is_checked,
49
- is_preset=check.is_preset,
50
- last_run=last_run,
51
- )
45
+ return CheckOut(
46
+ check_id=check.check_id,
47
+ name=check.name,
48
+ description=check.description,
49
+ type=check.type.value,
50
+ params=check.params,
51
+ view_options=check.view_options,
52
+ is_checked=check.is_checked,
53
+ is_preset=check.is_preset,
54
+ last_run=last_run,
55
+ )
52
56
 
53
57
 
54
58
  @check_router.post("/checks", status_code=201, response_model=CheckOut)
@@ -59,7 +63,7 @@ async def create_check(check_in: CreateCheckIn, background_tasks: BackgroundTask
59
63
  check_in.run_id,
60
64
  check_name=check_in.name,
61
65
  check_description=check_in.description,
62
- check_view_options=check_in.view_options
66
+ check_view_options=check_in.view_options,
63
67
  )
64
68
  else:
65
69
  check = create_check_without_run(
@@ -67,12 +71,15 @@ async def create_check(check_in: CreateCheckIn, background_tasks: BackgroundTask
67
71
  check_description=check_in.description,
68
72
  check_type=check_in.type,
69
73
  params=check_in.params,
70
- check_view_options=check_in.view_options
74
+ check_view_options=check_in.view_options,
71
75
  )
72
- log_api_event('create_check', dict(
73
- type=str(check.type),
74
- track_props=check_in.track_props,
75
- ))
76
+ log_api_event(
77
+ "create_check",
78
+ dict(
79
+ type=str(check.type),
80
+ track_props=check_in.track_props,
81
+ ),
82
+ )
76
83
  except NameError as e:
77
84
  raise HTTPException(status_code=404, detail=str(e))
78
85
  except ValueError as e:
@@ -93,10 +100,13 @@ async def run_check_handler(check_id: UUID, input: RunCheckIn):
93
100
  raise HTTPException(status_code=404, detail=f"Check ID '{check_id}' not found")
94
101
 
95
102
  try:
96
- log_api_event('rerun_check', dict(
97
- type=str(check.type),
98
- rerun=True,
99
- ))
103
+ log_api_event(
104
+ "rerun_check",
105
+ dict(
106
+ type=str(check.type),
107
+ rerun=True,
108
+ ),
109
+ )
100
110
  run, future = submit_run(check.type, check.params, check_id=check_id)
101
111
  except RecceException as e:
102
112
  raise HTTPException(status_code=400, detail=str(e))
@@ -122,7 +132,7 @@ async def list_checks_handler():
122
132
  async def get_check_handler(check_id: UUID):
123
133
  check = CheckDAO().find_check_by_id(check_id)
124
134
  if check is None:
125
- raise HTTPException(status_code=404, detail='Not Found')
135
+ raise HTTPException(status_code=404, detail="Not Found")
126
136
 
127
137
  runs = RunDAO().list_by_check_id(check_id)
128
138
  last_run = runs[-1] if len(runs) > 0 else None
@@ -142,21 +152,9 @@ class PatchCheckIn(BaseModel):
142
152
 
143
153
  @check_router.patch("/checks/{check_id}", status_code=200, response_model=CheckOut, response_model_exclude_none=True)
144
154
  async def update_check_handler(check_id: UUID, patch: PatchCheckIn, background_tasks: BackgroundTasks):
145
- check = CheckDAO().find_check_by_id(check_id)
155
+ check = CheckDAO().update_check_by_id(check_id, patch)
146
156
  if check is None:
147
- raise HTTPException(status_code=404, detail='Not Found')
148
-
149
- if patch.name is not None:
150
- check.name = patch.name
151
- if patch.description is not None:
152
- check.description = patch.description
153
- if patch.params is not None:
154
- check.params = patch.params
155
- if patch.view_options is not None:
156
- check.view_options = patch.view_options
157
- if patch.is_checked is not None:
158
- check.is_checked = patch.is_checked
159
- check.updated_at = datetime.now(timezone.utc).replace(microsecond=0)
157
+ raise HTTPException(status_code=404, detail="Not Found")
160
158
 
161
159
  background_tasks.add_task(export_persistent_state)
162
160
  return CheckOut.from_check(check)
@@ -184,3 +182,22 @@ async def reorder_handler(order: ReorderChecksIn):
184
182
  CheckDAO().reorder(order.source, order.destination)
185
183
  except RecceException as e:
186
184
  raise HTTPException(status_code=400, detail=e.message)
185
+
186
+
187
+ @check_router.post("/checks/{check_id}/mark-as-preset", status_code=204)
188
+ async def mark_as_preset_check_handler(check_id: UUID, background_tasks: BackgroundTasks):
189
+ """
190
+ Mark an existing check as a preset check (cloud users only).
191
+
192
+ This creates a preset check from the specified check.
193
+ Only available for users with cloud mode enabled.
194
+
195
+ Returns:
196
+ 204 No Content: Successfully marked check as preset
197
+ 400 Bad Request: Error with detail message (e.g., not in cloud mode, check not found)
198
+ """
199
+ try:
200
+ CheckDAO().mark_as_preset_check(check_id)
201
+ background_tasks.add_task(export_persistent_state)
202
+ except RecceException as e:
203
+ raise HTTPException(status_code=400, detail=e.message)
@@ -0,0 +1,353 @@
1
+ """
2
+ Check Events API endpoints.
3
+
4
+ This module provides REST endpoints for check events (timeline/conversation),
5
+ proxying requests to Recce Cloud. This feature is only available for cloud users.
6
+ """
7
+
8
+ import logging
9
+ from typing import List, Optional
10
+ from uuid import UUID
11
+
12
+ from fastapi import APIRouter, HTTPException
13
+ from pydantic import BaseModel
14
+
15
+ from recce.core import default_context
16
+ from recce.event import get_recce_api_token
17
+ from recce.exceptions import RecceException
18
+ from recce.util.cloud.check_events import CheckEventsCloud
19
+ from recce.util.recce_cloud import RecceCloud, RecceCloudException
20
+
21
+ logger = logging.getLogger("uvicorn")
22
+
23
+ check_events_router = APIRouter(tags=["check_events"])
24
+
25
+
26
+ # ============================================================================
27
+ # Helper Functions
28
+ # ============================================================================
29
+
30
+
31
+ def _is_cloud_user() -> bool:
32
+ """Check if the current user is connected to Recce Cloud."""
33
+ ctx = default_context()
34
+ if ctx is None or ctx.state_loader is None:
35
+ return False
36
+ return hasattr(ctx.state_loader, "session_id") and ctx.state_loader.session_id is not None
37
+
38
+
39
+ def _get_session_info() -> tuple:
40
+ """
41
+ Get organization ID, project ID, and session ID from state loader.
42
+
43
+ Returns:
44
+ tuple: (org_id, project_id, session_id)
45
+
46
+ Raises:
47
+ HTTPException: If not in cloud mode or session info unavailable
48
+ """
49
+ if not _is_cloud_user():
50
+ raise HTTPException(
51
+ status_code=400,
52
+ detail="Check events are only available when connected to Recce Cloud.",
53
+ )
54
+
55
+ ctx = default_context()
56
+ state_loader = ctx.state_loader
57
+
58
+ session_id = state_loader.session_id
59
+
60
+ # Check if org_id and project_id are cached
61
+ if hasattr(state_loader, "org_id") and hasattr(state_loader, "project_id"):
62
+ return state_loader.org_id, state_loader.project_id, session_id
63
+
64
+ # Fetch from cloud API
65
+ api_token = get_recce_api_token() or state_loader.token
66
+ if not api_token:
67
+ raise HTTPException(
68
+ status_code=401,
69
+ detail="Cannot access Recce Cloud: no API token available.",
70
+ )
71
+
72
+ try:
73
+ recce_cloud = RecceCloud(api_token)
74
+ session = recce_cloud.get_session(session_id)
75
+
76
+ org_id = session.get("org_id")
77
+ project_id = session.get("project_id")
78
+
79
+ if not org_id or not project_id:
80
+ raise HTTPException(
81
+ status_code=400,
82
+ detail=f"Session {session_id} does not belong to a valid organization or project.",
83
+ )
84
+
85
+ # Cache for future use
86
+ state_loader.org_id = org_id
87
+ state_loader.project_id = project_id
88
+
89
+ return org_id, project_id, session_id
90
+
91
+ except RecceCloudException as e:
92
+ logger.error(f"Failed to get session info: {e}")
93
+ raise HTTPException(status_code=e.status_code, detail=str(e.reason))
94
+
95
+
96
+ def _get_events_client() -> CheckEventsCloud:
97
+ """
98
+ Get the CheckEventsCloud client.
99
+
100
+ Returns:
101
+ CheckEventsCloud: Cloud client for event operations
102
+
103
+ Raises:
104
+ HTTPException: If client cannot be initialized
105
+ """
106
+ ctx = default_context()
107
+ api_token = get_recce_api_token() or ctx.state_loader.token
108
+
109
+ if not api_token:
110
+ raise HTTPException(
111
+ status_code=401,
112
+ detail="Cannot access Recce Cloud: no API token available.",
113
+ )
114
+
115
+ return CheckEventsCloud(api_token)
116
+
117
+
118
+ # ============================================================================
119
+ # Pydantic Models
120
+ # ============================================================================
121
+
122
+
123
+ class CheckEventActorOut(BaseModel):
124
+ """Actor who performed the event."""
125
+
126
+ type: str # "user", "recce_ai", "preset_system"
127
+ user_id: Optional[int] = None
128
+ login: Optional[str] = None
129
+ fullname: Optional[str] = None
130
+
131
+
132
+ class CheckEventOut(BaseModel):
133
+ """Check event response model."""
134
+
135
+ id: str
136
+ check_id: str
137
+ event_type: str
138
+ actor: CheckEventActorOut
139
+ content: Optional[str] = None
140
+ old_value: Optional[str] = None
141
+ new_value: Optional[str] = None
142
+ is_edited: bool = False
143
+ is_deleted: bool = False
144
+ created_at: str
145
+ updated_at: str
146
+
147
+
148
+ class CreateCommentIn(BaseModel):
149
+ """Request body for creating a comment."""
150
+
151
+ content: str
152
+
153
+
154
+ class UpdateCommentIn(BaseModel):
155
+ """Request body for updating a comment."""
156
+
157
+ content: str
158
+
159
+
160
+ # ============================================================================
161
+ # API Endpoints
162
+ # ============================================================================
163
+
164
+
165
+ @check_events_router.get(
166
+ "/checks/{check_id}/events",
167
+ status_code=200,
168
+ response_model=List[CheckEventOut],
169
+ )
170
+ async def list_check_events(check_id: UUID):
171
+ """
172
+ List all events for a check in chronological order.
173
+
174
+ This endpoint returns all events (comments, state changes, etc.) for the
175
+ specified check. Events are returned in chronological order (oldest first).
176
+
177
+ Args:
178
+ check_id: The check ID
179
+
180
+ Returns:
181
+ List of CheckEventOut objects
182
+
183
+ Raises:
184
+ 400: Not connected to Recce Cloud
185
+ 401: No API token available
186
+ 404: Check not found
187
+ """
188
+ try:
189
+ org_id, project_id, session_id = _get_session_info()
190
+ client = _get_events_client()
191
+
192
+ events = client.list_events(org_id, project_id, session_id, str(check_id))
193
+ return events
194
+
195
+ except RecceCloudException as e:
196
+ logger.error(f"Failed to list check events: {e}")
197
+ raise HTTPException(status_code=e.status_code, detail=str(e.reason))
198
+ except RecceException as e:
199
+ logger.error(f"Failed to list check events: {e}")
200
+ raise HTTPException(status_code=400, detail=str(e))
201
+
202
+
203
+ @check_events_router.get(
204
+ "/checks/{check_id}/events/{event_id}",
205
+ status_code=200,
206
+ response_model=CheckEventOut,
207
+ )
208
+ async def get_check_event(check_id: UUID, event_id: UUID):
209
+ """
210
+ Get a specific event by ID.
211
+
212
+ Args:
213
+ check_id: The check ID
214
+ event_id: The event ID
215
+
216
+ Returns:
217
+ CheckEventOut object
218
+
219
+ Raises:
220
+ 400: Not connected to Recce Cloud
221
+ 401: No API token available
222
+ 404: Event not found
223
+ """
224
+ try:
225
+ org_id, project_id, session_id = _get_session_info()
226
+ client = _get_events_client()
227
+
228
+ event = client.get_event(org_id, project_id, session_id, str(check_id), str(event_id))
229
+ return event
230
+
231
+ except RecceCloudException as e:
232
+ logger.error(f"Failed to get check event: {e}")
233
+ raise HTTPException(status_code=e.status_code, detail=str(e.reason))
234
+ except RecceException as e:
235
+ logger.error(f"Failed to get check event: {e}")
236
+ raise HTTPException(status_code=400, detail=str(e))
237
+
238
+
239
+ @check_events_router.post(
240
+ "/checks/{check_id}/events",
241
+ status_code=201,
242
+ response_model=CheckEventOut,
243
+ )
244
+ async def create_comment(check_id: UUID, body: CreateCommentIn):
245
+ """
246
+ Create a new comment on a check.
247
+
248
+ Args:
249
+ check_id: The check ID
250
+ body: Request body containing comment content
251
+
252
+ Returns:
253
+ Created CheckEventOut object
254
+
255
+ Raises:
256
+ 400: Not connected to Recce Cloud or invalid content
257
+ 401: No API token available
258
+ 404: Check not found
259
+ """
260
+ if not body.content or not body.content.strip():
261
+ raise HTTPException(status_code=400, detail="Comment content cannot be empty.")
262
+
263
+ try:
264
+ org_id, project_id, session_id = _get_session_info()
265
+ client = _get_events_client()
266
+
267
+ event = client.create_comment(org_id, project_id, session_id, str(check_id), body.content)
268
+ return event
269
+
270
+ except RecceCloudException as e:
271
+ logger.error(f"Failed to create comment: {e}")
272
+ raise HTTPException(status_code=e.status_code, detail=str(e.reason))
273
+ except RecceException as e:
274
+ logger.error(f"Failed to create comment: {e}")
275
+ raise HTTPException(status_code=400, detail=str(e))
276
+
277
+
278
+ @check_events_router.patch(
279
+ "/checks/{check_id}/events/{event_id}",
280
+ status_code=200,
281
+ response_model=CheckEventOut,
282
+ )
283
+ async def update_comment(check_id: UUID, event_id: UUID, body: UpdateCommentIn):
284
+ """
285
+ Update an existing comment.
286
+
287
+ Only the author or an admin can update a comment.
288
+
289
+ Args:
290
+ check_id: The check ID
291
+ event_id: The event ID of the comment to update
292
+ body: Request body containing new comment content
293
+
294
+ Returns:
295
+ Updated CheckEventOut object
296
+
297
+ Raises:
298
+ 400: Not connected to Recce Cloud or invalid content
299
+ 401: No API token available
300
+ 403: Not authorized to update this comment
301
+ 404: Comment not found
302
+ """
303
+ if not body.content or not body.content.strip():
304
+ raise HTTPException(status_code=400, detail="Comment content cannot be empty.")
305
+
306
+ try:
307
+ org_id, project_id, session_id = _get_session_info()
308
+ client = _get_events_client()
309
+
310
+ event = client.update_comment(org_id, project_id, session_id, str(check_id), str(event_id), body.content)
311
+ return event
312
+
313
+ except RecceCloudException as e:
314
+ logger.error(f"Failed to update comment: {e}")
315
+ raise HTTPException(status_code=e.status_code, detail=str(e.reason))
316
+ except RecceException as e:
317
+ logger.error(f"Failed to update comment: {e}")
318
+ raise HTTPException(status_code=400, detail=str(e))
319
+
320
+
321
+ @check_events_router.delete(
322
+ "/checks/{check_id}/events/{event_id}",
323
+ status_code=204,
324
+ )
325
+ async def delete_comment(check_id: UUID, event_id: UUID):
326
+ """
327
+ Delete a comment (soft delete).
328
+
329
+ Only the author or an admin can delete a comment. The comment will be
330
+ marked as deleted but remain in the timeline with a "Comment deleted" indicator.
331
+
332
+ Args:
333
+ check_id: The check ID
334
+ event_id: The event ID of the comment to delete
335
+
336
+ Raises:
337
+ 400: Not connected to Recce Cloud
338
+ 401: No API token available
339
+ 403: Not authorized to delete this comment
340
+ 404: Comment not found
341
+ """
342
+ try:
343
+ org_id, project_id, session_id = _get_session_info()
344
+ client = _get_events_client()
345
+
346
+ client.delete_comment(org_id, project_id, session_id, str(check_id), str(event_id))
347
+
348
+ except RecceCloudException as e:
349
+ logger.error(f"Failed to delete comment: {e}")
350
+ raise HTTPException(status_code=e.status_code, detail=str(e.reason))
351
+ except RecceException as e:
352
+ logger.error(f"Failed to delete comment: {e}")
353
+ raise HTTPException(status_code=400, detail=str(e))