shotgun-sh 0.2.17__py3-none-any.whl → 0.4.0.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.
- shotgun/agents/agent_manager.py +219 -37
- shotgun/agents/common.py +79 -78
- shotgun/agents/config/README.md +89 -0
- shotgun/agents/config/__init__.py +10 -1
- shotgun/agents/config/manager.py +364 -53
- shotgun/agents/config/models.py +101 -21
- shotgun/agents/config/provider.py +51 -13
- shotgun/agents/config/streaming_test.py +119 -0
- shotgun/agents/context_analyzer/analyzer.py +6 -2
- shotgun/agents/conversation/__init__.py +18 -0
- shotgun/agents/conversation/filters.py +164 -0
- shotgun/agents/conversation/history/chunking.py +278 -0
- shotgun/agents/{history → conversation/history}/compaction.py +27 -1
- shotgun/agents/{history → conversation/history}/constants.py +5 -0
- shotgun/agents/conversation/history/file_content_deduplication.py +239 -0
- shotgun/agents/{history → conversation/history}/history_processors.py +267 -3
- shotgun/agents/{history → conversation/history}/token_counting/anthropic.py +8 -0
- shotgun/agents/{conversation_manager.py → conversation/manager.py} +1 -1
- shotgun/agents/{conversation_history.py → conversation/models.py} +8 -94
- shotgun/agents/error/__init__.py +11 -0
- shotgun/agents/error/models.py +19 -0
- shotgun/agents/export.py +12 -13
- shotgun/agents/models.py +66 -1
- shotgun/agents/plan.py +12 -13
- shotgun/agents/research.py +13 -10
- shotgun/agents/router/__init__.py +47 -0
- shotgun/agents/router/models.py +376 -0
- shotgun/agents/router/router.py +185 -0
- shotgun/agents/router/tools/__init__.py +18 -0
- shotgun/agents/router/tools/delegation_tools.py +503 -0
- shotgun/agents/router/tools/plan_tools.py +322 -0
- shotgun/agents/runner.py +230 -0
- shotgun/agents/specify.py +12 -13
- shotgun/agents/tasks.py +12 -13
- shotgun/agents/tools/file_management.py +49 -1
- shotgun/agents/tools/registry.py +2 -0
- shotgun/agents/tools/web_search/__init__.py +1 -2
- shotgun/agents/tools/web_search/gemini.py +1 -3
- shotgun/agents/tools/web_search/openai.py +1 -1
- shotgun/build_constants.py +2 -2
- shotgun/cli/clear.py +1 -1
- shotgun/cli/compact.py +5 -3
- shotgun/cli/context.py +44 -1
- shotgun/cli/error_handler.py +24 -0
- shotgun/cli/export.py +34 -34
- shotgun/cli/plan.py +34 -34
- shotgun/cli/research.py +17 -9
- shotgun/cli/spec/__init__.py +5 -0
- shotgun/cli/spec/backup.py +81 -0
- shotgun/cli/spec/commands.py +132 -0
- shotgun/cli/spec/models.py +48 -0
- shotgun/cli/spec/pull_service.py +219 -0
- shotgun/cli/specify.py +20 -19
- shotgun/cli/tasks.py +34 -34
- shotgun/codebase/core/change_detector.py +1 -1
- shotgun/codebase/core/ingestor.py +154 -8
- shotgun/codebase/core/manager.py +1 -1
- shotgun/codebase/models.py +2 -0
- shotgun/exceptions.py +325 -0
- shotgun/llm_proxy/__init__.py +17 -0
- shotgun/llm_proxy/client.py +215 -0
- shotgun/llm_proxy/models.py +137 -0
- shotgun/logging_config.py +42 -0
- shotgun/main.py +4 -0
- shotgun/posthog_telemetry.py +1 -1
- shotgun/prompts/agents/export.j2 +2 -0
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +23 -3
- shotgun/prompts/agents/partials/interactive_mode.j2 +3 -3
- shotgun/prompts/agents/partials/router_delegation_mode.j2 +36 -0
- shotgun/prompts/agents/plan.j2 +29 -1
- shotgun/prompts/agents/research.j2 +75 -23
- shotgun/prompts/agents/router.j2 +440 -0
- shotgun/prompts/agents/specify.j2 +80 -4
- shotgun/prompts/agents/state/system_state.j2 +15 -8
- shotgun/prompts/agents/tasks.j2 +63 -23
- shotgun/prompts/history/chunk_summarization.j2 +34 -0
- shotgun/prompts/history/combine_summaries.j2 +53 -0
- shotgun/sdk/codebase.py +14 -3
- shotgun/settings.py +5 -0
- shotgun/shotgun_web/__init__.py +67 -1
- shotgun/shotgun_web/client.py +42 -1
- shotgun/shotgun_web/constants.py +46 -0
- shotgun/shotgun_web/exceptions.py +29 -0
- shotgun/shotgun_web/models.py +390 -0
- shotgun/shotgun_web/shared_specs/__init__.py +32 -0
- shotgun/shotgun_web/shared_specs/file_scanner.py +175 -0
- shotgun/shotgun_web/shared_specs/hasher.py +83 -0
- shotgun/shotgun_web/shared_specs/models.py +71 -0
- shotgun/shotgun_web/shared_specs/upload_pipeline.py +329 -0
- shotgun/shotgun_web/shared_specs/utils.py +34 -0
- shotgun/shotgun_web/specs_client.py +703 -0
- shotgun/shotgun_web/supabase_client.py +31 -0
- shotgun/tui/app.py +78 -15
- shotgun/tui/components/mode_indicator.py +120 -25
- shotgun/tui/components/status_bar.py +2 -2
- shotgun/tui/containers.py +1 -1
- shotgun/tui/dependencies.py +64 -9
- shotgun/tui/layout.py +5 -0
- shotgun/tui/protocols.py +37 -0
- shotgun/tui/screens/chat/chat.tcss +9 -1
- shotgun/tui/screens/chat/chat_screen.py +1015 -106
- shotgun/tui/screens/chat/codebase_index_prompt_screen.py +196 -17
- shotgun/tui/screens/chat_screen/command_providers.py +13 -89
- shotgun/tui/screens/chat_screen/hint_message.py +76 -1
- shotgun/tui/screens/chat_screen/history/agent_response.py +7 -3
- shotgun/tui/screens/chat_screen/history/chat_history.py +12 -0
- shotgun/tui/screens/chat_screen/history/formatters.py +53 -15
- shotgun/tui/screens/chat_screen/history/partial_response.py +11 -1
- shotgun/tui/screens/chat_screen/messages.py +219 -0
- shotgun/tui/screens/confirmation_dialog.py +40 -0
- shotgun/tui/screens/directory_setup.py +45 -41
- shotgun/tui/screens/feedback.py +10 -3
- shotgun/tui/screens/github_issue.py +11 -2
- shotgun/tui/screens/model_picker.py +28 -8
- shotgun/tui/screens/onboarding.py +179 -26
- shotgun/tui/screens/pipx_migration.py +58 -6
- shotgun/tui/screens/provider_config.py +66 -8
- shotgun/tui/screens/shared_specs/__init__.py +21 -0
- shotgun/tui/screens/shared_specs/create_spec_dialog.py +273 -0
- shotgun/tui/screens/shared_specs/models.py +56 -0
- shotgun/tui/screens/shared_specs/share_specs_dialog.py +390 -0
- shotgun/tui/screens/shared_specs/upload_progress_screen.py +452 -0
- shotgun/tui/screens/shotgun_auth.py +110 -16
- shotgun/tui/screens/spec_pull.py +288 -0
- shotgun/tui/screens/welcome.py +123 -0
- shotgun/tui/services/conversation_service.py +5 -2
- shotgun/tui/utils/mode_progress.py +20 -86
- shotgun/tui/widgets/__init__.py +2 -1
- shotgun/tui/widgets/approval_widget.py +152 -0
- shotgun/tui/widgets/cascade_confirmation_widget.py +203 -0
- shotgun/tui/widgets/plan_panel.py +129 -0
- shotgun/tui/widgets/step_checkpoint_widget.py +180 -0
- shotgun/tui/widgets/widget_coordinator.py +1 -1
- {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/METADATA +11 -4
- shotgun_sh-0.4.0.dev1.dist-info/RECORD +242 -0
- {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/WHEEL +1 -1
- shotgun_sh-0.2.17.dist-info/RECORD +0 -194
- /shotgun/agents/{history → conversation/history}/__init__.py +0 -0
- /shotgun/agents/{history → conversation/history}/context_extraction.py +0 -0
- /shotgun/agents/{history → conversation/history}/history_building.py +0 -0
- /shotgun/agents/{history → conversation/history}/message_utils.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/__init__.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/base.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/openai.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/sentencepiece_counter.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/tokenizer_cache.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/utils.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_estimation.py +0 -0
- {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/entry_points.txt +0 -0
- {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.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())
|