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_cli.py CHANGED
@@ -1,20 +1,24 @@
1
1
  from unittest import TestCase
2
- from unittest.mock import patch, MagicMock
2
+ from unittest.mock import MagicMock, patch
3
3
 
4
4
  from click.testing import CliRunner
5
5
 
6
- from recce.cli import server as cli_command_server, run as cli_command_run
6
+ from recce.cli import run as cli_command_run
7
+ from recce.cli import server as cli_command_server
8
+ from recce.cli import snapshot as cli_command_snapshot
9
+ from recce.cli import upload_session as cli_command_upload_session
7
10
  from recce.core import RecceContext
8
- from recce.state import RecceStateLoader
11
+ from recce.state import CloudStateLoader
9
12
 
10
13
 
11
14
  def test_cmd_version():
12
- from recce.cli import version
13
15
  from recce import __version__
16
+ from recce.cli import version
17
+
14
18
  runner = CliRunner()
15
19
  result = runner.invoke(version, [])
16
20
  assert result.exit_code == 0
17
- assert result.output.replace('\n', '') == __version__
21
+ assert result.output.replace("\n", "") == __version__
18
22
 
19
23
 
20
24
  class TestCommandServer(TestCase):
@@ -22,90 +26,175 @@ class TestCommandServer(TestCase):
22
26
  self.runner = CliRunner()
23
27
  pass
24
28
 
25
- @patch.object(RecceContext, 'verify_required_artifacts')
26
- @patch('recce.cli.uvicorn.run')
29
+ @patch.object(RecceContext, "verify_required_artifacts")
30
+ @patch("recce.cli.uvicorn.run")
27
31
  def test_cmd_server(self, mock_run, mock_verify_required_artifacts):
28
32
  from recce.server import app
33
+
29
34
  mock_verify_required_artifacts.return_value = True, None
30
- self.runner.invoke(cli_command_server, ['--host', 'unittest', '--port', 5566])
31
- mock_run.assert_called_once_with(app, host='unittest', port=5566, lifespan='on')
35
+ self.runner.invoke(cli_command_server, ["--host", "unittest", "--port", 5566, "--single-env"])
36
+ mock_run.assert_called_once_with(app, host="unittest", port=5566, lifespan="on")
32
37
 
33
- @patch('recce.cli.uvicorn.run')
38
+ @patch("recce.cli.uvicorn.run")
34
39
  def test_cmd_server_with_cloud_without_password(self, mock_run):
35
40
  # Should fail if no password is provided
36
- result = self.runner.invoke(cli_command_server, ['--cloud'])
41
+ result = self.runner.invoke(cli_command_server, ["--cloud"])
37
42
  assert result.exit_code == 1
38
43
 
39
- @patch('recce.cli.uvicorn.run')
44
+ @patch("recce.cli.uvicorn.run")
40
45
  def test_cmd_server_with_cloud_without_token(self, mock_run):
41
46
  # Should fail if no token is provided
42
- result = self.runner.invoke(cli_command_server, ['--cloud', '--password', 'unittest'])
47
+ result = self.runner.invoke(cli_command_server, ["--cloud", "--password", "unittest"])
43
48
  assert result.exit_code == 1
44
49
 
