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
tests/test_config.py CHANGED
@@ -10,7 +10,7 @@ test_root_path = os.path.dirname(os.path.abspath(__file__))
10
10
 
11
11
  class RecceConfigTestCase(TestCase):
12
12
  def setUp(self):
13
- self.recce_config_path = os.path.join(test_root_path, 'data', 'config', 'recce.yml')
13
+ self.recce_config_path = os.path.join(test_root_path, "data", "config", "recce.yml")
14
14
  pass
15
15
 
16
16
  def tearDown(self):
@@ -21,25 +21,23 @@ class RecceConfigTestCase(TestCase):
21
21
  config = RecceConfig(self.recce_config_path)
22
22
 
23
23
  # Test data contains 2 checks
24
- preset_checks = config.config.get('checks')
24
+ preset_checks = config.config.get("checks")
25
25
  self.assertIsNotNone(preset_checks)
26
26
  self.assertIsInstance(preset_checks, list)
27
27
  self.assertEqual(len(preset_checks), 2)
28
28
 
29
- @patch('recce.config.RecceConfig.save')
29
+ @patch("recce.config.RecceConfig.save")
30
30
  def test_recce_config_not_found(self, mock_save):
31
- default_config = RecceConfig('NOT_EXISTING_FILE')
31
+ default_config = RecceConfig("NOT_EXISTING_FILE")
32
32
  assert mock_save.called is True
33
33
  # Default config should be generated
34
- preset_checks = default_config.config.get('checks')
34
+ preset_checks = default_config.config.get("checks")
35
35
  self.assertIsNotNone(default_config.config)
36
36
  self.assertIsInstance(preset_checks, list)
37
37
  self.assertEqual(len(preset_checks), 2)
38
38
 
39
- @patch('recce.yaml.safe_load')
39
+ @patch("recce.yaml.safe_load")
40
40
  def test_recce_config_null_checks(self, mock_yaml_safe_load):
41
41
  # mock to load a yaml file with null checks
42
- mock_yaml_safe_load.return_value = {
43
- 'checks': None
44
- }
42
+ mock_yaml_safe_load.return_value = {"checks": None}
45
43
  RecceConfig(self.recce_config_path)
@@ -0,0 +1,82 @@
1
+ import base64
2
+ import unittest
3
+ from unittest.mock import patch
4
+ from urllib.parse import quote
5
+
6
+ from cryptography.hazmat.primitives import hashes
7
+ from cryptography.hazmat.primitives.asymmetric import padding
8
+
9
+ from recce.connect_to_cloud import (
10
+ connect_to_cloud_background_task,
11
+ decrypt_code,
12
+ generate_key_pair,
13
+ is_callback_server_running,
14
+ prepare_connection_url,
15
+ )
16
+
17
+
18
+ class ConnectToCloudTests(unittest.TestCase):
19
+
20
+ def test_generate_key_pair(self):
21
+ private_key, public_key = generate_key_pair()
22
+ self.assertIsNotNone(private_key)
23
+ self.assertIsNotNone(public_key)
24
+ self.assertEqual(private_key.public_key().public_numbers(), public_key.public_numbers())
25
+
26
+ def test_prepare_connection_url(self):
27
+ _, public_key = generate_key_pair()
28
+ url, port = prepare_connection_url(public_key)
29
+ self.assertIn("connect?", url)
30
+ self.assertTrue(port >= 10000 and port <= 15000)
31
+
32
+ def test_decrypt_code(self):
33
+ private_key, public_key = generate_key_pair()
34
+ test_string = "recce-api-token-123"
35
+ ciphertext = public_key.encrypt(
36
+ test_string.encode(),
37
+ padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA1()), algorithm=hashes.SHA1(), label=None),
38
+ )
39
+ b64_ciphertext = base64.b64encode(ciphertext).decode()
40
+ result = decrypt_code(private_key, b64_ciphertext)
41
+ self.assertEqual(result, test_string)
42
+
43
+ @patch("recce.connect_to_cloud.update_recce_api_token")
44
+ @patch("recce.connect_to_cloud.update_onboarding_state")
45
+ @patch("recce.connect_to_cloud.RecceCloud")
46
+ def test_handle_callback_request_success(self, mock_recce_cloud, mock_update_state, mock_update_token):
47
+ private_key, public_key = generate_key_pair()
48
+
49
+ # Prepare encrypted token
50
+ test_token = "recce-api-token-xyz"
51
+ ciphertext = public_key.encrypt(
52
+ test_token.encode(),
53
+ padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA1()), algorithm=hashes.SHA1(), label=None),
54
+ )
55
+ encrypted_b64 = base64.b64encode(ciphertext).decode()
56
+
57
+ # Set up mocks
58
+ mock_recce_cloud.return_value.verify_token.return_value = True
59
+
60
+ from recce.connect_to_cloud import handle_callback_request
61
+
62
+ result = handle_callback_request(f"code={quote(encrypted_b64)}", private_key)
63
+
64
+ assert result == test_token
65
+ mock_update_token.assert_called_once_with(test_token)
66
+ mock_update_state.assert_called_once_with(test_token, False)
67
+
68
+ def test_is_callback_server_running(self):
69
+ # Should return False by default
70
+ self.assertFalse(is_callback_server_running())
71
+
72
+ @patch("recce.connect_to_cloud.run_one_time_http_server")
73
+ def test_connect_to_cloud_background_task_runs(self, mock_server):
74
+ private_key, public_key = generate_key_pair()
75
+ url, port = prepare_connection_url(public_key)
76
+
77
+ connect_to_cloud_background_task(private_key, port, url)
78
+ mock_server.assert_called_once()
79
+
80
+
81
+ if __name__ == "__main__":
82
+ unittest.main()
tests/test_core.py CHANGED
@@ -1,5 +1,152 @@
1
1
  # noinspection PyUnresolvedReferences
