shotgun-sh 0.2.17__py3-none-any.whl → 0.3.3.dev1__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 (112) hide show
  1. shotgun/agents/agent_manager.py +28 -14
  2. shotgun/agents/common.py +1 -1
  3. shotgun/agents/config/README.md +89 -0
  4. shotgun/agents/config/__init__.py +10 -1
  5. shotgun/agents/config/manager.py +323 -53
  6. shotgun/agents/config/models.py +85 -21
  7. shotgun/agents/config/provider.py +51 -13
  8. shotgun/agents/config/streaming_test.py +119 -0
  9. shotgun/agents/context_analyzer/analyzer.py +6 -2
  10. shotgun/agents/conversation/__init__.py +18 -0
  11. shotgun/agents/conversation/filters.py +164 -0
  12. shotgun/agents/conversation/history/chunking.py +278 -0
  13. shotgun/agents/{history → conversation/history}/compaction.py +27 -1
  14. shotgun/agents/{history → conversation/history}/constants.py +5 -0
  15. shotgun/agents/conversation/history/file_content_deduplication.py +216 -0
  16. shotgun/agents/{history → conversation/history}/history_processors.py +267 -3
  17. shotgun/agents/{history → conversation/history}/token_counting/anthropic.py +8 -0
  18. shotgun/agents/{conversation_manager.py → conversation/manager.py} +1 -1
  19. shotgun/agents/{conversation_history.py → conversation/models.py} +8 -94
  20. shotgun/agents/error/__init__.py +11 -0
  21. shotgun/agents/error/models.py +19 -0
  22. shotgun/agents/runner.py +230 -0
  23. shotgun/agents/tools/web_search/openai.py +1 -1
  24. shotgun/build_constants.py +2 -2
  25. shotgun/cli/clear.py +1 -1
  26. shotgun/cli/compact.py +5 -3
  27. shotgun/cli/context.py +44 -1
  28. shotgun/cli/error_handler.py +24 -0
  29. shotgun/cli/export.py +34 -34
  30. shotgun/cli/plan.py +34 -34
  31. shotgun/cli/research.py +17 -9
  32. shotgun/cli/spec/__init__.py +5 -0
  33. shotgun/cli/spec/backup.py +81 -0
  34. shotgun/cli/spec/commands.py +132 -0
  35. shotgun/cli/spec/models.py +48 -0
  36. shotgun/cli/spec/pull_service.py +219 -0
  37. shotgun/cli/specify.py +20 -19
  38. shotgun/cli/tasks.py +34 -34
  39. shotgun/codebase/core/ingestor.py +153 -7
  40. shotgun/codebase/models.py +2 -0
  41. shotgun/exceptions.py +325 -0
  42. shotgun/llm_proxy/__init__.py +17 -0
  43. shotgun/llm_proxy/client.py +215 -0
  44. shotgun/llm_proxy/models.py +137 -0
  45. shotgun/logging_config.py +42 -0
  46. shotgun/main.py +4 -0
  47. shotgun/posthog_telemetry.py +1 -1
  48. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +28 -3
  49. shotgun/prompts/agents/partials/interactive_mode.j2 +3 -3
  50. shotgun/prompts/agents/plan.j2 +16 -0
  51. shotgun/prompts/agents/research.j2 +16 -3
  52. shotgun/prompts/agents/specify.j2 +54 -1
  53. shotgun/prompts/agents/state/system_state.j2 +0 -2
  54. shotgun/prompts/agents/tasks.j2 +16 -0
  55. shotgun/prompts/history/chunk_summarization.j2 +34 -0
  56. shotgun/prompts/history/combine_summaries.j2 +53 -0
  57. shotgun/sdk/codebase.py +14 -3
  58. shotgun/settings.py +5 -0
  59. shotgun/shotgun_web/__init__.py +67 -1
  60. shotgun/shotgun_web/client.py +42 -1
  61. shotgun/shotgun_web/constants.py +46 -0
  62. shotgun/shotgun_web/exceptions.py +29 -0
  63. shotgun/shotgun_web/models.py +390 -0
  64. shotgun/shotgun_web/shared_specs/__init__.py +32 -0
  65. shotgun/shotgun_web/shared_specs/file_scanner.py +175 -0
  66. shotgun/shotgun_web/shared_specs/hasher.py +83 -0
  67. shotgun/shotgun_web/shared_specs/models.py +71 -0
  68. shotgun/shotgun_web/shared_specs/upload_pipeline.py +329 -0
  69. shotgun/shotgun_web/shared_specs/utils.py +34 -0
  70. shotgun/shotgun_web/specs_client.py +703 -0
  71. shotgun/shotgun_web/supabase_client.py +31 -0
  72. shotgun/tui/app.py +73 -9
  73. shotgun/tui/containers.py +1 -1
  74. shotgun/tui/layout.py +5 -0
  75. shotgun/tui/screens/chat/chat_screen.py +372 -95
  76. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +196 -17
  77. shotgun/tui/screens/chat_screen/command_providers.py +13 -2
  78. shotgun/tui/screens/chat_screen/hint_message.py +76 -1
  79. shotgun/tui/screens/confirmation_dialog.py +40 -0
  80. shotgun/tui/screens/directory_setup.py +45 -41
  81. shotgun/tui/screens/feedback.py +10 -3
  82. shotgun/tui/screens/github_issue.py +11 -2
  83. shotgun/tui/screens/model_picker.py +28 -8
  84. shotgun/tui/screens/onboarding.py +149 -0
  85. shotgun/tui/screens/pipx_migration.py +58 -6
  86. shotgun/tui/screens/provider_config.py +66 -8
  87. shotgun/tui/screens/shared_specs/__init__.py +21 -0
  88. shotgun/tui/screens/shared_specs/create_spec_dialog.py +273 -0
  89. shotgun/tui/screens/shared_specs/models.py +56 -0
  90. shotgun/tui/screens/shared_specs/share_specs_dialog.py +390 -0
  91. shotgun/tui/screens/shared_specs/upload_progress_screen.py +452 -0
  92. shotgun/tui/screens/shotgun_auth.py +110 -16
  93. shotgun/tui/screens/spec_pull.py +288 -0
  94. shotgun/tui/screens/welcome.py +123 -0
  95. shotgun/tui/services/conversation_service.py +5 -2
  96. shotgun/tui/widgets/widget_coordinator.py +1 -1
  97. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/METADATA +9 -2
  98. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/RECORD +112 -77
  99. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/WHEEL +1 -1
  100. /shotgun/agents/{history → conversation/history}/__init__.py +0 -0
  101. /shotgun/agents/{history → conversation/history}/context_extraction.py +0 -0
  102. /shotgun/agents/{history → conversation/history}/history_building.py +0 -0
  103. /shotgun/agents/{history → conversation/history}/message_utils.py +0 -0
  104. /shotgun/agents/{history → conversation/history}/token_counting/__init__.py +0 -0
  105. /shotgun/agents/{history → conversation/history}/token_counting/base.py +0 -0
  106. /shotgun/agents/{history → conversation/history}/token_counting/openai.py +0 -0
  107. /shotgun/agents/{history → conversation/history}/token_counting/sentencepiece_counter.py +0 -0
  108. /shotgun/agents/{history → conversation/history}/token_counting/tokenizer_cache.py +0 -0
  109. /shotgun/agents/{history → conversation/history}/token_counting/utils.py +0 -0
  110. /shotgun/agents/{history → conversation/history}/token_estimation.py +0 -0
  111. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/entry_points.txt +0 -0
  112. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,703 @@