45
- @patch.object(RecceContext, 'verify_required_artifacts')
46
- @patch('recce.util.recce_cloud.get_recce_cloud_onboarding_state')
47
- @patch('recce.cli.uvicorn.run')
48
- @patch('recce.cli.RecceStateLoader')
49
- def test_cmd_server_with_cloud(self, mock_state_loader_class, mock_run, mock_get_recce_cloud_onboarding_state,
50
- mock_verify_required_artifacts):
51
- mock_state_loader = MagicMock(spec=RecceStateLoader)
50
+ @patch.object(RecceContext, "verify_required_artifacts")
51
+ @patch("recce.util.recce_cloud.get_recce_cloud_onboarding_state")
52
+ @patch("recce.cli.uvicorn.run")
53
+ @patch("recce.cli.CloudStateLoader")
54
+ def test_cmd_server_with_cloud(
55
+ self, mock_state_loader_class, mock_run, mock_get_recce_cloud_onboarding_state, mock_verify_required_artifacts
56
+ ):
57
+ mock_state_loader = MagicMock(spec=CloudStateLoader)
58
+ mock_state_loader.verify.return_value = True
59
+ mock_state_loader.review_mode = True
60
+ mock_get_recce_cloud_onboarding_state.return_value = "completed"
61
+ mock_verify_required_artifacts.return_value = True, None
62
+
63
+ mock_state_loader_class.return_value = mock_state_loader
64
+ self.runner.invoke(
65
+ cli_command_server, ["--cloud", "--password", "unittest", "--cloud-token", "unittest", "--single-env"]
66
+ )
67
+ mock_state_loader_class.assert_called_once()
68
+ mock_run.assert_called_once()
69
+
70
+ @patch.object(RecceContext, "verify_required_artifacts")
71
+ @patch("recce.util.recce_cloud.get_recce_cloud_onboarding_state")
72
+ @patch("recce.cli.uvicorn.run")
73
+ @patch("recce.cli.CloudStateLoader")
74
+ @patch("recce.cli.prepare_api_token", return_value="test_api_token")
75
+ def test_cmd_server_with_session_id(
76
+ self,
77
+ mock_prepare_api_token,
78
+ mock_state_loader_class,
79
+ mock_run,
80
+ mock_get_recce_cloud_onboarding_state,
81
+ mock_verify_required_artifacts,
82
+ ):
83
+ """Test that --session-id automatically enables cloud and review mode"""
84
+ mock_state_loader = MagicMock(spec=CloudStateLoader)
85
+ mock_state_loader.verify.return_value = True
86
+ mock_state_loader.review_mode = True
87
+ mock_get_recce_cloud_onboarding_state.return_value = "completed"
88
+ mock_verify_required_artifacts.return_value = True, None
89
+
90
+ mock_state_loader_class.return_value = mock_state_loader
91
+
92
+ # Test with --session-id (should automatically enable cloud and review)
93
+ result = self.runner.invoke(cli_command_server, ["--session-id", "test-session-123", "--single-env"])
94
+
95
+ # Should succeed
96
+ assert result.exit_code == 0
97
+
98
+ # Should create CloudStateLoader with session_id in cloud_options
99
+ mock_state_loader_class.assert_called_once()
100
+ call_args = mock_state_loader_class.call_args
101
+ assert call_args.kwargs["review_mode"] is True
102
+ assert "session_id" in call_args.kwargs["cloud_options"]
103
+ assert call_args.kwargs["cloud_options"]["session_id"] == "test-session-123"
104
+
105
+ mock_run.assert_called_once()
106
+
107
+ @patch.object(RecceContext, "verify_required_artifacts")
108
+ @patch("recce.util.recce_cloud.get_recce_cloud_onboarding_state")
109
+ @patch("recce.cli.uvicorn.run")
110
+ @patch("recce.cli.CloudStateLoader")
111
+ @patch("recce.cli.prepare_api_token", return_value="test_api_token")
112
+ def test_cmd_server_with_share_url(
113
+ self,
114
+ mock_prepare_api_token,
115
+ mock_state_loader_class,
116
+ mock_run,
117
+ mock_get_recce_cloud_onboarding_state,
118
+ mock_verify_required_artifacts,
119
+ ):
120
+ """Test that --share-url automatically enables cloud and review mode"""
121
+ mock_state_loader = MagicMock(spec=CloudStateLoader)
52
122
  mock_state_loader.verify.return_value = True
53
123
  mock_state_loader.review_mode = True
54
- mock_get_recce_cloud_onboarding_state.return_value = 'completed'
124
+ mock_get_recce_cloud_onboarding_state.return_value = "completed"
55
125
  mock_verify_required_artifacts.return_value = True, None
56
126
 
57
127
  mock_state_loader_class.return_value = mock_state_loader
58
- self.runner.invoke(cli_command_server, ['--cloud', '--password', 'unittest', '--cloud-token', 'unittest'])
128
+
129
+ # Test with --share-url (should automatically enable cloud and review)
130
+ result = self.runner.invoke(
131
+ cli_command_server, ["--share-url", "https://cloud.recce.io/share/abc123", "--single-env"]
132
+ )
133
+
134
+ # Should succeed
135
+ assert result.exit_code == 0
136
+
137
+ # Should create CloudStateLoader with share_id in cloud_options
59
138
  mock_state_loader_class.assert_called_once()