2
- from tests.adapter.dbt_adapter.conftest import dbt_test_helper
2
+ import os
3
+ import unittest
4
+ from datetime import datetime
5
+
6
+ from recce.core import RecceContext
7
+ from recce.models import Check, Run, RunType
8
+ from recce.state import ArtifactsRoot, FileStateLoader, RecceState
9
+ from tests.adapter.dbt_adapter.conftest import dbt_test_helper # noqa: F401
10
+
11
+ current_dir = os.path.dirname(os.path.abspath(__file__))
12
+
13
+
14
+ class TestRecceState(unittest.TestCase):
15
+ def test_load(self):
16
+ run = Run(type=RunType.QUERY, params=dict(sql_template="select * from users"))
17
+ check = Check(name="check 1", description="desc 1", type=run.type, params=run.params)
18
+
19
+ state = RecceState(runs=[run], checks=[check])
20
+ json_content = state.to_json()
21
+ new_state = RecceState.from_json(json_content)
22
+
23
+ run_loaded = new_state.runs[0]
24
+ check_loaded = new_state.checks[0]
25
+
26
+ assert run.run_id == run_loaded.run_id
27
+ assert check.check_id == check_loaded.check_id
28
+
29
+ def test_merge_checks(self):
30
+ check1 = Check(name="test1", description="", type="query")
31
+ check2 = Check(name="test2", description="", type="query", updated_at=datetime(2000, 1, 1))
32
+ check2_2 = Check(
33
+ name="test2_2", description="", type="query", updated_at=datetime(2020, 1, 1), check_id=check2.check_id
34
+ )
35
+ check3 = Check(name="test3", description="", type="query")
36
+
37
+ context = RecceContext()
38
+ state = RecceState(checks=[check1], runs=[])
39
+ context.import_state(state)
40
+ self.assertEqual(1, len(context.checks))
41
+ self.assertEqual(check1.name, context.checks[0].name)
42
+
43
+ context = RecceContext(checks=[check1, check2])
44
+ state = RecceState(checks=[check1, check2_2, check3], runs=[])
45
+ context.import_state(state)
46
+ self.assertEqual(3, len(context.checks))
47
+ self.assertEqual(check2_2.name, context.checks[1].name)
48
+
49
+ def test_merge_preset_checks(self):
50
+ check1 = Check(
51
+ name="test1",
52
+ description="test1",
53
+ type="query",
54
+ params=dict(foo="bar"),
55
+ updated_at=datetime(2000, 1, 1),
56
+ is_preset=True,
57
+ )
58
+ check2 = Check(
59
+ name="test2",
60
+ description="test2",
61
+ type="query",
62
+ params=dict(foo="bar"),
63
+ updated_at=datetime(2001, 1, 1),
64
+ is_preset=True,
65
+ )
66
+
67
+ context = RecceContext(checks=[check1])
68
+ state = RecceState(checks=[check2], runs=[])
69
+ context.import_state(state)
70
+ self.assertEqual(1, len(context.checks))
71
+ self.assertEqual(check2.name, context.checks[0].name)
72
+
73
+ context = RecceContext(checks=[check2])
74
+ state = RecceState(checks=[check1], runs=[])
75
+ context.import_state(state)
76
+ self.assertEqual(1, len(context.checks))
77
+ self.assertEqual(check2.name, context.checks[0].name)
78
+
79
+ def test_revert_checks(self):
80
+ check1 = Check(name="test1", description="", type="query")
81
+ check2 = Check(name="test2", description="", type="query")
82
+ check2_2 = Check(name="test2_2", description="", type="query", check_id=check2.check_id)
83
+ check3 = Check(name="test3", description="", type="query")
84
+
85
+ context = RecceContext(checks=[check1, check2])
86
+ state = RecceState(checks=[check2_2, check3], runs=[])
87
+ context.import_state(state, merge=False)
88
+ self.assertEqual(2, len(context.checks))
89
+ self.assertEqual(check2_2.name, context.checks[0].name)
90
+
91
+ def test_merge_runs(self):
92
+ run1 = Run(type="query")
93
+ run2 = Run(type="query")
94
+ run3 = Run(type="query")
95
+
96
+ context = RecceContext(runs=[])
97
+ state = RecceState(runs=[run1])
98
+ context.import_state(state)
99
+ self.assertEqual(1, len(context.runs))
100
+
101
+ context = RecceContext(runs=[run1, run2])
102
+ state = RecceState(runs=[run2, run3])
103
+ context.import_state(state)
104
+ self.assertEqual(3, len(context.runs))
105
+
106
+ def test_merge_dbt_artifacts(self):
107
+ import json
108
+ import os
109
+
110
+ with open(os.path.join(current_dir, "manifest.json"), "r") as f:
111
+ manifest = json.load(f)
112
+ manifest["metadata"]["generated_at"] = "2000-01-01T00:00:00Z"
113
+ artifacts = ArtifactsRoot(
114
+ base=dict(
115
+ manifest=manifest,
116
+ ),
117
+ current=dict(
118
+ manifest=manifest,
119
+ ),
120
+ )
121
+
122
+ from tests.adapter.dbt_adapter.dbt_test_helper import DbtTestHelper
123
+
124
+ adapter = DbtTestHelper().adapter
125
+ adapter.import_artifacts(artifacts)
126
+ self.assertNotEqual(adapter.base_manifest.metadata.invocation_id, manifest.get("metadata").get("invocation_id"))
127
+
128
+ manifest["metadata"]["generated_at"] = "2099-01-01T00:00:00Z"
129
+ adapter.import_artifacts(artifacts)
130
+ self.assertEqual(adapter.base_manifest.metadata.invocation_id, manifest.get("metadata").get("invocation_id"))
131
+
132
+ def test_state_loader(self):
133
+ # copy ./recce_state.json to temp and open
134
+
135
+ # use library to create a temp file in the context
136
+ import os
137
+ import shutil
138
+ import tempfile
139
+
140
+ with tempfile.NamedTemporaryFile() as f:
141
+ # copy ./recce_state.json to temp file
142
+ current_dir = os.path.dirname(os.path.abspath(__file__))
143
+ state_file = os.path.join(current_dir, "recce_state.json")
144
+ shutil.copy(state_file, f.name)
145
+
146
+ # load the state file
147
+ state_loader = FileStateLoader(state_file=f.name)
148
+ state = state_loader.load()
149
+ assert len(state.runs) == 17
3
150
 