1
+ """Async HTTP client for Shotgun Specs API."""
2
+
3
+ from collections.abc import AsyncIterator
4
+ from pathlib import Path
5
+ from typing import Literal
6
+
7
+ import aiofiles
8
+ import httpx
9
+ from tenacity import (
10
+ retry,
11
+ retry_if_exception_type,
12
+ stop_after_attempt,
13
+ wait_exponential,
14
+ )
15
+
16
+ from shotgun.agents.config import get_config_manager
17
+ from shotgun.logging_config import get_logger
18
+
19
+ from .constants import (
20
+ FILES_PATH,
21
+ PERMISSIONS_PATH,
22
+ PUBLIC_FILE_PATH,
23
+ PUBLIC_SPEC_FILES_PATH,
24
+ PUBLIC_SPEC_PATH,
25
+ SHOTGUN_WEB_BASE_URL,
26
+ SPECS_BASE_PATH,
27
+ SPECS_DETAIL_PATH,
28
+ VERSION_BY_ID_PATH,
29
+ VERSION_CLOSE_PATH,
30
+ VERSION_SET_LATEST_PATH,
31
+ VERSIONS_PATH,
32
+ WORKSPACES_PATH,
33
+ )
34
+ from .exceptions import (
35
+ ConflictError,
36
+ ForbiddenError,
37
+ NotFoundError,
38
+ PayloadTooLargeError,
39
+ RateLimitExceededError,
40
+ ShotgunWebError,
41
+ UnauthorizedError,
42
+ )
43
+ from .models import (
44
+ FileListResponse,
45
+ FileUploadResponse,
46
+ PermissionCheckResponse,
47
+ PublicSpecResponse,
48
+ SpecCreateRequest,
49
+ SpecCreateResponse,
50
+ SpecFileResponse,
51
+ SpecListResponse,
52
+ SpecResponse,
53
+ SpecUpdateRequest,
54
+ SpecVersionResponse,
55
+ VersionCloseResponse,
56
+ VersionCreateResponse,
57
+ VersionListResponse,
58
+ VersionWithFilesResponse,
59
+ WorkspaceListResponse,
60
+ WorkspaceNotFoundError,
61
+ )
62
+
63
+ logger = get_logger(__name__)
64
+
65
+ # Chunk size for file uploads (1MB)
66
+ UPLOAD_CHUNK_SIZE = 1024 * 1024
67
+
68
+
69
+ class SpecsClient:
70
+ """Async HTTP client for Shotgun Specs API."""
71
+
72
+ def __init__(self, base_url: str | None = None, timeout: float = 30.0):
73
+ """Initialize Specs client.
74
+
75
+ Args:
76
+ base_url: Base URL for Shotgun Web API. If None, uses SHOTGUN_WEB_BASE_URL
77
+ timeout: Request timeout in seconds
78
+ """
79
+ self.base_url = base_url or SHOTGUN_WEB_BASE_URL
80
+ self.timeout = timeout
81
+
82
+ async def _get_auth_token(self) -> str:
83
+ """Get supabase_jwt from ConfigManager.
84
+
85
+ Returns:
86
+ JWT token string
87
+
88
+ Raises:
89
+ UnauthorizedError: If user is not authenticated
90
+ """
91
+ config_manager = get_config_manager()
92
+ config = await config_manager.load()
93
+ jwt = config.shotgun.supabase_jwt
94
+ if jwt is None:
95
+ raise UnauthorizedError("Not authenticated. Run 'shotgun auth' to login.")
96
+ return jwt.get_secret_value()
97
+
98
+ def _raise_for_status(self, response: httpx.Response) -> None:
99
+ """Raise typed exception based on HTTP status code.
100
+
101
+ Args:
102
+ response: HTTP response to check
103
+
104
+ Raises:
105
+ UnauthorizedError: 401 status
106
+ ForbiddenError: 403 status
107
+ NotFoundError: 404 status
108
+ ConflictError: 409 status
109
+ PayloadTooLargeError: 413 status
110
+ RateLimitExceededError: 429 status
111
+ ShotgunWebError: Other 4xx/5xx status codes
112
+ """
113
+ if response.is_success:
114
+ return
115
+
116
+ status = response.status_code
117
+ try:
118
+ error_data = response.json()
119
+ message = error_data.get("message", response.text)
120
+ except Exception:
121
+ message = response.text
122
+
123
+ if status == 401:
124
+ raise UnauthorizedError(message)
125
+ elif status == 403:
126
+ raise ForbiddenError(message)
127
+ elif status == 404:
128
+ raise NotFoundError(message)
129
+ elif status == 409:
130
+ raise ConflictError(message)
131
+ elif status == 413:
132
+ raise PayloadTooLargeError(message)
133
+ elif status == 429:
134
+ raise RateLimitExceededError(message)
135
+ else:
136
+ raise ShotgunWebError(f"HTTP {status}: {message}")
137
+
138
+ # =========================================================================
139
+ # Workspace Methods
140
+ # =========================================================================
141
+
142
+ async def list_workspaces(self) -> WorkspaceListResponse:
143
+ """List workspaces the current user has access to.
144
+
145
+ Returns:
146
+ WorkspaceListResponse with list of workspaces
147
+ """
148
+ token = await self._get_auth_token()
149
+ url = f"{self.base_url}{WORKSPACES_PATH}"
150
+
151
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
152
+ response = await client.get(
153
+ url,
154
+ headers={"Authorization": f"Bearer {token}"},
155
+ )
156
+ self._raise_for_status(response)
157
+ data = response.json()
158
+ # Handle both formats: raw list or {"workspaces": [...]}
159
+ if isinstance(data, list):
160
+ data = {"workspaces": data}
161
+ return WorkspaceListResponse.model_validate(data)
162
+
163
+ async def get_or_fetch_workspace_id(self) -> str:
164
+ """Get workspace_id from config or fetch it using current JWT.
165
+
166
+ Returns:
167
+ workspace_id string
168
+
169
+ Raises:
170
+ UnauthorizedError: If not authenticated
171
+ WorkspaceNotFoundError: If user has no workspaces
172
+ """
173
+ config_manager = get_config_manager()
174
+ config = await config_manager.load()
175
+
176
+ # Check if already cached
177
+ if config.shotgun.workspace_id:
178
+ return config.shotgun.workspace_id
179
+
180
+ # Fetch using existing JWT
181
+ response = await self.list_workspaces()
182
+ if not response.workspaces:
183
+ raise WorkspaceNotFoundError("No workspaces found for user")
184
+
185
+ workspace_id = response.workspaces[0].id
186
+
187
+ # Cache for future use
188
+ await config_manager.update_shotgun_account(workspace_id=workspace_id)
189
+
190
+ return workspace_id
191
+
192
+ # =========================================================================
193
+ # Permission Methods
194
+ # =========================================================================
195
+
196
+ async def check_permissions(self, workspace_id: str) -> PermissionCheckResponse:
197
+ """Check user permissions in workspace.
198
+
199
+ Args:
200
+ workspace_id: Workspace UUID
201
+
202
+ Returns:
203
+ PermissionCheckResponse with user's role and capabilities
204
+ """
205
+ token = await self._get_auth_token()
206
+ url = f"{self.base_url}{PERMISSIONS_PATH.format(workspace_id=workspace_id)}"
207
+
208
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
209
+ response = await client.get(
210
+ url,
211
+ headers={"Authorization": f"Bearer {token}"},
212
+ )
213
+ self._raise_for_status(response)
214
+ return PermissionCheckResponse.model_validate(response.json())
215
+
216
+ # =========================================================================
217
+ # Spec Methods
218
+ # =========================================================================
219
+
220
+ async def list_specs(
221
+ self,
222
+ workspace_id: str,
223
+ page: int = 1,
224
+ page_size: int = 50,
225
+ sort: Literal["name", "created_on", "updated_on"] = "updated_on",
226
+ order: Literal["asc", "desc"] = "desc",
227
+ ) -> SpecListResponse:
228
+ """List specs in workspace.
229
+
230
+ Args:
231
+ workspace_id: Workspace UUID
232
+ page: Page number (1-indexed)
233
+ page_size: Items per page (max 100)
234
+ sort: Sort field
235
+ order: Sort order
236
+
237
+ Returns:
238
+ SpecListResponse with paginated specs
239
+ """
240
+ token = await self._get_auth_token()
241
+ url = f"{self.base_url}{SPECS_BASE_PATH.format(workspace_id=workspace_id)}"
242
+
243
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
244
+ response = await client.get(
245
+ url,
246
+ params={
247
+ "page": page,
248
+ "page_size": page_size,
249
+ "sort": sort,
250
+ "order": order,
251
+ },
252
+ headers={"Authorization": f"Bearer {token}"},
253
+ )
254
+ self._raise_for_status(response)
255
+ return SpecListResponse.model_validate(response.json())
256
+
257
+ async def get_spec(self, workspace_id: str, spec_id: str) -> SpecResponse:
258
+ """Get spec details.
259
+
260
+ Args:
261
+ workspace_id: Workspace UUID
262
+ spec_id: Spec UUID
263
+
264
+ Returns:
265
+ SpecResponse with spec details
266
+ """
267
+ token = await self._get_auth_token()
268
+ url = f"{self.base_url}{SPECS_DETAIL_PATH.format(workspace_id=workspace_id, spec_id=spec_id)}"
269
+
270
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
271
+ response = await client.get(
272
+ url,
273
+ headers={"Authorization": f"Bearer {token}"},
274
+ )
275
+ self._raise_for_status(response)
276
+ return SpecResponse.model_validate(response.json())
277
+
278
+ async def create_spec(
279
+ self,
280
+ workspace_id: str,
281
+ name: str,
282
+ description: str | None = None,
283
+ ) -> SpecCreateResponse:
284
+ """Create a new spec with initial version in uploading state.
285
+
286
+ Args:
287
+ workspace_id: Workspace UUID
288
+ name: Spec name (unique per workspace)
289
+ description: Optional spec description
290
+
291
+ Returns:
292
+ SpecCreateResponse with spec, initial version, and upload URL
293
+ """
294
+ token = await self._get_auth_token()
295
+ url = f"{self.base_url}{SPECS_BASE_PATH.format(workspace_id=workspace_id)}"
296
+ request_data = SpecCreateRequest(name=name, description=description)
297
+ request_body = request_data.model_dump(exclude_none=True)
298
+
299
+ logger.debug(
300
+ "Creating spec: POST %s with body=%s",
301
+ url,
302
+ request_body,
303
+ )
304
+
305
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
306
+ response = await client.post(
307
+ url,
308
+ json=request_body,
309
+ headers={"Authorization": f"Bearer {token}"},
310
+ )
311
+ if not response.is_success:
312
+ logger.error(
313
+ "create_spec failed: POST %s returned %d - %s",
314
+ url,
315
+ response.status_code,
316
+ response.text,
317
+ )
318
+ self._raise_for_status(response)
319
+ return SpecCreateResponse.model_validate(response.json())
320
+
321
+ async def update_spec(
322
+ self,
323
+ workspace_id: str,
324
+ spec_id: str,
325
+ name: str | None = None,
326
+ description: str | None = None,
327
+ is_public: bool | None = None,
328
+ ) -> SpecResponse:
329
+ """Update spec metadata or visibility.
330
+
331
+ Args:
332
+ workspace_id: Workspace UUID
333
+ spec_id: Spec UUID
334
+ name: New spec name
335
+ description: New description
336
+ is_public: New visibility setting
337
+
338
+ Returns:
339
+ Updated SpecResponse
340
+ """
341
+ token = await self._get_auth_token()
342
+ url = f"{self.base_url}{SPECS_DETAIL_PATH.format(workspace_id=workspace_id, spec_id=spec_id)}"
343
+ request_data = SpecUpdateRequest(
344
+ name=name, description=description, is_public=is_public
345
+ )
346
+
347
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
348
+ response = await client.patch(
349
+ url,
350
+ json=request_data.model_dump(exclude_none=True),
351
+ headers={"Authorization": f"Bearer {token}"},
352
+ )
353
+ self._raise_for_status(response)
354
+ return SpecResponse.model_validate(response.json())
355
+
356
+ # =========================================================================
357
+ # Version Methods
358
+ # =========================================================================
359
+
360
+ async def list_versions(
361
+ self,
362
+ workspace_id: str,
363
+ spec_id: str,
364
+ page: int = 1,
365
+ page_size: int = 50,
366
+ state: str | None = None,
367
+ ) -> VersionListResponse:
368
+ """List versions of a spec.
369
+
370
+ Args:
371
+ workspace_id: Workspace UUID
372
+ spec_id: Spec UUID
373
+ page: Page number
374
+ page_size: Items per page
375
+ state: Filter by version state
376
+
377
+ Returns:
378
+ VersionListResponse with paginated versions
379
+ """
380
+ token = await self._get_auth_token()
381
+ url = f"{self.base_url}{VERSIONS_PATH.format(workspace_id=workspace_id, spec_id=spec_id)}"
382
+ params: dict[str, int | str] = {"page": page, "page_size": page_size}
383
+ if state:
384
+ params["state"] = state
385
+
386
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
387
+ response = await client.get(
388
+ url,
389
+ params=params,
390
+ headers={"Authorization": f"Bearer {token}"},
391
+ )
392
+ self._raise_for_status(response)
393
+ return VersionListResponse.model_validate(response.json())
394
+
395
+ async def create_version(
396
+ self,
397
+ workspace_id: str,
398
+ spec_id: str,
399
+ label: str | None = None,
400
+ notes: str | None = None,
401
+ ) -> VersionCreateResponse:
402
+ """Create a new version for an existing spec.
403
+
404
+ Args:
405
+ workspace_id: Workspace UUID
406
+ spec_id: Spec UUID
407
+ label: Optional version label
408
+ notes: Optional version notes
409
+
410
+ Returns:
411
+ VersionCreateResponse with version and upload URL
412
+ """
413
+ token = await self._get_auth_token()
414
+ url = f"{self.base_url}{VERSIONS_PATH.format(workspace_id=workspace_id, spec_id=spec_id)}"
415
+ request_data = {"spec_id": spec_id}
416
+ if label:
417
+ request_data["label"] = label
418
+ if notes:
419
+ request_data["notes"] = notes
420
+
421
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
422
+ response = await client.post(
423
+ url,
424
+ json=request_data,
425
+ headers={"Authorization": f"Bearer {token}"},
426
+ )
427
+ self._raise_for_status(response)
428
+ return VersionCreateResponse.model_validate(response.json())
429
+
430
+ async def close_version(
431
+ self,
432
+ workspace_id: str,
433
+ spec_id: str,
434
+ version_id: str,
435
+ ) -> VersionCloseResponse:
436
+ """Close/finalize a version.
437
+
438
+ Transitions version from uploading to ready state.
439
+
440
+ Args:
441
+ workspace_id: Workspace UUID
442
+ spec_id: Spec UUID
443
+ version_id: Version UUID
444
+
445
+ Returns:
446
+ VersionCloseResponse with closed version and web URL
447
+ """
448
+ token = await self._get_auth_token()
449
+ url = f"{self.base_url}{VERSION_CLOSE_PATH.format(workspace_id=workspace_id, spec_id=spec_id, version_id=version_id)}"
450
+
451
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
452
+ response = await client.post(
453
+ url,
454
+ headers={"Authorization": f"Bearer {token}"},
455
+ )
456
+ self._raise_for_status(response)
457
+ return VersionCloseResponse.model_validate(response.json())
458
+
459
+ async def set_latest_version(
460
+ self,
461
+ workspace_id: str,
462
+ spec_id: str,
463
+ version_id: str,
464
+ ) -> SpecVersionResponse:
465
+ """Set version as latest.
466
+
467
+ Args:
468
+ workspace_id: Workspace UUID
469
+ spec_id: Spec UUID
470
+ version_id: Version UUID
471
+
472
+ Returns:
473
+ Updated SpecVersionResponse
474
+ """
475
+ token = await self._get_auth_token()
476
+ url = f"{self.base_url}{VERSION_SET_LATEST_PATH.format(workspace_id=workspace_id, spec_id=spec_id, version_id=version_id)}"
477
+
478
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
479
+ response = await client.post(
480
+ url,
481
+ headers={"Authorization": f"Bearer {token}"},
482
+ )
483
+ self._raise_for_status(response)
484
+ return SpecVersionResponse.model_validate(response.json())
485
+
486
+ async def get_version_with_files(self, version_id: str) -> VersionWithFilesResponse:
487
+ """Get version metadata and files by version ID only.
488
+
489
+ This is a convenience endpoint for CLI that doesn't require
490
+ workspace_id or spec_id in the request path.
491
+
492
+ Args:
493
+ version_id: Version UUID
494
+
495
+ Returns:
496
+ VersionWithFilesResponse with version details, spec info, and files
497
+
498
+ Raises:
499
+ UnauthorizedError: If not authenticated
500
+ NotFoundError: If version not found
501
+ ForbiddenError: If user lacks access to the spec
502
+ """
503
+ token = await self._get_auth_token()
504
+ url = f"{self.base_url}{VERSION_BY_ID_PATH.format(version_id=version_id)}"
505
+
506
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
507
+ response = await client.get(
508
+ url,
509
+ headers={"Authorization": f"Bearer {token}"},
510
+ )
511
+ self._raise_for_status(response)
512
+ return VersionWithFilesResponse.model_validate(response.json())
513
+
514
+ # =========================================================================
515
+ # File Methods
516
+ # =========================================================================
517
+
518
+ async def list_files(
519
+ self,
520
+ workspace_id: str,
521
+ spec_id: str,
522
+ version_id: str,
523
+ include_download_urls: bool = False,
524
+ ) -> FileListResponse:
525
+ """List files in a version.
526
+
527
+ Args:
528
+ workspace_id: Workspace UUID
529
+ spec_id: Spec UUID
530
+ version_id: Version UUID
531
+ include_download_urls: Include pre-signed download URLs
532
+
533
+ Returns:
534
+ FileListResponse with files
535
+ """
536
+ token = await self._get_auth_token()
537
+ url = f"{self.base_url}{FILES_PATH.format(workspace_id=workspace_id, spec_id=spec_id, version_id=version_id)}"
538
+
539
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
540
+ response = await client.get(
541
+ url,
542
+ params={"include_download_urls": include_download_urls},
543
+ headers={"Authorization": f"Bearer {token}"},
544
+ )
545
+ self._raise_for_status(response)
546
+ return FileListResponse.model_validate(response.json())
547
+
548
+ @retry(
549
+ retry=retry_if_exception_type((httpx.HTTPError, ShotgunWebError)),
550
+ stop=stop_after_attempt(3),
551
+ wait=wait_exponential(multiplier=1, min=1, max=4),
552
+ reraise=True,
553
+ )
554
+ async def initiate_file_upload(
555
+ self,
556
+ workspace_id: str,
557
+ spec_id: str,
558
+ version_id: str,
559
+ relative_path: str,
560
+ size_bytes: int,
561
+ content_hash: str,
562
+ ) -> FileUploadResponse:
563
+ """Initiate file upload to a version.
564
+
565
+ Retries on transient failures with exponential backoff.
566
+
567
+ Args:
568
+ workspace_id: Workspace UUID
569
+ spec_id: Spec UUID
570
+ version_id: Version UUID
571
+ relative_path: Path relative to .shotgun/
572
+ size_bytes: File size in bytes
573
+ content_hash: SHA-256 hash of file content
574
+
575
+ Returns:
576
+ FileUploadResponse with file details and pre-signed upload URL
577
+ """
578
+ token = await self._get_auth_token()
579
+ url = f"{self.base_url}{FILES_PATH.format(workspace_id=workspace_id, spec_id=spec_id, version_id=version_id)}"
580
+ request_data = {
581
+ "spec_id": spec_id,
582
+ "version_id": version_id,
583
+ "relative_path": relative_path,
584
+ "size_bytes": size_bytes,
585
+ "content_hash": content_hash,
586
+ }
587
+
588
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
589
+ response = await client.post(
590
+ url,
591
+ json=request_data,
592
+ headers={"Authorization": f"Bearer {token}"},
593
+ )
594
+ self._raise_for_status(response)
595
+ return FileUploadResponse.model_validate(response.json())
596
+
597
+ @retry(
598
+ retry=retry_if_exception_type((httpx.HTTPError, ShotgunWebError)),
599
+ stop=stop_after_attempt(3),
600
+ wait=wait_exponential(multiplier=1, min=1, max=4),
601
+ reraise=True,
602
+ )
603
+ async def upload_file_to_presigned_url(
604
+ self,
605
+ presigned_url: str,
606
+ file_path: Path,
607
+ ) -> None:
608
+ """Upload file content to pre-signed URL.
609
+
610
+ Streams file in 1MB chunks with retry logic for transient failures.
611
+
612
+ Args:
613
+ presigned_url: Pre-signed URL to upload to
614
+ file_path: Path to file to upload
615
+
616
+ Raises:
617
+ ShotgunWebError: If upload fails after retries
618
+ """
619
+ logger.debug("Uploading file %s to presigned URL", file_path)
620
+
621
+ async def file_stream() -> AsyncIterator[bytes]:
622
+ async with aiofiles.open(file_path, "rb") as f:
623
+ while chunk := await f.read(UPLOAD_CHUNK_SIZE):
624
+ yield chunk
625
+
626
+ async with httpx.AsyncClient(timeout=300.0) as client:
627
+ response = await client.put(
628
+ presigned_url,
629
+ content=file_stream(),
630
+ )
631
+ if not response.is_success:
632
+ raise ShotgunWebError(
633
+ f"Failed to upload file: HTTP {response.status_code}"
634
+ )
635
+
636
+ logger.debug("Successfully uploaded file %s", file_path)
637
+
638
+ # =========================================================================
639
+ # Public Access Methods (No Authentication Required)
640
+ # =========================================================================
641
+
642
+ async def get_public_spec(self, spec_id: str) -> PublicSpecResponse:
643
+ """Get public spec details.
644
+
645
+ No authentication required.
646
+
647
+ Args:
648
+ spec_id: Spec UUID
649
+
650
+ Returns:
651
+ PublicSpecResponse with spec details (latest version only)
652
+ """
653
+ url = f"{self.base_url}{PUBLIC_SPEC_PATH.format(spec_id=spec_id)}"
654
+
655
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
656
+ response = await client.get(url)
657
+ self._raise_for_status(response)
658
+ return PublicSpecResponse.model_validate(response.json())
659
+
660
+ async def get_public_spec_files(
661
+ self,
662
+ spec_id: str,
663
+ include_download_urls: bool = True,
664
+ ) -> FileListResponse:
665
+ """List files in latest version of public spec.
666
+
667
+ No authentication required.
668
+
669
+ Args:
670
+ spec_id: Spec UUID
671
+ include_download_urls: Include pre-signed download URLs
672
+
673
+ Returns:
674
+ FileListResponse with files from latest version
675
+ """
676
+ url = f"{self.base_url}{PUBLIC_SPEC_FILES_PATH.format(spec_id=spec_id)}"
677
+
678
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
679
+ response = await client.get(
680
+ url,
681
+ params={"include_download_urls": include_download_urls},
682
+ )
683
+ self._raise_for_status(response)
684
+ return FileListResponse.model_validate(response.json())
685
+
686
+ async def get_public_file(self, spec_id: str, file_id: str) -> SpecFileResponse:
687
+ """Get file details from public spec.
688
+
689
+ No authentication required.
690
+
691
+ Args:
692
+ spec_id: Spec UUID
693
+ file_id: File UUID
694
+
695
+ Returns:
696
+ SpecFileResponse with file details and download URL
697
+ """
698
+ url = f"{self.base_url}{PUBLIC_FILE_PATH.format(spec_id=spec_id, file_id=file_id)}"
699
+
700
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
701
+ response = await client.get(url)
702
+ self._raise_for_status(response)
703
+ return SpecFileResponse.model_validate(response.json())