139
+ call_args = mock_state_loader_class.call_args
140
+ assert call_args.kwargs["review_mode"] is True
141
+ assert "share_id" in call_args.kwargs["cloud_options"]
142
+ assert call_args.kwargs["cloud_options"]["share_id"] == "abc123"
143
+
60
144
  mock_run.assert_called_once()
61
145
 
62
- @patch.object(RecceContext, 'verify_required_artifacts')
63
- @patch('os.path.isdir', side_effect=lambda path: True if path == 'existed_folder' else False)
64
- @patch('recce.cli.uvicorn.run')
65
- @patch('recce.server.AppState')
66
- def test_cmd_server_with_single_env(self,
67
- mock_app_state, mock_run, mock_isdir, mock_verify_required_artifacts):
146
+ @patch.object(RecceContext, "verify_required_artifacts")
147
+ @patch("os.path.isdir", side_effect=lambda path: True if path == "existed_folder" else False)
148
+ @patch("recce.cli.uvicorn.run")
149
+ @patch("recce.server.AppState")
150
+ def test_cmd_server_with_single_env(self, mock_app_state, mock_run, mock_isdir, mock_verify_required_artifacts):
68
151
  mock_verify_required_artifacts.return_value = True, None
69
- self.runner.invoke(cli_command_server,
70
- [
71
- '--target-path', 'existed_folder',
72
- '--target-base-path', 'non_existed_folder',
73
- ])
152
+ self.runner.invoke(
153
+ cli_command_server,
154
+ ["--single-env", "--target-path", "existed_folder", "--target-base-path", "non_existed_folder"],
155
+ )
74
156
  mock_run.assert_called_once()
75
157
 
76
158
  # Onboarding mode should be set to True
77
159
  app_state_call_args = mock_app_state.call_args
78
- app_state_flag = app_state_call_args.kwargs['flag']
79
- assert 'single_env_onboarding' in app_state_flag
80
- assert app_state_flag['single_env_onboarding'] is True
81
- assert 'show_relaunch_hint' in app_state_flag
82
- assert app_state_flag['show_relaunch_hint'] is True
160
+ app_state_flag = app_state_call_args.kwargs["flag"]
161
+ assert "single_env_onboarding" in app_state_flag
162
+ assert app_state_flag["single_env_onboarding"] is True
163
+ assert "show_relaunch_hint" in app_state_flag
164
+ assert app_state_flag["show_relaunch_hint"] is True
83
165
 
84
166
  # The target_base_path should be set to the same as target_path
85
167
  verify_required_artifacts_args = mock_verify_required_artifacts.call_args
86
- assert verify_required_artifacts_args.kwargs['target_path'] == verify_required_artifacts_args.kwargs[
87
- 'target_base_path']
88
-
89
- @patch.object(RecceContext, 'verify_required_artifacts')
90
- @patch('os.path.isdir', side_effect=lambda path: True if path == 'existed_folder' else False)
91
- @patch('recce.cli.uvicorn.run')
92
- @patch('recce.server.AppState')
93
- def test_cmd_server_with_single_env_but_review_mode_enabled(self,
94
- mock_app_state, mock_run, mock_isdir,
95
- mock_verify_required_artifacts):
168
+ assert (
169
+ verify_required_artifacts_args.kwargs["target_path"]
170
+ == verify_required_artifacts_args.kwargs["target_base_path"]
171
+ )
172
+
173
+ @patch.object(RecceContext, "verify_required_artifacts")
174
+ @patch("os.path.isdir", side_effect=lambda path: True if path == "existed_folder" else False)
175
+ @patch("recce.cli.uvicorn.run")
176
+ @patch("recce.server.AppState")
177
+ def test_cmd_server_with_single_env_but_review_mode_enabled(
178
+ self, mock_app_state, mock_run, mock_isdir, mock_verify_required_artifacts
179
+ ):
96
180
  mock_verify_required_artifacts.return_value = True, None