4
151
 
5
152
  def test_lineage_diff(dbt_test_helper):
@@ -21,7 +168,7 @@ def test_lineage_diff(dbt_test_helper):
21
168
  dbt_test_helper.create_model("model1", sql_model1, sql_model1)
22
169
  dbt_test_helper.create_model("model2", sql_model2, sql_model2_)
23
170
  result = dbt_test_helper.context.get_lineage_diff()
24
- nodediff = result.diff.get('model1')
171
+ nodediff = result.diff.get("model1")
25
172
  assert nodediff is None
26
- nodediff2 = result.diff.get('model2')
27
- assert nodediff2 is not None and nodediff2.change_status == 'modified' and nodediff2.change.category == 'non_breaking'
173
+ nodediff2 = result.diff.get("model2")
174
+ assert nodediff2 is not None and nodediff2.change_status == "modified"
tests/test_dbt.py CHANGED
@@ -1,18 +1,18 @@
1
1
  import os
2
2
  from unittest import TestCase
3
- from unittest.mock import patch, MagicMock
3
+ from unittest.mock import MagicMock
4
4
 
5
- from recce.adapter.dbt_adapter import load_manifest, load_catalog, DbtAdapter
5
+ from recce.adapter.dbt_adapter import DbtAdapter, load_catalog, load_manifest
6
6
 
