recce-nightly 1.10.0.20250629__py3-none-any.whl → 1.25.0.20251112a2066__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/6LypcDXgyuSaiSCrsmUub/_buildManifest.js +11 -0
  15. recce/data/_next/static/6LypcDXgyuSaiSCrsmUub/_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/71f88fcc615bf282.js +1 -0
  21. recce/data/_next/static/chunks/917619ab62a32388.js +1 -0
  22. recce/data/_next/static/chunks/93ba5a62932b704f.js +4 -0
  23. recce/data/_next/static/chunks/a43a2a5e06d5a92b.js +1 -0
  24. recce/data/_next/static/chunks/a6c78b24bd8b84fc.js +1 -0
  25. recce/data/_next/static/chunks/b2610ba997ff8c4f.js +110 -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.20251112a2066.dist-info}/METADATA +31 -27
  100. recce_nightly-1.25.0.20251112a2066.dist-info/RECORD +178 -0
  101. {recce_nightly-1.10.0.20250629.dist-info → recce_nightly-1.25.0.20251112a2066.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 → 6LypcDXgyuSaiSCrsmUub}/_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.20251112a2066.dist-info}/WHEEL +0 -0
  167. {recce_nightly-1.10.0.20250629.dist-info → recce_nightly-1.25.0.20251112a2066.dist-info}/entry_points.txt +0 -0
  168. {recce_nightly-1.10.0.20250629.dist-info → recce_nightly-1.25.0.20251112a2066.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,372 @@
1
+ """
2
+ Integration tests for recce-cloud CLI commands.
3
+ """
4
+
5
+ import os
6
+ import tempfile
7
+ import unittest
8
+ from pathlib import Path
9
+ from unittest.mock import patch
10
+
11
+ from click.testing import CliRunner
12
+
13
+ from recce_cloud.cli import cloud_cli
14
+
15
+
16
+ class TestUploadDryRun(unittest.TestCase):
17
+ """Test cases for the --dry-run flag in upload command."""
18
+
19
+ def setUp(self):
20
+ """Set up test fixtures."""
21
+ self.runner = CliRunner()
22
+ self.temp_dir = tempfile.mkdtemp()
23
+
24
+ # Create mock dbt artifacts
25
+ manifest_path = Path(self.temp_dir) / "manifest.json"
26
+ catalog_path = Path(self.temp_dir) / "catalog.json"
27
+
28
+ manifest_content = {
29
+ "metadata": {"adapter_type": "postgres"},
30
+ "nodes": {},
31
+ }
32
+
33
+ catalog_content = {
34
+ "nodes": {},
35
+ }
36
+
37
+ import json
38
+
39
+ with open(manifest_path, "w") as f:
40
+ json.dump(manifest_content, f)
41
+
42
+ with open(catalog_path, "w") as f:
43
+ json.dump(catalog_content, f)
44
+
45
+ def tearDown(self):
46
+ """Clean up test fixtures."""
47
+ import shutil
48
+
49
+ if os.path.exists(self.temp_dir):
50
+ shutil.rmtree(self.temp_dir)
51
+
52
+ def test_dry_run_github_actions_pr_context(self):
53
+ """Test dry-run with GitHub Actions PR context."""
54
+ env = {
55
+ "GITHUB_ACTIONS": "true",
56
+ "GITHUB_REPOSITORY": "DataRecce/recce",
57
+ "GITHUB_EVENT_NAME": "pull_request",
58
+ "GITHUB_SHA": "abc123def456",
59
+ "GITHUB_HEAD_REF": "feature/test-branch",
60
+ "GITHUB_BASE_REF": "main",
61
+ "RECCE_API_TOKEN": "test_token_123",
62
+ }
63
+
64
+ # Create mock event file
65
+ event_file = Path(self.temp_dir) / "github_event.json"
66
+ import json
67
+
68
+ with open(event_file, "w") as f:
69
+ json.dump({"pull_request": {"number": 42}}, f)
70
+
71
+ env["GITHUB_EVENT_PATH"] = str(event_file)
72
+
73
+ with patch.dict(os.environ, env, clear=True):
74
+ result = self.runner.invoke(
75
+ cloud_cli,
76
+ ["upload", "--target-path", self.temp_dir, "--dry-run"],
77
+ )
78
+
79
+ # Assertions
80
+ self.assertEqual(result.exit_code, 0, f"Command failed: {result.output}")
81
+ self.assertIn("Dry run mode enabled", result.output)
82
+ self.assertIn("Platform Information:", result.output)
83
+ self.assertIn("Platform: github-actions", result.output)
84
+ self.assertIn("Repository: DataRecce/recce", result.output)
85
+ self.assertIn("CR Number: 42", result.output)
86
+ self.assertIn("Commit SHA: abc123de", result.output)
87
+ self.assertIn("Source Branch: feature/test-branch", result.output)
88
+ self.assertIn("Base Branch: main", result.output)
89
+ self.assertIn("Upload Workflow:", result.output)
90
+ self.assertIn("Auto-create session and upload", result.output)
91
+ self.assertIn("Platform-specific APIs will be used", result.output)
92
+ self.assertIn("Files to upload:", result.output)
93
+ self.assertIn("manifest.json:", result.output)
94
+ self.assertIn("catalog.json:", result.output)
95
+ self.assertIn("Adapter type: postgres", result.output)
96
+ self.assertIn("Dry run completed successfully", result.output)
97
+
98
+ def test_dry_run_gitlab_ci_mr_context(self):
99
+ """Test dry-run with GitLab CI MR context."""
100
+ env = {
101
+ "GITLAB_CI": "true",
102
+ "CI_PROJECT_PATH": "recce/jaffle-shop",
103
+ "CI_PROJECT_URL": "https://gitlab.com/recce/jaffle-shop",
104
+ "CI_MERGE_REQUEST_IID": "5",
105
+ "CI_MERGE_REQUEST_SOURCE_BRANCH_NAME": "feature/new-models",
106
+ "CI_MERGE_REQUEST_TARGET_BRANCH_NAME": "main",
107
+ "CI_COMMIT_SHA": "def456abc789",
108
+ "CI_SERVER_URL": "https://gitlab.com",
109
+ "RECCE_API_TOKEN": "test_token_abc",
110
+ }
111
+
112
+ with patch.dict(os.environ, env, clear=True):
113
+ result = self.runner.invoke(
114
+ cloud_cli,
115
+ ["upload", "--target-path", self.temp_dir, "--dry-run"],
116
+ )
117
+
118
+ # Assertions
119
+ self.assertEqual(result.exit_code, 0, f"Command failed: {result.output}")
120
+ self.assertIn("Dry run mode enabled", result.output)
121
+ self.assertIn("Platform Information:", result.output)
122
+ self.assertIn("Platform: gitlab-ci", result.output)
123
+ self.assertIn("Repository: recce/jaffle-shop", result.output)
124
+ self.assertIn("CR Number: 5", result.output)
125
+ self.assertIn("Commit SHA: def456ab", result.output)
126
+ self.assertIn("Source Branch: feature/new-models", result.output)
127
+ self.assertIn("Base Branch: main", result.output)
128
+ self.assertIn("Auto-create session and upload", result.output)
129
+ self.assertIn("Platform-specific APIs will be used", result.output)
130
+ self.assertIn("Adapter type: postgres", result.output)
131
+
132
+ def test_dry_run_gitlab_ci_self_hosted(self):
133
+ """Test dry-run with self-hosted GitLab instance."""
134
+ env = {
135
+ "GITLAB_CI": "true",
136
+ "CI_PROJECT_PATH": "data-team/dbt-project",
137
+ "CI_PROJECT_URL": "https://gitlab.mycompany.com/data-team/dbt-project",
138
+ "CI_MERGE_REQUEST_IID": "25",
139
+ "CI_MERGE_REQUEST_SOURCE_BRANCH_NAME": "develop",
140
+ "CI_MERGE_REQUEST_TARGET_BRANCH_NAME": "production",
141
+ "CI_COMMIT_SHA": "fedcba987654",
142
+ "CI_SERVER_URL": "https://gitlab.mycompany.com",
143
+ "RECCE_API_TOKEN": "test_token_xyz",
144
+ }
145
+
146
+ with patch.dict(os.environ, env, clear=True):
147
+ result = self.runner.invoke(
148
+ cloud_cli,
149
+ ["upload", "--target-path", self.temp_dir, "--dry-run"],
150
+ )
151
+
152
+ # Assertions
153
+ self.assertEqual(result.exit_code, 0, f"Command failed: {result.output}")
154
+ self.assertIn("Platform: gitlab-ci", result.output)
155
+ self.assertIn("Repository: data-team/dbt-project", result.output)
156
+ self.assertIn("CR Number: 25", result.output)
157
+ self.assertIn("Source Branch: develop", result.output)
158
+ self.assertIn("Base Branch: production", result.output)
159
+
160
+ def test_dry_run_with_session_id(self):
161
+ """Test dry-run with existing session ID (generic workflow)."""
162
+ env = {
163
+ "GITHUB_ACTIONS": "true",
164
+ "GITHUB_REPOSITORY": "DataRecce/recce",
165
+ "RECCE_API_TOKEN": "test_token_789",
166
+ }
167
+
168
+ with patch.dict(os.environ, env, clear=True):
169
+ result = self.runner.invoke(
170
+ cloud_cli,
171
+ [
172
+ "upload",
173
+ "--target-path",
174
+ self.temp_dir,
175
+ "--session-id",
176
+ "sess_abc123xyz",
177
+ "--dry-run",
178
+ ],
179
+ )
180
+
181
+ # Assertions
182
+ self.assertEqual(result.exit_code, 0, f"Command failed: {result.output}")
183
+ self.assertIn("Dry run mode enabled", result.output)
184
+ self.assertIn("Upload Workflow:", result.output)
185
+ self.assertIn("Upload to existing session", result.output)
186
+ self.assertIn("Session ID: sess_abc123xyz", result.output)
187
+ self.assertNotIn("Auto-create session", result.output)
188
+
189
+ def test_dry_run_github_main_branch(self):
190
+ """Test dry-run with GitHub Actions main branch (no PR)."""
191
+ env = {
192
+ "GITHUB_ACTIONS": "true",
193
+ "GITHUB_REPOSITORY": "DataRecce/recce",
194
+ "GITHUB_EVENT_NAME": "push",
195
+ "GITHUB_REF": "refs/heads/main",
196
+ "GITHUB_SHA": "xyz789abc123",
197
+ "RECCE_API_TOKEN": "test_token_456",
198
+ }
199
+
200
+ with patch.dict(os.environ, env, clear=True):
201
+ result = self.runner.invoke(
202
+ cloud_cli,
203
+ ["upload", "--target-path", self.temp_dir, "--dry-run"],
204
+ )
205
+
206
+ # Assertions
207
+ self.assertEqual(result.exit_code, 0, f"Command failed: {result.output}")
208
+ self.assertIn("Platform: github-actions", result.output)
209
+ self.assertIn("Repository: DataRecce/recce", result.output)
210
+ self.assertIn("Commit SHA: xyz789ab", result.output)
211
+ # Session type depends on git branch detection, could be prod or dev
212
+ self.assertIn("Session Type:", result.output)
213
+ # Should not have CR number
214
+ self.assertNotIn("CR Number:", result.output)
215
+
216
+ def test_dry_run_gitlab_main_branch(self):
217
+ """Test dry-run with GitLab CI main branch (no MR)."""
218
+ env = {
219
+ "GITLAB_CI": "true",
220
+ "CI_PROJECT_PATH": "recce/analytics",
221
+ "CI_PROJECT_URL": "https://gitlab.com/recce/analytics",
222
+ "CI_COMMIT_BRANCH": "main",
223
+ "CI_COMMIT_SHA": "123abc456def",
224
+ "CI_SERVER_URL": "https://gitlab.com",
225
+ "RECCE_API_TOKEN": "test_token_main",
226
+ }
227
+
228
+ with patch.dict(os.environ, env, clear=True):
229
+ result = self.runner.invoke(
230
+ cloud_cli,
231
+ ["upload", "--target-path", self.temp_dir, "--dry-run"],
232
+ )
233
+
234
+ # Assertions
235
+ self.assertEqual(result.exit_code, 0, f"Command failed: {result.output}")
236
+ self.assertIn("Platform: gitlab-ci", result.output)
237
+ self.assertIn("Repository: recce/analytics", result.output)
238
+ # Session type depends on git branch detection, could be prod or dev
239
+ self.assertIn("Session Type:", result.output)
240
+ # Should not have CR number
241
+ self.assertNotIn("CR Number:", result.output)
242
+
243
+ def test_dry_run_with_manual_overrides(self):
244
+ """Test dry-run with manual overrides."""
245
+ env = {
246
+ "GITHUB_ACTIONS": "true",
247
+ "GITHUB_REPOSITORY": "DataRecce/recce",
248
+ "GITHUB_EVENT_NAME": "pull_request",
249
+ "GITHUB_SHA": "abc123",
250
+ "RECCE_API_TOKEN": "test_token",
251
+ }
252
+
253
+ # Create mock event file with PR number 42
254
+ event_file = Path(self.temp_dir) / "github_event.json"
255
+ import json
256
+
257
+ with open(event_file, "w") as f:
258
+ json.dump({"pull_request": {"number": 42}}, f)
259
+
260
+ env["GITHUB_EVENT_PATH"] = str(event_file)
261
+
262
+ with patch.dict(os.environ, env, clear=True):
263
+ result = self.runner.invoke(
264
+ cloud_cli,
265
+ [
266
+ "upload",
267
+ "--target-path",
268
+ self.temp_dir,
269
+ "--cr",
270
+ "100",
271
+ "--type",
272
+ "cr",
273
+ "--dry-run",
274
+ ],
275
+ )
276
+
277
+ # Assertions
278
+ self.assertEqual(result.exit_code, 0, f"Command failed: {result.output}")
279
+ # Should show overridden CR number
280
+ self.assertIn("CR Number: 100", result.output)
281
+ self.assertIn("Session Type: cr", result.output)
282
+
283
+ def test_dry_run_no_ci_environment(self):
284
+ """Test dry-run without CI environment (local development)."""
285
+ env = {
286
+ "RECCE_API_TOKEN": "test_token_local",
287
+ }
288
+
289
+ with patch.dict(os.environ, env, clear=True):
290
+ result = self.runner.invoke(
291
+ cloud_cli,
292
+ [
293
+ "upload",
294
+ "--target-path",
295
+ self.temp_dir,
296
+ "--session-id",
297
+ "sess_local123",
298
+ "--dry-run",
299
+ ],
300
+ )
301
+
302
+ # Assertions
303
+ self.assertEqual(result.exit_code, 0, f"Command failed: {result.output}")
304
+ self.assertIn("Dry run mode enabled", result.output)
305
+ self.assertIn("Upload to existing session", result.output)
306
+ self.assertIn("Session ID: sess_local123", result.output)
307
+ # Should not show platform information
308
+ self.assertNotIn("Platform Information:", result.output)
309
+
310
+ def test_dry_run_unsupported_platform_without_session_id(self):
311
+ """Test dry-run with unsupported platform and no session ID."""
312
+ env = {
313
+ "RECCE_API_TOKEN": "test_token",
314
+ }
315
+
316
+ with patch.dict(os.environ, env, clear=True):
317
+ result = self.runner.invoke(
318
+ cloud_cli,
319
+ ["upload", "--target-path", self.temp_dir, "--dry-run"],
320
+ )
321
+
322
+ # Assertions
323
+ self.assertEqual(result.exit_code, 0, f"Command failed: {result.output}")
324
+ self.assertIn("Dry run mode enabled", result.output)
325
+ self.assertIn("Auto-create session and upload", result.output)
326
+ self.assertIn("Warning: Platform not supported for auto-session creation", result.output)
327
+
328
+ def test_dry_run_missing_artifacts(self):
329
+ """Test dry-run with missing dbt artifacts."""
330
+ import shutil
331
+
332
+ shutil.rmtree(self.temp_dir)
333
+
334
+ env = {
335
+ "GITHUB_ACTIONS": "true",
336
+ "RECCE_API_TOKEN": "test_token",
337
+ }
338
+
339
+ with patch.dict(os.environ, env, clear=True):
340
+ result = self.runner.invoke(
341
+ cloud_cli,
342
+ ["upload", "--target-path", self.temp_dir, "--dry-run"],
343
+ )
344
+
345
+ # Assertions
346
+ # Should fail before dry-run validation happens
347
+ self.assertNotEqual(result.exit_code, 0)
348
+ self.assertIn("does not exist", result.output)
349
+
350
+ def test_dry_run_custom_target_path(self):
351
+ """Test dry-run with custom target path."""
352
+ env = {
353
+ "GITHUB_ACTIONS": "true",
354
+ "GITHUB_REPOSITORY": "DataRecce/recce",
355
+ "RECCE_API_TOKEN": "test_token",
356
+ }
357
+
358
+ with patch.dict(os.environ, env, clear=True):
359
+ result = self.runner.invoke(
360
+ cloud_cli,
361
+ ["upload", "--target-path", self.temp_dir, "--dry-run"],
362
+ )
363
+
364
+ # Assertions
365
+ self.assertEqual(result.exit_code, 0, f"Command failed: {result.output}")
366
+ self.assertIn(self.temp_dir, result.output)
367
+ self.assertIn("manifest.json:", result.output)
368
+ self.assertIn("catalog.json:", result.output)
369
+
370
+
371
+ if __name__ == "__main__":
372
+ unittest.main()
@@ -0,0 +1,273 @@
1
+ import json
2
+ import unittest
3
+ from unittest.mock import MagicMock, patch
4
+
5
+ from recce_cloud.api.client import RecceCloudClient, RecceCloudException
6
+
7
+
8
+ class RecceCloudClientTests(unittest.TestCase):
9
+ def setUp(self):
10
+ """Set up test fixtures."""
11
+ self.api_token = "rct-test-token-123"
12
+ self.session_id = "session-123"
13
+ self.org_id = "org-456"
14
+ self.project_id = "project-789"
15
+
16
+ def test_init_with_api_token(self):
17
+ """Test client initialization with Recce API token."""
18
+ client = RecceCloudClient(self.api_token)
19
+ self.assertEqual(client.token, self.api_token)
20
+ self.assertIn("/api/v2", client.base_url_v2)
21
+
22
+ def test_init_with_none_token_raises_error(self):
23
+ """Test client initialization with None token raises ValueError."""
24
+ with self.assertRaises(ValueError) as context:
25
+ RecceCloudClient(None)
26
+ self.assertIn("Token cannot be None", str(context.exception))
27
+
28
+ @patch("recce_cloud.api.client.requests.request")
29
+ def test_get_session_success(self, mock_request):
30
+ """Test successful get_session call."""
31
+ client = RecceCloudClient(self.api_token)
32
+
33
+ # Mock successful response
34
+ mock_response = MagicMock()
35
+ mock_response.status_code = 200
36
+ mock_response.json.return_value = {
37
+ "success": True,
38
+ "session": {
39
+ "id": self.session_id,
40
+ "org_id": self.org_id,
41
+ "project_id": self.project_id,
42
+ },
43
+ }
44
+ mock_request.return_value = mock_response
45
+
46
+ result = client.get_session(self.session_id)
47
+
48
+ self.assertEqual(result["id"], self.session_id)
49
+ self.assertEqual(result["org_id"], self.org_id)
50
+ self.assertEqual(result["project_id"], self.project_id)
51
+
52
+ # Verify request was made correctly
53
+ mock_request.assert_called_once()
54
+ call_args = mock_request.call_args
55
+ self.assertEqual(call_args[0][0], "GET")
56
+ self.assertIn(self.session_id, call_args[0][1])
57
+ self.assertEqual(call_args[1]["headers"]["Authorization"], f"Bearer {self.api_token}")
58
+
59
+ @patch("recce_cloud.api.client.requests.request")
60
+ def test_get_session_not_found(self, mock_request):
61
+ """Test get_session with 404 response."""
62
+ client = RecceCloudClient(self.api_token)
63
+
64
+ # Mock 404 response
65
+ mock_response = MagicMock()
66
+ mock_response.status_code = 404
67
+ mock_response.text = "Session not found"
68
+ mock_request.return_value = mock_response
69
+
70
+ with self.assertRaises(RecceCloudException) as context:
71
+ client.get_session(self.session_id)
72
+
73
+ self.assertEqual(context.exception.status_code, 404)
74
+ self.assertIn("Session not found", str(context.exception))
75
+
76
+ @patch("recce_cloud.api.client.requests.request")
77
+ def test_get_session_forbidden(self, mock_request):
78
+ """Test get_session with 403 response."""
79
+ client = RecceCloudClient(self.api_token)
80
+
81
+ # Mock 403 response
82
+ mock_response = MagicMock()
83
+ mock_response.status_code = 403
84
+ mock_response.json.return_value = {"detail": "Access denied"}
85
+ mock_request.return_value = mock_response
86
+
87
+ result = client.get_session(self.session_id)
88
+
89
+ self.assertEqual(result["status"], "error")
90
+ self.assertEqual(result["message"], "Access denied")
91
+
92
+ @patch("recce_cloud.api.client.requests.request")
93
+ def test_get_session_api_error(self, mock_request):
94
+ """Test get_session with API returning success=False."""
95
+ client = RecceCloudClient(self.api_token)
96
+
97
+ # Mock response with success=False
98
+ mock_response = MagicMock()
99
+ mock_response.status_code = 200
100
+ mock_response.json.return_value = {"success": False, "message": "Invalid session"}
101
+ mock_request.return_value = mock_response
102
+
103
+ with self.assertRaises(RecceCloudException) as context:
104
+ client.get_session(self.session_id)
105
+
106
+ self.assertIn("Invalid session", context.exception.reason)
107
+
108
+ @patch("recce_cloud.api.client.requests.request")
109
+ def test_get_upload_urls_success(self, mock_request):
110
+ """Test successful get_upload_urls_by_session_id call."""
111
+ client = RecceCloudClient(self.api_token)
112
+
113
+ # Mock successful response
114
+ mock_response = MagicMock()
115
+ mock_response.status_code = 200
116
+ mock_response.json.return_value = {
117
+ "presigned_urls": {
118
+ "manifest_url": "https://s3.amazonaws.com/bucket/manifest.json?token=abc",
119
+ "catalog_url": "https://s3.amazonaws.com/bucket/catalog.json?token=def",
120
+ }
121
+ }
122
+ mock_request.return_value = mock_response
123
+
124
+ result = client.get_upload_urls_by_session_id(self.org_id, self.project_id, self.session_id)
125
+
126
+ self.assertIn("manifest_url", result)
127
+ self.assertIn("catalog_url", result)
128
+ self.assertIn("s3.amazonaws.com", result["manifest_url"])
129
+
130
+ # Verify request was made correctly
131
+ mock_request.assert_called_once()
132
+ call_args = mock_request.call_args
133
+ self.assertEqual(call_args[0][0], "GET")
134
+ self.assertIn(self.org_id, call_args[0][1])
135
+ self.assertIn(self.project_id, call_args[0][1])
136
+ self.assertIn(self.session_id, call_args[0][1])
137
+ self.assertIn("upload-url", call_args[0][1])
138
+
139
+ @patch("recce_cloud.api.client.requests.request")
140
+ def test_get_upload_urls_no_presigned_urls(self, mock_request):
141
+ """Test get_upload_urls_by_session_id with no presigned URLs."""
142
+ client = RecceCloudClient(self.api_token)
143
+
144
+ # Mock response with null presigned_urls
145
+ mock_response = MagicMock()
146
+ mock_response.status_code = 200
147
+ mock_response.json.return_value = {"presigned_urls": None}
148
+ mock_request.return_value = mock_response
149
+
150
+ with self.assertRaises(RecceCloudException) as context:
151
+ client.get_upload_urls_by_session_id(self.org_id, self.project_id, self.session_id)
152
+
153
+ self.assertEqual(context.exception.status_code, 404)
154
+ self.assertIn("No presigned URLs", str(context.exception))
155
+
156
+ @patch("recce_cloud.api.client.requests.request")
157
+ def test_get_upload_urls_failure(self, mock_request):
158
+ """Test get_upload_urls_by_session_id with API failure."""
159
+ client = RecceCloudClient(self.api_token)
160
+
161
+ # Mock error response
162
+ mock_response = MagicMock()
163
+ mock_response.status_code = 500
164
+ mock_response.text = "Internal server error"
165
+ mock_request.return_value = mock_response
166
+
167
+ with self.assertRaises(RecceCloudException) as context:
168
+ client.get_upload_urls_by_session_id(self.org_id, self.project_id, self.session_id)
169
+
170
+ self.assertEqual(context.exception.status_code, 500)
171
+
172
+ @patch("recce_cloud.api.client.requests.request")
173
+ def test_update_session_success(self, mock_request):
174
+ """Test successful update_session call."""
175
+ client = RecceCloudClient(self.api_token)
176
+ adapter_type = "postgres"
177
+
178
+ # Mock successful response
179
+ mock_response = MagicMock()
180
+ mock_response.status_code = 200
181
+ mock_response.json.return_value = {"success": True, "session": {"adapter_type": adapter_type}}
182
+ mock_request.return_value = mock_response
183
+
184
+ result = client.update_session(self.org_id, self.project_id, self.session_id, adapter_type)
185
+
186
+ self.assertTrue(result["success"])
187
+ self.assertEqual(result["session"]["adapter_type"], adapter_type)
188
+
189
+ # Verify request was made correctly
190
+ mock_request.assert_called_once()
191
+ call_args = mock_request.call_args
192
+ self.assertEqual(call_args[0][0], "PATCH")
193
+ self.assertIn(self.org_id, call_args[0][1])
194
+ self.assertIn(self.project_id, call_args[0][1])
195
+ self.assertIn(self.session_id, call_args[0][1])
196
+ self.assertEqual(call_args[1]["json"]["adapter_type"], adapter_type)
197
+
198
+ @patch("recce_cloud.api.client.requests.request")
199
+ def test_update_session_forbidden(self, mock_request):
200
+ """Test update_session with 403 response."""
201
+ client = RecceCloudClient(self.api_token)
202
+
203
+ # Mock 403 response
204
+ mock_response = MagicMock()
205
+ mock_response.status_code = 403
206
+ mock_response.json.return_value = {"detail": "Insufficient permissions"}
207
+ mock_request.return_value = mock_response
208
+
209
+ result = client.update_session(self.org_id, self.project_id, self.session_id, "postgres")
210
+
211
+ self.assertEqual(result["status"], "error")
212
+ self.assertEqual(result["message"], "Insufficient permissions")
213
+
214
+ @patch("recce_cloud.api.client.requests.request")
215
+ def test_update_session_failure(self, mock_request):
216
+ """Test update_session with API failure."""
217
+ client = RecceCloudClient(self.api_token)
218
+
219
+ # Mock error response
220
+ mock_response = MagicMock()
221
+ mock_response.status_code = 400
222
+ mock_response.text = "Bad request"
223
+ mock_request.return_value = mock_response
224
+
225
+ with self.assertRaises(RecceCloudException) as context:
226
+ client.update_session(self.org_id, self.project_id, self.session_id, "invalid_adapter")
227
+
228
+ self.assertEqual(context.exception.status_code, 400)
229
+
230
+ def test_recce_cloud_exception_with_json_detail(self):
231
+ """Test RecceCloudException parses JSON detail."""
232
+ json_reason = json.dumps({"detail": "Invalid session ID"})
233
+ exception = RecceCloudException(reason=json_reason, status_code=400)
234
+
235
+ self.assertEqual(exception.status_code, 400)
236
+ self.assertEqual(exception.reason, "Invalid session ID")
237
+ self.assertIn("Invalid session ID", str(exception))
238
+
239
+ def test_recce_cloud_exception_with_plain_text(self):
240
+ """Test RecceCloudException with plain text reason."""
241
+ plain_reason = "Connection timeout"
242
+ exception = RecceCloudException(reason=plain_reason, status_code=500)
243
+
244
+ self.assertEqual(exception.status_code, 500)
245
+ self.assertEqual(exception.reason, plain_reason)
246
+
247
+ @patch.dict("os.environ", {"RECCE_INSTANCE_ENV": "docker"})
248
+ @patch("recce_cloud.api.client.requests.request")
249
+ def test_docker_internal_url_replacement(self, mock_request):
250
+ """Test localhost URL is replaced with docker internal URL."""
251
+ client = RecceCloudClient(self.api_token)
252
+
253
+ # Mock response with localhost URL
254
+ mock_response = MagicMock()
255
+ mock_response.status_code = 200
256
+ mock_response.json.return_value = {
257
+ "presigned_urls": {
258
+ "manifest_url": "http://localhost:8000/manifest.json",
259
+ "catalog_url": "http://localhost:8000/catalog.json",
260
+ }
261
+ }
262
+ mock_request.return_value = mock_response
263
+
264
+ result = client.get_upload_urls_by_session_id(self.org_id, self.project_id, self.session_id)
265
+
266
+ # URLs should be replaced with docker internal
267
+ self.assertIn("host.docker.internal", result["manifest_url"])
268
+ self.assertIn("host.docker.internal", result["catalog_url"])
269
+ self.assertNotIn("localhost", result["manifest_url"])
270
+
271
+
272
+ if __name__ == "__main__":
273
+ unittest.main()