97
- self.runner.invoke(cli_command_server,
98
- [
99
- 'existed_state_file',
100
- '--review',
101
- '--target-path', 'existed_folder',
102
- '--target-base-path', 'non_existed_folder',
103
- ])
181
+ self.runner.invoke(
182
+ cli_command_server,
183
+ [
184
+ "existed_state_file",
185
+ "--review",
186
+ "--single-env",
187
+ "--target-path",
188
+ "existed_folder",
189
+ "--target-base-path",
190
+ "non_existed_folder",
191
+ ],
192
+ )
104
193
  mock_run.assert_called_once()
105
194
  app_state_call_args = mock_app_state.call_args
106
- app_state_flag = app_state_call_args.kwargs['flag']
107
- assert 'single_env_onboarding' in app_state_flag
108
- assert app_state_flag['single_env_onboarding'] is False
195
+ app_state_flag = app_state_call_args.kwargs["flag"]
196
+ assert "single_env_onboarding" in app_state_flag
197
+ assert app_state_flag["single_env_onboarding"] is False
109
198
 
110
199
 
111
200
  class TestCommandRun(TestCase):
@@ -113,10 +202,35 @@ class TestCommandRun(TestCase):
113
202
  self.runner = CliRunner()
114
203
  pass
115
204
 
116
- @patch.object(RecceContext, 'verify_required_artifacts')
117
- @patch('recce.cli.cli_run')
205
+ @patch.object(RecceContext, "verify_required_artifacts")
206
+ @patch("recce.cli.cli_run")
118
207
  def test_cmd_run(self, mock_cli_run, mock_verify_required_artifacts):
119
208
  mock_verify_required_artifacts.return_value = True, None
120
209
 
121
210
  self.runner.invoke(cli_command_run, [])
122
211
  mock_cli_run.assert_called_once()