7
7
  current_dir = os.path.dirname(os.path.abspath(__file__))
8
8
 
9
9
 
10
10
  class TestAdapterLineage(TestCase):
11
11
  def setUp(self) -> None:
12
- self.manifest = load_manifest(path=os.path.join(current_dir, 'manifest.json'))
12
+ self.manifest = load_manifest(path=os.path.join(current_dir, "manifest.json"))
13
13
  assert self.manifest is not None
14
14
 
15
- self.catalog = load_catalog(path=os.path.join(current_dir, 'catalog.json'))
15
+ self.catalog = load_catalog(path=os.path.join(current_dir, "catalog.json"))
16
16
  assert self.catalog is not None
17
17
 
18
18
  def tearDown(self):
@@ -22,8 +22,8 @@ class TestAdapterLineage(TestCase):
22
22
  dbt_adapter = DbtAdapter(curr_manifest=self.manifest)
23
23
  lineage = dbt_adapter.get_lineage()
24
24
  assert lineage is not None
25
- assert lineage['nodes']['model.jaffle_shop.orders'] is not None
26
- assert 'columns' not in lineage['nodes']['model.jaffle_shop.orders']
25
+ assert lineage["nodes"]["model.jaffle_shop.orders"] is not None
26
+ assert "columns" not in lineage["nodes"]["model.jaffle_shop.orders"]
27
27
 
28
28
  def test_load_lineage_with_catalog(self):
29
29
  mock_adapter = MagicMock()
@@ -33,4 +33,4 @@ class TestAdapterLineage(TestCase):
33
33
  dbt_adapter.adapter = mock_adapter
34
34
  lineage = dbt_adapter.get_lineage()
35
35
  assert lineage is not None
