recce-nightly 1.10.0.20250629__py3-none-any.whl → 1.25.0.20251112a20664__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.
Files changed (168) hide show
  1. recce/VERSION +1 -1
  2. recce/__init__.py +5 -0
  3. recce/adapter/dbt_adapter/__init__.py +116 -74
  4. recce/artifact.py +76 -3
  5. recce/cli.py +665 -69
  6. recce/config.py +2 -2
  7. recce/connect_to_cloud.py +1 -1
  8. recce/core.py +3 -3
  9. recce/data/404.html +1 -22
  10. recce/data/__next.__PAGE__.txt +10 -0
  11. recce/data/__next._full.txt +23 -0
  12. recce/data/__next._index.txt +8 -0
  13. recce/data/__next._tree.txt +12 -0
  14. recce/data/_next/static/JwV_pqetN5WamZZ7aGdfH/_buildManifest.js +11 -0
  15. recce/data/_next/static/JwV_pqetN5WamZZ7aGdfH/_clientMiddlewareManifest.json +1 -0
  16. recce/data/_next/static/chunks/0a2b2dd4b57049c2.js +1 -0
  17. recce/data/_next/static/chunks/19c10d219a6a21ff.js +1 -0
  18. recce/data/_next/static/chunks/24fd885c7180a612.js +1 -0
  19. recce/data/_next/static/chunks/27e66b2eab4adc32.js +19 -0
  20. recce/data/_next/static/chunks/67b1c6a62f19d429.js +110 -0
  21. recce/data/_next/static/chunks/71f88fcc615bf282.js +1 -0
  22. recce/data/_next/static/chunks/917619ab62a32388.js +1 -0
  23. recce/data/_next/static/chunks/93ba5a62932b704f.js +4 -0
  24. recce/data/_next/static/chunks/a43a2a5e06d5a92b.js +1 -0
  25. recce/data/_next/static/chunks/a6c78b24bd8b84fc.js +1 -0
  26. recce/data/_next/static/chunks/ba2d87265a68599d.css +2 -0
  27. recce/data/_next/static/chunks/c117fd1c1382dd83.js +11 -0
  28. recce/data/_next/static/chunks/c9425ca46eebdde9.js +1 -0
  29. recce/data/_next/static/chunks/cc8a9eadba012be0.css +6 -0
  30. recce/data/_next/static/chunks/e124bccf574a3361.css +1 -0
  31. recce/data/_next/static/chunks/e392ad92847c3e17.js +1 -0
  32. recce/data/_next/static/chunks/e4ce95efe88dae79.js +11 -0
  33. recce/data/_next/static/chunks/e69c777814fea6ed.js +2 -0
  34. recce/data/_next/static/chunks/turbopack-21cfd73037ff57ab.js +3 -0
  35. recce/data/_next/static/media/favicon.a8d38d84.ico +0 -0
  36. recce/data/_next/static/media/montserrat-cyrillic-800-normal.d80d830d.woff2 +0 -0
  37. recce/data/_next/static/media/{montserrat-cyrillic-800-normal.bd5c9f50.woff → montserrat-cyrillic-800-normal.f9d58125.woff} +0 -0
  38. recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.076c2a93.woff2 +0 -0
  39. recce/data/_next/static/media/montserrat-latin-800-normal.cde454cc.woff2 +0 -0
  40. recce/data/_next/static/media/{montserrat-latin-800-normal.fc315020.woff → montserrat-latin-800-normal.d5761935.woff} +0 -0
  41. recce/data/_next/static/media/montserrat-latin-ext-800-normal.40ec0659.woff2 +0 -0
  42. recce/data/_next/static/media/{montserrat-latin-ext-800-normal.2e5381b2.woff → montserrat-latin-ext-800-normal.b671449b.woff} +0 -0
  43. recce/data/_next/static/media/{montserrat-vietnamese-800-normal.20c545e6.woff → montserrat-vietnamese-800-normal.9f7b8541.woff} +0 -0
  44. recce/data/_next/static/media/montserrat-vietnamese-800-normal.f9eb854e.woff2 +0 -0
  45. recce/data/_not-found/__next._full.txt +17 -0
  46. recce/data/_not-found/__next._index.txt +8 -0
  47. recce/data/_not-found/__next._not-found.__PAGE__.txt +5 -0
  48. recce/data/_not-found/__next._not-found.txt +4 -0
  49. recce/data/_not-found/__next._tree.txt +10 -0
  50. recce/data/_not-found.html +1 -0
  51. recce/data/_not-found.txt +17 -0
  52. recce/data/auth_callback.html +1 -1
  53. recce/data/index.html +1 -27
  54. recce/data/index.txt +23 -8
  55. recce/event/__init__.py +9 -8
  56. recce/event/collector.py +6 -2
  57. recce/event/track.py +10 -0
  58. recce/github.py +1 -1
  59. recce/mcp_server.py +632 -0
  60. recce/models/types.py +23 -2
  61. recce/pull_request.py +1 -1
  62. recce/run.py +23 -16
  63. recce/server.py +165 -11
  64. recce/state/__init__.py +31 -0
  65. recce/state/cloud.py +632 -0
  66. recce/state/const.py +26 -0
  67. recce/state/local.py +56 -0
  68. recce/state/state.py +119 -0
  69. recce/state/state_loader.py +174 -0
  70. recce/summary.py +2 -1
  71. recce/tasks/dataframe.py +59 -2
  72. recce/tasks/rowcount.py +4 -1
  73. recce/tasks/schema.py +4 -1
  74. recce/tasks/valuediff.py +1 -1
  75. recce/util/api_token.py +11 -2
  76. recce/util/breaking.py +9 -0
  77. recce/util/cll.py +1 -2
  78. recce/util/io.py +2 -2
  79. recce/util/lineage.py +14 -18
  80. recce/util/perf_tracking.py +85 -0
  81. recce/util/recce_cloud.py +229 -5
  82. recce/yaml/__init__.py +2 -2
  83. recce_cloud/__init__.py +15 -0
  84. recce_cloud/api/__init__.py +17 -0
  85. recce_cloud/api/base.py +104 -0
  86. recce_cloud/api/client.py +150 -0
  87. recce_cloud/api/exceptions.py +26 -0
  88. recce_cloud/api/factory.py +63 -0
  89. recce_cloud/api/github.py +72 -0
  90. recce_cloud/api/gitlab.py +78 -0
  91. recce_cloud/artifact.py +57 -0
  92. recce_cloud/ci_providers/__init__.py +9 -0
  93. recce_cloud/ci_providers/base.py +82 -0
  94. recce_cloud/ci_providers/detector.py +147 -0
  95. recce_cloud/ci_providers/github_actions.py +136 -0
  96. recce_cloud/ci_providers/gitlab_ci.py +130 -0
  97. recce_cloud/cli.py +303 -0
  98. recce_cloud/upload.py +213 -0
  99. {recce_nightly-1.10.0.20250629.dist-info → recce_nightly-1.25.0.20251112a20664.dist-info}/METADATA +31 -27
  100. recce_nightly-1.25.0.20251112a20664.dist-info/RECORD +178 -0
  101. {recce_nightly-1.10.0.20250629.dist-info → recce_nightly-1.25.0.20251112a20664.dist-info}/top_level.txt +1 -0
  102. tests/adapter/dbt_adapter/test_dbt_cll.py +68 -17
  103. tests/recce_cloud/__init__.py +0 -0
  104. tests/recce_cloud/test_ci_providers.py +351 -0
  105. tests/recce_cloud/test_cli.py +372 -0
  106. tests/recce_cloud/test_client.py +273 -0
  107. tests/recce_cloud/test_platform_clients.py +279 -0
  108. tests/test_cli.py +106 -3
  109. tests/test_cli_mcp_optional.py +45 -0
  110. tests/test_cloud_listing_cli.py +324 -0
  111. tests/test_core.py +147 -0
  112. tests/test_mcp_server.py +332 -0
  113. tests/test_server.py +6 -6
  114. tests/test_summary.py +14 -6
  115. recce/data/_next/static/Mrb9CZ3toH6Q8xrzNzCrg/_buildManifest.js +0 -1
  116. recce/data/_next/static/chunks/181-acc61ddada3bc0ca.js +0 -43
  117. recce/data/_next/static/chunks/1bff33f1-1ef85cf5e658a751.js +0 -1
  118. recce/data/_next/static/chunks/217-879a84d70f7a907c.js +0 -2
  119. recce/data/_next/static/chunks/29e3cc0d-60045b2e47aa3916.js +0 -1
  120. recce/data/_next/static/chunks/36e1c10d-8e7be4a6c1f6ab2d.js +0 -1
  121. recce/data/_next/static/chunks/3998a672-03adacad07b346ac.js +0 -1
  122. recce/data/_next/static/chunks/3a92ee20-1081c360214f9602.js +0 -1
  123. recce/data/_next/static/chunks/41-f30276c289169376.js +0 -9
  124. recce/data/_next/static/chunks/450c323b-fd94e7ffaa4a5efa.js +0 -1
  125. recce/data/_next/static/chunks/47d8844f-929aed9b1c73a905.js +0 -1
  126. recce/data/_next/static/chunks/608-3b079b544e5d5f5e.js +0 -15
  127. recce/data/_next/static/chunks/6dc81886-adbfa45836061d79.js +0 -1
  128. recce/data/_next/static/chunks/7a8a3e83-edf6dc64b5d5f0a5.js +0 -1
  129. recce/data/_next/static/chunks/7f27ae6c-d5f0438edd5c2a5b.js +0 -1
  130. recce/data/_next/static/chunks/86730205-cfb14e3f051bab35.js +0 -1
  131. recce/data/_next/static/chunks/8d700b6a.8bb140898499c512.js +0 -1
  132. recce/data/_next/static/chunks/92-68460b15fe448f33.js +0 -1
  133. recce/data/_next/static/chunks/9746af58-a42b7d169cacadf0.js +0 -1
  134. recce/data/_next/static/chunks/a30376cd-de84559016d7e133.js +0 -1
  135. recce/data/_next/static/chunks/app/_not-found/page-01ed58b7f971d311.js +0 -1
  136. recce/data/_next/static/chunks/app/layout-292f035bb0d2a98e.js +0 -1
  137. recce/data/_next/static/chunks/app/page-598f8acc82179d01.js +0 -1
  138. recce/data/_next/static/chunks/b63b1b3f-4282bdcf459e075c.js +0 -1
  139. recce/data/_next/static/chunks/bbda5537-9ec25eb1dd62348a.js +0 -1
  140. recce/data/_next/static/chunks/c132bf7d-08cb668a789d6afd.js +0 -1
  141. recce/data/_next/static/chunks/ce84277d-2e5d1d46910cf052.js +0 -1
  142. recce/data/_next/static/chunks/febdd86e-c6b525341634b860.js +0 -54
  143. recce/data/_next/static/chunks/fee69bc6-2dbccaf9b90474e6.js +0 -1
  144. recce/data/_next/static/chunks/framework-ded83d71b51ce901.js +0 -1
  145. recce/data/_next/static/chunks/main-app-39061b0166c47f55.js +0 -1
  146. recce/data/_next/static/chunks/main-b5b3ae20a1405261.js +0 -1
  147. recce/data/_next/static/chunks/pages/_app-437c455677d62394.js +0 -1
  148. recce/data/_next/static/chunks/pages/_error-e7650df18ca04bde.js +0 -1
  149. recce/data/_next/static/chunks/webpack-7b49d5ba7e3a434d.js +0 -1
  150. recce/data/_next/static/css/17a96168e3a9db13.css +0 -1
  151. recce/data/_next/static/css/35c6679a098e1e34.css +0 -1
  152. recce/data/_next/static/css/951e2e0eea2d4a5b.css +0 -14
  153. recce/data/_next/static/css/a2b12b4ba4227f0a.css +0 -3
  154. recce/data/_next/static/media/montserrat-cyrillic-800-normal.22628180.woff2 +0 -0
  155. recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.94a63aea.woff2 +0 -0
  156. recce/data/_next/static/media/montserrat-latin-800-normal.6f8fa298.woff2 +0 -0
  157. recce/data/_next/static/media/montserrat-latin-ext-800-normal.013b84f9.woff2 +0 -0
  158. recce/data/_next/static/media/montserrat-vietnamese-800-normal.c0035377.woff2 +0 -0
  159. recce/state.py +0 -786
  160. recce_nightly-1.10.0.20250629.dist-info/RECORD +0 -154
  161. tests/test_state.py +0 -134
  162. /recce/data/_next/static/{Mrb9CZ3toH6Q8xrzNzCrg → JwV_pqetN5WamZZ7aGdfH}/_ssgManifest.js +0 -0
  163. /recce/data/_next/static/chunks/{polyfills-42372ed130431b0a.js → a6dad97d9634a72d.js} +0 -0
  164. /recce/data/_next/static/media/{montserrat-cyrillic-ext-800-normal.e6e0d8d0.woff → montserrat-cyrillic-ext-800-normal.a4fa76b5.woff} +0 -0
  165. /recce/data/_next/static/media/{reload-image.79aabb7d.svg → reload-image.7aa931c7.svg} +0 -0
  166. {recce_nightly-1.10.0.20250629.dist-info → recce_nightly-1.25.0.20251112a20664.dist-info}/WHEEL +0 -0
  167. {recce_nightly-1.10.0.20250629.dist-info → recce_nightly-1.25.0.20251112a20664.dist-info}/entry_points.txt +0 -0
  168. {recce_nightly-1.10.0.20250629.dist-info → recce_nightly-1.25.0.20251112a20664.dist-info}/licenses/LICENSE +0 -0
@@ -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()
tests/test_core.py CHANGED
@@ -1,6 +1,153 @@
1
1
  # noinspection PyUnresolvedReferences
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
2
9
  from tests.adapter.dbt_adapter.conftest import dbt_test_helper # noqa: F401
3
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
150
+
4
151
 
5
152
  def test_lineage_diff(dbt_test_helper):
6
153
  sql_model1 = """