212
+
213
+
214
+ class TestCommandUploadSession(TestCase):
215
+ def setUp(self):
216
+ self.runner = CliRunner()
217
+ pass
218
+
219
+ @patch("recce.cli.prepare_api_token", return_value="unittest_token")
220
+ @patch("recce.cli.upload_artifacts_to_session", return_value=0)
221
+ def test_cmd_upload_session(self, mock_upload_artifacts_to_session, mock_prepare_api_token):
222
+ self.runner.invoke(
223
+ cli_command_upload_session,
224
+ ["--session-id", "unittest_session", "--api-token", mock_prepare_api_token.return_value],
225
+ )
226
+ mock_upload_artifacts_to_session.assert_called_once_with(
227
+ "target", session_id="unittest_session", token="unittest_token", debug=False
228
+ )
229
+
230
+ self.runner.invoke(
231
+ cli_command_snapshot,
232
+ ["--snapshot-id", "unittest_session", "--api-token", mock_prepare_api_token.return_value],
233
+ )
234
+ mock_upload_artifacts_to_session.assert_called_once_with(
235
+ "target", session_id="unittest_session", token="unittest_token", debug=False
236
+ )
@@ -0,0 +1,45 @@
1
+ """
2
+ Test that CLI can be imported even when mcp is not available.
3
+ """
4
+
5
+ import sys
6
+ from unittest.mock import patch
7
+
8
+
9
+ def test_cli_can_be_imported_without_mcp():
10
+ """Test that recce.cli can be imported even if mcp package is not available"""
11
+ # This test verifies that the CLI module doesn't fail to import
12
+ # when mcp is not installed, since mcp is an optional dependency
13
+ from recce import cli
14
+
15
+ assert cli is not None
16
+ assert hasattr(cli, "cli")
17
+ assert hasattr(cli, "mcp_server")
18
+
19
+
20
+ def test_mcp_server_command_fails_gracefully_without_mcp():
21
+ """Test that mcp-server command shows helpful error when mcp is not available"""
22
+ # Mock sys.modules to simulate mcp not being installed
23
+ with patch.dict(sys.modules, {"mcp": None, "mcp.server": None, "mcp.server.stdio": None, "mcp.types": None}):
24
+ # Remove mcp_server from modules to force reimport
25
+ if "recce.mcp_server" in sys.modules:
26
+ del sys.modules["recce.mcp_server"]
27
+
28
+ from recce.cli import mcp_server
29
+
30
+ # The function should exist
31
+ assert mcp_server is not None
32
+
33
+ # When called, it should handle ImportError gracefully
34
+ # (We can't easily test the actual execution without more mocking,
35
+ # but we've verified the function exists and can be imported)
36
+
37
+
38
+ def test_cli_command_exists():
39
+ """Test that both server and mcp-server commands are registered"""
40
+ from recce.cli import cli
41
+
42
+ # Check that both commands exist
43
+ commands = {cmd.name for cmd in cli.commands.values()}
44
+ assert "server" in commands
45
+ assert "mcp_server" in commands or "mcp-server" in commands
@@ -0,0 +1,324 @@
1
+ import unittest
2
+ from unittest.mock import Mock, patch
3
+
4
+ from click.testing import CliRunner
5
+
6
+ from recce.cli import list_organizations, list_projects, list_sessions
7
+ from recce.exceptions import RecceConfigException
8
+ from recce.util.recce_cloud import RecceCloudException
9
+
10
+
11
+ class TestCloudListingCLI(unittest.TestCase):
12
+ """Test cases for the cloud listing CLI commands."""
13
+
14
+ def setUp(self):
15
+ """Set up test fixtures."""
16
+ self.runner = CliRunner()
17
+
18
+ @patch("recce.cli.prepare_api_token")
19
+ @patch("recce.util.recce_cloud.RecceCloud")
20
+ def test_list_organizations_success(self, mock_recce_cloud, mock_prepare_token):
21
+ """Test successful list-organizations command."""
22
+ # Setup mocks
23
+ mock_prepare_token.return_value = "test-token"
24
+ mock_cloud_instance = Mock()
25
+ mock_cloud_instance.list_organizations.return_value = [
26
+ {"id": 1, "name": "org1", "display_name": "Organization 1"},
27
+ {"id": 2, "name": "org2", "display_name": "Organization 2"},
28
+ ]
29
+ mock_recce_cloud.return_value = mock_cloud_instance
30
+
31
+ # Test command
32
+ result = self.runner.invoke(list_organizations, [])
33
+
34
+ # Assertions
35
+ self.assertEqual(result.exit_code, 0)
36
+ self.assertIn("Organizations", result.output)
37
+ self.assertIn("org1", result.output)
38
+ self.assertIn("Organization 1", result.output)
39
+ self.assertIn("org2", result.output)
40
+ self.assertIn("Organization 2", result.output)
41
+ mock_cloud_instance.list_organizations.assert_called_once()
42
+
43
+ @patch("recce.cli.prepare_api_token")
44
+ @patch("recce.util.recce_cloud.RecceCloud")
45
+ def test_list_organizations_empty(self, mock_recce_cloud, mock_prepare_token):
46
+ """Test list-organizations command with no organizations."""
47
+ # Setup mocks
48
+ mock_prepare_token.return_value = "test-token"
49
+ mock_cloud_instance = Mock()
50
+ mock_cloud_instance.list_organizations.return_value = []
51
+ mock_recce_cloud.return_value = mock_cloud_instance
52
+
53
+ # Test command
54
+ result = self.runner.invoke(list_organizations, [])
55
+
56
+ # Assertions
57
+ self.assertEqual(result.exit_code, 0)
58
+ self.assertIn("No organizations found", result.output)
59
+
60
+ @patch("recce.cli.prepare_api_token")
61
+ def test_list_organizations_invalid_token(self, mock_prepare_token):
62
+ """Test list-organizations command with invalid token."""
63
+ # Setup mock to raise exception
64
+ mock_prepare_token.side_effect = RecceConfigException("Invalid token")
65
+
66
+ # Test command
67
+ result = self.runner.invoke(list_organizations, [])
68
+
69
+ # Assertions
70
+ self.assertEqual(result.exit_code, 1)
71
+
72
+ @patch("recce.cli.prepare_api_token")
73
+ @patch("recce.util.recce_cloud.RecceCloud")
74
+ def test_list_projects_with_cli_arg(self, mock_recce_cloud, mock_prepare_token):
75
+ """Test list-projects command with CLI argument."""
76
+ # Setup mocks
77
+ mock_prepare_token.return_value = "test-token"
78
+ mock_cloud_instance = Mock()
79
+ mock_cloud_instance.list_projects.return_value = [
80
+ {"id": 1, "name": "project1", "display_name": "Project 1"},
81
+ {"id": 2, "name": "project2", "display_name": "Project 2"},
82
+ ]
83
+ mock_recce_cloud.return_value = mock_cloud_instance
84
+
85
+ # Test command with CLI argument
86
+ result = self.runner.invoke(list_projects, ["--organization", "8"])
87
+
88
+ # Assertions
89
+ self.assertEqual(result.exit_code, 0)
90
+ self.assertIn("Projects in Organization 8", result.output)
91
+ self.assertIn("project1", result.output)
92
+ self.assertIn("Project 1", result.output)
93
+ mock_cloud_instance.list_projects.assert_called_once_with("8")
94
+
95
+ @patch("recce.cli.prepare_api_token")
96
+ @patch("recce.util.recce_cloud.RecceCloud")
97
+ def test_list_projects_with_env_var(self, mock_recce_cloud, mock_prepare_token):
98
+ """Test list-projects command with environment variable."""
99
+ # Setup mocks
100
+ mock_prepare_token.return_value = "test-token"
101
+ mock_cloud_instance = Mock()
102
+ mock_cloud_instance.list_projects.return_value = [{"id": 1, "name": "project1", "display_name": "Project 1"}]
103
+ mock_recce_cloud.return_value = mock_cloud_instance
104
+
105
+ # Test command with environment variable
106
+ result = self.runner.invoke(list_projects, [], env={"RECCE_ORGANIZATION_ID": "8"})
107
+
108
+ # Assertions
109
+ self.assertEqual(result.exit_code, 0)
110
+ self.assertIn("Projects in Organization 8", result.output)
111
+ self.assertIn("project1", result.output)
112
+ mock_cloud_instance.list_projects.assert_called_once_with("8")
113
+
114
+ @patch("recce.cli.prepare_api_token")
115
+ @patch("recce.util.recce_cloud.RecceCloud")
116
+ def test_list_projects_cli_overrides_env(self, mock_recce_cloud, mock_prepare_token):
117
+ """Test list-projects command where CLI argument overrides environment variable."""
118
+ # Setup mocks
119
+ mock_prepare_token.return_value = "test-token"
120
+ mock_cloud_instance = Mock()
121
+ mock_cloud_instance.list_projects.return_value = [{"id": 1, "name": "project1", "display_name": "Project 1"}]
122
+ mock_recce_cloud.return_value = mock_cloud_instance
123
+
124
+ # Test command with both env var and CLI arg (CLI should win)
125
+ result = self.runner.invoke(list_projects, ["--organization", "10"], env={"RECCE_ORGANIZATION_ID": "8"})
126
+
127
+ # Assertions
128
+ self.assertEqual(result.exit_code, 0)
129
+ self.assertIn("Projects in Organization 10", result.output)
130
+ # Verify CLI argument (10) was used, not env var (8)
131
+ mock_cloud_instance.list_projects.assert_called_once_with("10")
132
+
133
+ @patch("recce.cli.prepare_api_token")
134
+ def test_list_projects_missing_organization(self, mock_prepare_token):
135
+ """Test list-projects command with missing organization ID."""
136
+ # Setup mocks
137
+ mock_prepare_token.return_value = "test-token"
138
+
139
+ # Test command without organization ID
140
+ result = self.runner.invoke(list_projects, [])
141
+
142
+ # Assertions
143
+ self.assertEqual(result.exit_code, 1)
144
+ self.assertIn("Organization ID is required", result.output)
145
+ self.assertIn("--organization", result.output)
146
+ self.assertIn("RECCE_ORGANIZATION_ID", result.output)
147
+
148
+ @patch("recce.cli.prepare_api_token")
149
+ @patch("recce.util.recce_cloud.RecceCloud")
150
+ def test_list_projects_empty(self, mock_recce_cloud, mock_prepare_token):
151
+ """Test list-projects command with no projects."""
152
+ # Setup mocks
153
+ mock_prepare_token.return_value = "test-token"
154
+ mock_cloud_instance = Mock()
155
+ mock_cloud_instance.list_projects.return_value = []
156
+ mock_recce_cloud.return_value = mock_cloud_instance
157
+
158
+ # Test command
159
+ result = self.runner.invoke(list_projects, ["--organization", "8"])
160
+
161
+ # Assertions
162
+ self.assertEqual(result.exit_code, 0)
163
+ self.assertIn("No projects found in organization 8", result.output)
164
+
165
+ @patch("recce.cli.prepare_api_token")
166
+ @patch("recce.util.recce_cloud.RecceCloud")
167
+ def test_list_sessions_with_cli_args(self, mock_recce_cloud, mock_prepare_token):
168
+ """Test list-sessions command with CLI arguments."""
169
+ # Setup mocks
170
+ mock_prepare_token.return_value = "test-token"
171
+ mock_cloud_instance = Mock()
172
+ mock_cloud_instance.list_sessions.return_value = [
173
+ {"id": "session1", "name": "PR-123", "is_base": False},
174
+ {"id": "session2", "name": "Base Session", "is_base": True},
175
+ ]
176
+ mock_recce_cloud.return_value = mock_cloud_instance
177
+
178
+ # Test command with CLI arguments
179
+ result = self.runner.invoke(list_sessions, ["--organization", "8", "--project", "7"])
180
+
181
+ # Assertions
182
+ self.assertEqual(result.exit_code, 0)
183
+ self.assertIn("Sessions in Project 7", result.output)
184
+ self.assertIn("PR-123", result.output)
185
+ self.assertIn("Base Session", result.output)
186
+ self.assertIn("✓", result.output) # Base session marker
187
+ mock_cloud_instance.list_sessions.assert_called_once_with("8", "7")
188
+
189
+ @patch("recce.cli.prepare_api_token")
190
+ @patch("recce.util.recce_cloud.RecceCloud")
191
+ def test_list_sessions_with_env_vars(self, mock_recce_cloud, mock_prepare_token):
192
+ """Test list-sessions command with environment variables."""
193
+ # Setup mocks
194
+ mock_prepare_token.return_value = "test-token"
195
+ mock_cloud_instance = Mock()
196
+ mock_cloud_instance.list_sessions.return_value = [{"id": "session1", "name": "Session 1", "is_base": False}]
197
+ mock_recce_cloud.return_value = mock_cloud_instance
198
+
199
+ # Test command with environment variables
200
+ result = self.runner.invoke(list_sessions, [], env={"RECCE_ORGANIZATION_ID": "8", "RECCE_PROJECT_ID": "7"})
201
+
202
+ # Assertions
203
+ self.assertEqual(result.exit_code, 0)
204
+ self.assertIn("Sessions in Project 7", result.output)
205
+ self.assertIn("Session 1", result.output)
206
+ mock_cloud_instance.list_sessions.assert_called_once_with("8", "7")
207
+
208
+ @patch("recce.cli.prepare_api_token")
209
+ @patch("recce.util.recce_cloud.RecceCloud")
210
+ def test_list_sessions_mixed_env_and_cli(self, mock_recce_cloud, mock_prepare_token):
211
+ """Test list-sessions command with mixed environment variables and CLI args."""
212
+ # Setup mocks
213
+ mock_prepare_token.return_value = "test-token"
214
+ mock_cloud_instance = Mock()
215
+ mock_cloud_instance.list_sessions.return_value = [{"id": "session1", "name": "Session 1", "is_base": False}]
216
+ mock_recce_cloud.return_value = mock_cloud_instance
217
+
218
+ # Test command with env var for org and CLI arg for project
219
+ result = self.runner.invoke(list_sessions, ["--project", "9"], env={"RECCE_ORGANIZATION_ID": "8"})
220
+
221
+ # Assertions
222
+ self.assertEqual(result.exit_code, 0)
223
+ self.assertIn("Sessions in Project 9", result.output)
224
+ # Verify it used env var for org (8) and CLI arg for project (9)
225
+ mock_cloud_instance.list_sessions.assert_called_once_with("8", "9")
226
+
227
+ @patch("recce.cli.prepare_api_token")
228
+ def test_list_sessions_missing_organization(self, mock_prepare_token):
229
+ """Test list-sessions command with missing organization ID."""
230
+ # Setup mocks
231
+ mock_prepare_token.return_value = "test-token"
232
+
233
+ # Test command without organization ID
234
+ result = self.runner.invoke(list_sessions, ["--project", "7"])
235
+
236
+ # Assertions
237
+ self.assertEqual(result.exit_code, 1)
238
+ self.assertIn("Organization ID is required", result.output)
239
+ self.assertIn("--organization", result.output)
240
+ self.assertIn("RECCE_ORGANIZATION_ID", result.output)
241
+
242
+ @patch("recce.cli.prepare_api_token")
243
+ def test_list_sessions_missing_project(self, mock_prepare_token):
244
+ """Test list-sessions command with missing project ID."""
245
+ # Setup mocks
246
+ mock_prepare_token.return_value = "test-token"
247
+
248
+ # Test command without project ID
249
+ result = self.runner.invoke(list_sessions, ["--organization", "8"])
250
+
251
+ # Assertions
252
+ self.assertEqual(result.exit_code, 1)
253
+ self.assertIn("Project ID is required", result.output)
254
+ self.assertIn("--project", result.output)
255
+ self.assertIn("RECCE_PROJECT_ID", result.output)
256
+
257
+ @patch("recce.cli.prepare_api_token")
258
+ @patch("recce.util.recce_cloud.RecceCloud")
259
+ def test_list_sessions_empty(self, mock_recce_cloud, mock_prepare_token):
260
+ """Test list-sessions command with no sessions."""
261
+ # Setup mocks
262
+ mock_prepare_token.return_value = "test-token"
263
+ mock_cloud_instance = Mock()
264
+ mock_cloud_instance.list_sessions.return_value = []
265
+ mock_recce_cloud.return_value = mock_cloud_instance
266
+
267
+ # Test command
268
+ result = self.runner.invoke(list_sessions, ["--organization", "8", "--project", "7"])
269
+
270
+ # Assertions
271
+ self.assertEqual(result.exit_code, 0)
272
+ self.assertIn("No sessions found in project 7", result.output)
273
+
274
+ @patch("recce.cli.prepare_api_token")
275
+ @patch("recce.util.recce_cloud.RecceCloud")
276
+ def test_list_sessions_api_error(self, mock_recce_cloud, mock_prepare_token):
277
+ """Test list-sessions command with API error."""
278
+ # Setup mocks
279
+ mock_prepare_token.return_value = "test-token"
280
+ mock_cloud_instance = Mock()
281
+ mock_cloud_instance.list_sessions.side_effect = RecceCloudException("Access denied", "Forbidden", 403)
282
+ mock_recce_cloud.return_value = mock_cloud_instance
283
+
284
+ # Test command
285
+ result = self.runner.invoke(list_sessions, ["--organization", "8", "--project", "7"])
286
+
287
+ # Assertions
288
+ self.assertEqual(result.exit_code, 1)
289
+ self.assertIn("Error", result.output)
290
+
291
+ @patch("recce.cli.prepare_api_token")
292
+ @patch("recce.util.recce_cloud.RecceCloud")
293
+ def test_sessions_base_session_display(self, mock_recce_cloud, mock_prepare_token):
294
+ """Test that base sessions are properly marked with checkmark."""
295
+ # Setup mocks
296
+ mock_prepare_token.return_value = "test-token"
297
+ mock_cloud_instance = Mock()
298
+ mock_cloud_instance.list_sessions.return_value = [
299
+ {"id": "session1", "name": "Regular Session", "is_base": False},
300
+ {"id": "session2", "name": "Base Session", "is_base": True},
301
+ {"id": "session3", "name": "Another Regular", "is_base": False},
302
+ ]
303
+ mock_recce_cloud.return_value = mock_cloud_instance
304
+
305
+ # Test command
306
+ result = self.runner.invoke(list_sessions, ["--organization", "8", "--project", "7"])
307
+
308
+ # Assertions
309
+ self.assertEqual(result.exit_code, 0)
310
+ output_lines = result.output.split("\n")
311
+
312
+ # Find the base session line and verify it has the checkmark
313
+ base_session_line = None
314
+ for line in output_lines:
315
+ if "Base Session" in line:
316
+ base_session_line = line
317
+ break
318
+
319
+ self.assertIsNotNone(base_session_line)
320
+ self.assertIn("✓", base_session_line)
321
+
322
+
323
+ if __name__ == "__main__":
324
+ unittest.main()