36
- assert len(lineage['nodes']['model.jaffle_shop.orders']['columns']) == 9
36
+ assert len(lineage["nodes"]["model.jaffle_shop.orders"]["columns"]) == 9
@@ -0,0 +1,332 @@
1
+ from unittest.mock import MagicMock, patch
2
+
3
+ import pytest
4
+
5
+ # Skip all tests in this module if mcp is not available
6
+ pytest.importorskip("mcp")
7
+
8
+ from recce.core import RecceContext # noqa: E402
9
+ from recce.mcp_server import RecceMCPServer, run_mcp_server # noqa: E402
10
+ from recce.models.types import LineageDiff # noqa: E402
11
+ from recce.server import RecceServerMode # noqa: E402
12
+ from recce.tasks.profile import ProfileDiffTask # noqa: E402
13
+ from recce.tasks.query import QueryDiffTask, QueryTask # noqa: E402
14
+ from recce.tasks.rowcount import RowCountDiffTask # noqa: E402
15
+
16
+
17
+ @pytest.fixture
18
+ def mcp_server():
19
+ """Fixture to create a RecceMCPServer instance for testing"""
20
+ mock_context = MagicMock(spec=RecceContext)
21
+ return RecceMCPServer(mock_context), mock_context
22
+
23
+
24
+ class TestRecceMCPServer:
25
+ """Test cases for the RecceMCPServer class"""
26
+
27
+ def test_server_initialization(self, mcp_server):
28
+ """Test that the MCP server initializes correctly"""
29
+ server, mock_context = mcp_server
30
+ assert server.context == mock_context
31
+ assert server.server is not None
32
+ assert server.server.name == "recce"
33
+
34
+ @pytest.mark.asyncio
35
+ async def test_tool_lineage_diff(self, mcp_server):
36
+ """Test the lineage_diff tool"""
37
+ server, mock_context = mcp_server
38
+ # Mock the lineage diff response
39
+ mock_lineage_diff = MagicMock(spec=LineageDiff)
40
+ mock_lineage_diff.model_dump.return_value = {
41
+ "base": {
42
+ "nodes": {
43
+ "model.project.model_a": {
44
+ "name": "model_a",
45
+ "resource_type": "model",
46
+ "config": {"materialized": "view"},
47
+ },
48
+ "model.project.model_b": {
49
+ "name": "model_b",
50
+ "resource_type": "model",
51
+ },
52
+ },
53
+ "parent_map": {
54
+ "model.project.model_a": [],
55
+ "model.project.model_b": ["model.project.model_a"],
56
+ },
57
+ },
58
+ "current": {
59
+ "nodes": {
60
+ "model.project.model_a": {
61
+ "name": "model_a",
62
+ "resource_type": "model",
63
+ "config": {"materialized": "view"},
64
+ },
65
+ "model.project.model_b": {
66
+ "name": "model_b",
67
+ "resource_type": "model",
68
+ },
69
+ },
70
+ "parent_map": {
71
+ "model.project.model_a": [],
72
+ "model.project.model_b": ["model.project.model_a"],
73
+ },
74
+ },
75
+ "diff": {
76
+ "model.project.model_a": {"change_status": "modified"},
77
+ },
78
+ }
79
+ mock_context.get_lineage_diff.return_value = mock_lineage_diff
80
+ mock_context.adapter.select_nodes.return_value = {
81
+ "model.project.model_a",
82
+ "model.project.model_b",
83
+ }
84
+
85
+ # Execute the method
86
+ result = await server._tool_lineage_diff({})
87
+
88
+ # Verify the result structure
89
+ assert "nodes" in result
90
+ assert "parent_map" in result
91
+
92
+ # Verify nodes is a DataFrame dict with columns and data
93
+ nodes = result["nodes"]
94
+ assert "columns" in nodes
95
+ assert "data" in nodes
96
+
97
+ # Verify data is a list with 2 rows
98
+ assert isinstance(nodes["data"], list)
99
+ assert len(nodes["data"]) == 2
100
+
101
+ mock_context.get_lineage_diff.assert_called_once()
102
+ mock_context.adapter.select_nodes.assert_called()
103
+
104
+ @pytest.mark.asyncio
105
+ async def test_tool_schema_diff(self, mcp_server):
106
+ """Test the schema_diff tool"""
107
+ server, mock_context = mcp_server
108
+ # Mock the lineage diff response with schema information
109
+ mock_lineage_diff = MagicMock(spec=LineageDiff)
110
+ mock_lineage_diff.model_dump.return_value = {
111
+ "base": {
112
+ "nodes": {
113
+ "model.project.model_a": {
114
+ "name": "model_a",
115
+ "resource_type": "model",
116
+ "columns": {
117
+ "id": {"name": "id", "type": "integer"},
118
+ "name": {"name": "name", "type": "text"},
119
+ },
120
+ },
121
+ },
122
+ },
123
+ "current": {
124
+ "nodes": {
125
+ "model.project.model_a": {
126
+ "name": "model_a",
127
+ "resource_type": "model",
128
+ "columns": {
129
+ "id": {"name": "id", "type": "integer"},
130
+ "name": {"name": "name", "type": "text"},
131
+ "age": {"name": "age", "type": "integer"},
132
+ },
133
+ },
134
+ },
135
+ },
136
+ }
137
+ mock_context.get_lineage_diff.return_value = mock_lineage_diff
138
+ mock_context.adapter.select_nodes.return_value = {"model.project.model_a"}
139
+
140
+ # Execute the method
141
+ result = await server._tool_schema_diff({})
142
+
143
+ # Verify the result is a DataFrame dict with columns and data
144
+ assert "columns" in result
145
+ assert "data" in result
146
+ assert "limit" in result
147
+ assert "more" in result
148
+
149
+ # Verify limit and more fields
150
+ assert result["limit"] == 100
151
+ assert isinstance(result["more"], bool)
152
+ assert isinstance(result["data"], list)
153
+ # Verify the data contains the added column
154
+ assert len(result["data"]) > 0
155
+
156
+ mock_context.get_lineage_diff.assert_called_once()
157
+
158
+ @pytest.mark.asyncio
159
+ async def test_tool_row_count_diff(self, mcp_server):
160
+ """Test the row_count_diff tool"""
161
+ server, _ = mcp_server
162
+ # Mock the task execution
163
+ mock_result = {"results": [{"node_id": "model.project.my_model", "base": 100, "current": 105, "diff": 5}]}
164
+
165
+ with patch.object(RowCountDiffTask, "execute", return_value=mock_result):
166
+ result = await server._tool_row_count_diff({"node_names": ["my_model"]})
167
+
168
+ # Verify the result
169
+ assert result == mock_result
170
+ assert "results" in result
171
+
172
+ @pytest.mark.asyncio
173
+ async def test_tool_query(self, mcp_server):
174
+ """Test the query tool"""
175
+ server, _ = mcp_server
176
+ # Mock the task execution
177
+ mock_result = MagicMock()
178
+ mock_result.model_dump.return_value = {
179
+ "columns": ["id", "name"],
180
+ "data": [[1, "Alice"], [2, "Bob"]],
181
+ }
182
+
183
+ with patch.object(QueryTask, "execute", return_value=mock_result):
184
+ result = await server._tool_query({"sql_template": "SELECT * FROM {{ ref('my_model') }}", "base": False})
185
+
186
+ # Verify the result
187
+ assert "columns" in result
188
+ assert "data" in result
189
+ mock_result.model_dump.assert_called_once_with(mode="json")
190
+
191
+ @pytest.mark.asyncio
192
+ async def test_tool_query_with_base_flag(self, mcp_server):
193
+ """Test the query tool with base environment flag"""
194
+ server, _ = mcp_server
195
+ mock_result = {"columns": ["id"], "data": [[1]]}
196
+
197
+ with patch.object(QueryTask, "execute", return_value=mock_result) as mock_execute:
198
+ with patch.object(QueryTask, "__init__", return_value=None):
199
+ task = QueryTask(params={"sql_template": "SELECT 1"})
200
+ task.is_base = True
201
+ task.execute = mock_execute
202
+
203
+ result = await server._tool_query({"sql_template": "SELECT 1", "base": True})
204
+
205
+ # Verify base flag was set (would need to inspect task creation)
206
+ assert result == mock_result
207
+
208
+ @pytest.mark.asyncio
209
+ async def test_tool_query_diff(self, mcp_server):
210
+ """Test the query_diff tool"""
211
+ server, _ = mcp_server
212
+ # Mock the task execution
213
+ mock_result = MagicMock()
214
+ mock_result.model_dump.return_value = {
215
+ "diff": {
216
+ "added": [[3, "Charlie"]],
217
+ "removed": [[1, "Alice"]],
218
+ "modified": [],
219
+ }
220
+ }
221
+
222
+ with patch.object(QueryDiffTask, "execute", return_value=mock_result):
223
+ result = await server._tool_query_diff(
224
+ {
225
+ "sql_template": "SELECT * FROM {{ ref('my_model') }}",
226
+ "primary_keys": ["id"],
227
+ }
228
+ )
229
+
230
+ # Verify the result
231
+ assert "diff" in result
232
+ mock_result.model_dump.assert_called_once_with(mode="json")
233
+
234
+ @pytest.mark.asyncio
235
+ async def test_tool_profile_diff(self, mcp_server):
236
+ """Test the profile_diff tool"""
237
+ server, _ = mcp_server
238
+ # Mock the task execution
239
+ mock_result = MagicMock()
240
+ mock_result.model_dump.return_value = {
241
+ "columns": {
242
+ "id": {
243
+ "base": {"min": 1, "max": 100, "avg": 50.5},
244
+ "current": {"min": 1, "max": 105, "avg": 53.0},
245
+ }
246
+ }
247
+ }
248
+
249
+ with patch.object(ProfileDiffTask, "execute", return_value=mock_result):
250
+ result = await server._tool_profile_diff({"model": "my_model", "columns": ["id"]})
251
+
252
+ # Verify the result
253
+ assert "columns" in result
254
+ mock_result.model_dump.assert_called_once_with(mode="json")
255
+
256
+ @pytest.mark.asyncio
257
+ async def test_error_handling(self, mcp_server):
258
+ """Test error handling in tool execution"""
259
+ server, mock_context = mcp_server
260
+ # Make get_lineage_diff raise an exception
261
+ mock_context.get_lineage_diff.side_effect = Exception("Test error")
262
+
263
+ # The method should raise the exception
264
+ with pytest.raises(Exception, match="Test error"):
265
+ await server._tool_lineage_diff({})
266
+
267
+
268
+ class TestRunMCPServer:
269
+ """Test cases for the run_mcp_server function"""
270
+
271
+ @pytest.mark.asyncio
272
+ @patch("recce.mcp_server.load_context")
273
+ @patch.object(RecceMCPServer, "run")
274
+ async def test_run_mcp_server(self, mock_run, mock_load_context):
275
+ """Test the run_mcp_server entry point"""
276
+ # Mock the context
277
+ mock_context = MagicMock(spec=RecceContext)
278
+ mock_load_context.return_value = mock_context
279
+
280
+ # Mock the server run method
281
+ mock_run.return_value = None
282
+
283
+ # Run the server
284
+ await run_mcp_server(project_dir="/test/path")
285
+
286
+ # Verify context was loaded with correct kwargs
287
+ mock_load_context.assert_called_once_with(project_dir="/test/path")
288
+
289
+ # Verify server was run
290
+ mock_run.assert_called_once()
291
+
292
+ @pytest.mark.asyncio
293
+ @patch("recce.mcp_server.load_context")
294
+ async def test_run_mcp_server_context_error(self, mock_load_context):
295
+ """Test run_mcp_server handles context loading errors"""
296
+ # Make load_context raise an exception
297
+ mock_load_context.side_effect = FileNotFoundError("manifest.json not found")
298
+
299
+ # The function should raise the exception
300
+ with pytest.raises(FileNotFoundError):
301
+ await run_mcp_server()
302
+
303
+
304
+ def test_mcp_cli_command_exists():
305
+ """Test that the mcp-server CLI command is registered"""
306
+ from recce.cli import cli
307
+
308
+ # Check that mcp_server is in the CLI commands
309
+ commands = [cmd.name for cmd in cli.commands.values()]
310
+ assert "mcp_server" in commands or "mcp-server" in commands
311
+
312
+
313
+ class TestMCPServerModes:
314
+ """Test cases for MCP server mode functionality"""
315
+
316
+ def test_server_mode_default(self):
317
+ """Test that server mode is the default when not specified"""
318
+ mock_context = MagicMock(spec=RecceContext)
319
+ server = RecceMCPServer(mock_context)
320
+
321
+ # Default mode should be server
322
+ assert server.mode == RecceServerMode.server
323
+
324
+ def test_non_server_mode_restricts_tools(self):
325
+ """Test that non-server mode (preview, read-only) restricts diff tools"""
326
+ mock_context = MagicMock(spec=RecceContext)
327
+ server = RecceMCPServer(mock_context, mode=RecceServerMode.preview)
328
+
329
+ # Verify mode is set correctly
330
+ assert server.mode == RecceServerMode.preview
331
+ # Verify it's not server mode
332
+ assert server.mode != RecceServerMode.server
@@ -4,7 +4,7 @@ from unittest.mock import patch
4
4
  import pytest
5
5
  import requests
6
6
 
7
- from recce.pull_request import fetch_pr_metadata_from_event_path, _fetch_pr_title
7
+ from recce.pull_request import _fetch_pr_title, fetch_pr_metadata_from_event_path
8
8
 
9
9
 
10
10
  @pytest.fixture