shotgun-sh 0.2.23.dev1__py3-none-any.whl → 0.2.29.dev2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of shotgun-sh might be problematic. Click here for more details.
- shotgun/agents/agent_manager.py +3 -3
- shotgun/agents/common.py +1 -1
- shotgun/agents/config/manager.py +36 -21
- shotgun/agents/config/models.py +30 -0
- shotgun/agents/config/provider.py +27 -14
- 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 +216 -0
- shotgun/agents/{history → conversation/history}/history_processors.py +267 -3
- shotgun/agents/{conversation_manager.py → conversation/manager.py} +1 -1
- shotgun/agents/{conversation_history.py → conversation/models.py} +8 -94
- shotgun/agents/tools/web_search/openai.py +1 -1
- shotgun/cli/clear.py +1 -1
- shotgun/cli/compact.py +5 -3
- shotgun/cli/context.py +1 -1
- shotgun/cli/spec/__init__.py +5 -0
- shotgun/cli/spec/backup.py +81 -0
- shotgun/cli/spec/commands.py +130 -0
- shotgun/cli/spec/models.py +30 -0
- shotgun/cli/spec/pull_service.py +165 -0
- shotgun/codebase/core/ingestor.py +153 -7
- shotgun/codebase/models.py +2 -0
- shotgun/exceptions.py +5 -3
- shotgun/main.py +2 -0
- shotgun/posthog_telemetry.py +1 -1
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +3 -3
- shotgun/prompts/agents/partials/interactive_mode.j2 +3 -3
- shotgun/prompts/agents/research.j2 +0 -3
- shotgun/prompts/history/chunk_summarization.j2 +34 -0
- shotgun/prompts/history/combine_summaries.j2 +53 -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 +291 -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 +39 -0
- shotgun/tui/containers.py +1 -1
- shotgun/tui/layout.py +5 -0
- shotgun/tui/screens/chat/chat_screen.py +212 -16
- shotgun/tui/screens/chat/codebase_index_prompt_screen.py +147 -19
- shotgun/tui/screens/chat_screen/command_providers.py +10 -0
- shotgun/tui/screens/chat_screen/history/chat_history.py +0 -36
- shotgun/tui/screens/confirmation_dialog.py +40 -0
- shotgun/tui/screens/model_picker.py +7 -1
- shotgun/tui/screens/onboarding.py +149 -0
- shotgun/tui/screens/pipx_migration.py +46 -0
- shotgun/tui/screens/provider_config.py +41 -0
- 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 +60 -6
- shotgun/tui/screens/spec_pull.py +286 -0
- shotgun/tui/screens/welcome.py +91 -0
- shotgun/tui/services/conversation_service.py +5 -2
- shotgun/tui/widgets/widget_coordinator.py +1 -1
- {shotgun_sh-0.2.23.dev1.dist-info → shotgun_sh-0.2.29.dev2.dist-info}/METADATA +1 -1
- {shotgun_sh-0.2.23.dev1.dist-info → shotgun_sh-0.2.29.dev2.dist-info}/RECORD +86 -59
- {shotgun_sh-0.2.23.dev1.dist-info → shotgun_sh-0.2.29.dev2.dist-info}/WHEEL +1 -1
- /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/anthropic.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.23.dev1.dist-info → shotgun_sh-0.2.29.dev2.dist-info}/entry_points.txt +0 -0
- {shotgun_sh-0.2.23.dev1.dist-info → shotgun_sh-0.2.29.dev2.dist-info}/licenses/LICENSE +0 -0
shotgun/shotgun_web/models.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"""Pydantic models for Shotgun Web API."""
|
|
2
2
|
|
|
3
|
+
from datetime import datetime
|
|
3
4
|
from enum import StrEnum
|
|
5
|
+
from pathlib import Path
|
|
4
6
|
|
|
5
7
|
from pydantic import BaseModel, Field
|
|
6
8
|
|
|
@@ -45,3 +47,391 @@ class TokenStatusResponse(BaseModel):
|
|
|
45
47
|
message: str | None = Field(
|
|
46
48
|
default=None, description="Human-readable status message"
|
|
47
49
|
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ============================================================================
|
|
53
|
+
# Specs API Enums
|
|
54
|
+
# ============================================================================
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class SpecVersionState(StrEnum):
|
|
58
|
+
"""Spec version lifecycle states."""
|
|
59
|
+
|
|
60
|
+
UPLOADING = "uploading"
|
|
61
|
+
READY = "ready"
|
|
62
|
+
FAILED = "failed"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class WorkspaceRole(StrEnum):
|
|
66
|
+
"""User roles within a workspace."""
|
|
67
|
+
|
|
68
|
+
OWNER = "owner"
|
|
69
|
+
EDITOR = "editor"
|
|
70
|
+
VIEWER = "viewer"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ============================================================================
|
|
74
|
+
# Workspace Models
|
|
75
|
+
# ============================================================================
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class WorkspaceResponse(BaseModel):
|
|
79
|
+
"""Workspace details."""
|
|
80
|
+
|
|
81
|
+
id: str = Field(description="Workspace UUID")
|
|
82
|
+
name: str = Field(description="Workspace name")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class MeWorkspaceResponse(BaseModel):
|
|
86
|
+
"""Workspace info from /api/me endpoint."""
|
|
87
|
+
|
|
88
|
+
id: str = Field(description="Workspace UUID")
|
|
89
|
+
name: str = Field(description="Workspace name")
|
|
90
|
+
role: str = Field(description="User's role in workspace")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class MeResponse(BaseModel):
|
|
94
|
+
"""Response from /api/me endpoint."""
|
|
95
|
+
|
|
96
|
+
id: str = Field(description="User UUID")
|
|
97
|
+
email: str = Field(description="User email")
|
|
98
|
+
first_name: str | None = Field(default=None, description="User's first name")
|
|
99
|
+
last_name: str | None = Field(default=None, description="User's last name")
|
|
100
|
+
workspace: MeWorkspaceResponse = Field(description="User's workspace info")
|
|
101
|
+
has_completed_unification: bool = Field(
|
|
102
|
+
description="Whether unification is complete"
|
|
103
|
+
)
|
|
104
|
+
last_unification_at: datetime | None = Field(
|
|
105
|
+
default=None, description="Last unification timestamp"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class WorkspaceListResponse(BaseModel):
|
|
110
|
+
"""Response for listing user's workspaces."""
|
|
111
|
+
|
|
112
|
+
workspaces: list[WorkspaceResponse] = Field(
|
|
113
|
+
description="List of workspaces the user has access to"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ============================================================================
|
|
118
|
+
# File Metadata (for file scanner)
|
|
119
|
+
# ============================================================================
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class FileMetadata(BaseModel):
|
|
123
|
+
"""Metadata for a file discovered by the file scanner."""
|
|
124
|
+
|
|
125
|
+
relative_path: str = Field(description="Path relative to .shotgun/ directory")
|
|
126
|
+
absolute_path: Path = Field(description="Absolute path to the file")
|
|
127
|
+
size_bytes: int = Field(description="File size in bytes")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ============================================================================
|
|
131
|
+
# Specs API Request Models
|
|
132
|
+
# ============================================================================
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class SpecCreateRequest(BaseModel):
|
|
136
|
+
"""Request to create a new spec."""
|
|
137
|
+
|
|
138
|
+
name: str = Field(
|
|
139
|
+
description="Human-readable spec name, unique per workspace",
|
|
140
|
+
min_length=1,
|
|
141
|
+
max_length=255,
|
|
142
|
+
)
|
|
143
|
+
description: str | None = Field(
|
|
144
|
+
default=None,
|
|
145
|
+
description="Optional description of the spec",
|
|
146
|
+
max_length=2000,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class SpecUpdateRequest(BaseModel):
|
|
151
|
+
"""Request to update spec metadata or visibility."""
|
|
152
|
+
|
|
153
|
+
name: str | None = Field(
|
|
154
|
+
default=None,
|
|
155
|
+
description="Update spec name",
|
|
156
|
+
min_length=1,
|
|
157
|
+
max_length=255,
|
|
158
|
+
)
|
|
159
|
+
description: str | None = Field(
|
|
160
|
+
default=None,
|
|
161
|
+
description="Update description",
|
|
162
|
+
max_length=2000,
|
|
163
|
+
)
|
|
164
|
+
is_public: bool | None = Field(
|
|
165
|
+
default=None,
|
|
166
|
+
description="Update visibility (public or team-only)",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class SpecVersionCreateRequest(BaseModel):
|
|
171
|
+
"""Request to create a new version for an existing spec."""
|
|
172
|
+
|
|
173
|
+
spec_id: str = Field(description="ID of the spec to create version for")
|
|
174
|
+
label: str | None = Field(
|
|
175
|
+
default=None,
|
|
176
|
+
description="Optional version label (e.g., 'v1.0', 'initial')",
|
|
177
|
+
max_length=100,
|
|
178
|
+
)
|
|
179
|
+
notes: str | None = Field(
|
|
180
|
+
default=None,
|
|
181
|
+
description="Optional version notes",
|
|
182
|
+
max_length=2000,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class FileUploadRequest(BaseModel):
|
|
187
|
+
"""Request to upload a file to a spec version."""
|
|
188
|
+
|
|
189
|
+
spec_id: str = Field(description="Spec ID")
|
|
190
|
+
version_id: str = Field(description="Version ID")
|
|
191
|
+
relative_path: str = Field(
|
|
192
|
+
description="Path relative to .shotgun/ directory",
|
|
193
|
+
max_length=1000,
|
|
194
|
+
)
|
|
195
|
+
size_bytes: int = Field(description="File size in bytes", gt=0)
|
|
196
|
+
content_hash: str = Field(
|
|
197
|
+
description="SHA-256 hash of file content (hex encoded)",
|
|
198
|
+
min_length=64,
|
|
199
|
+
max_length=64,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ============================================================================
|
|
204
|
+
# Specs API Response Models
|
|
205
|
+
# ============================================================================
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class SpecFileResponse(BaseModel):
|
|
209
|
+
"""Response model for a spec file."""
|
|
210
|
+
|
|
211
|
+
id: str = Field(description="File record ID")
|
|
212
|
+
relative_path: str = Field(description="Path relative to .shotgun/")
|
|
213
|
+
bucket_key: str = Field(description="Full key in object storage")
|
|
214
|
+
size_bytes: int = Field(description="File size in bytes")
|
|
215
|
+
content_hash: str = Field(description="SHA-256 hash (hex)")
|
|
216
|
+
content_type: str | None = Field(
|
|
217
|
+
default=None,
|
|
218
|
+
description="MIME type (e.g., text/markdown, application/json)",
|
|
219
|
+
)
|
|
220
|
+
created_on: datetime | None = Field(
|
|
221
|
+
default=None, description="Upload timestamp (None until upload completes)"
|
|
222
|
+
)
|
|
223
|
+
download_url: str | None = Field(
|
|
224
|
+
default=None,
|
|
225
|
+
description="Pre-signed download URL (temporary)",
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class SpecVersionResponse(BaseModel):
|
|
230
|
+
"""Response model for a spec version."""
|
|
231
|
+
|
|
232
|
+
id: str = Field(description="Version ID")
|
|
233
|
+
spec_id: str = Field(description="Parent spec ID")
|
|
234
|
+
workspace_id: str | None = Field(default=None, description="Workspace ID")
|
|
235
|
+
state: SpecVersionState = Field(description="Version state")
|
|
236
|
+
is_latest: bool = Field(description="Whether this is the latest version")
|
|
237
|
+
label: str | None = Field(default=None, description="Version label")
|
|
238
|
+
notes: str | None = Field(default=None, description="Version notes")
|
|
239
|
+
created_by: str = Field(description="User ID who created this version")
|
|
240
|
+
created_by_email: str | None = Field(
|
|
241
|
+
default=None,
|
|
242
|
+
description="Email of user who created version (hidden for anonymous viewers)",
|
|
243
|
+
)
|
|
244
|
+
created_on: datetime | None = Field(default=None, description="Creation timestamp")
|
|
245
|
+
file_count: int | None = Field(
|
|
246
|
+
default=None,
|
|
247
|
+
description="Number of files in this version",
|
|
248
|
+
)
|
|
249
|
+
total_size_bytes: int | None = Field(
|
|
250
|
+
default=None,
|
|
251
|
+
description="Total size of all files",
|
|
252
|
+
)
|
|
253
|
+
web_url: str | None = Field(
|
|
254
|
+
default=None,
|
|
255
|
+
description="URL to view this version in the web UI",
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class SpecResponse(BaseModel):
|
|
260
|
+
"""Response model for a spec."""
|
|
261
|
+
|
|
262
|
+
id: str = Field(description="Spec ID")
|
|
263
|
+
workspace_id: str = Field(description="Workspace ID")
|
|
264
|
+
name: str = Field(description="Spec name")
|
|
265
|
+
description: str | None = Field(default=None, description="Spec description")
|
|
266
|
+
is_public: bool = Field(
|
|
267
|
+
default=False,
|
|
268
|
+
description="Whether spec is publicly accessible",
|
|
269
|
+
)
|
|
270
|
+
created_by: str = Field(description="User ID who created the spec")
|
|
271
|
+
created_by_email: str | None = Field(
|
|
272
|
+
default=None,
|
|
273
|
+
description="Email of original creator (hidden for anonymous viewers)",
|
|
274
|
+
)
|
|
275
|
+
created_on: datetime | None = Field(default=None, description="Creation timestamp")
|
|
276
|
+
updated_on: datetime | None = Field(
|
|
277
|
+
default=None, description="Last update timestamp"
|
|
278
|
+
)
|
|
279
|
+
updated_by_email: str | None = Field(
|
|
280
|
+
default=None,
|
|
281
|
+
description="Email of user who last updated (hidden for anonymous viewers)",
|
|
282
|
+
)
|
|
283
|
+
latest_version: SpecVersionResponse | None = Field(
|
|
284
|
+
default=None,
|
|
285
|
+
description="Latest version details (if exists)",
|
|
286
|
+
)
|
|
287
|
+
version_count: int = Field(description="Total number of versions")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class PublicSpecResponse(BaseModel):
|
|
291
|
+
"""Response model for public spec access (redacted for anonymous users)."""
|
|
292
|
+
|
|
293
|
+
id: str = Field(description="Spec ID")
|
|
294
|
+
name: str = Field(description="Spec name")
|
|
295
|
+
description: str | None = Field(default=None, description="Spec description")
|
|
296
|
+
created_on: datetime = Field(description="Creation timestamp")
|
|
297
|
+
updated_on: datetime = Field(description="Last update timestamp")
|
|
298
|
+
latest_version: SpecVersionResponse | None = Field(
|
|
299
|
+
default=None,
|
|
300
|
+
description="Latest version details (without sensitive user info)",
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class SpecListResponse(BaseModel):
|
|
305
|
+
"""Response model for listing specs in a workspace."""
|
|
306
|
+
|
|
307
|
+
specs: list[SpecResponse] = Field(description="List of specs")
|
|
308
|
+
total: int = Field(description="Total number of specs")
|
|
309
|
+
page: int = Field(default=1, description="Current page number")
|
|
310
|
+
page_size: int = Field(default=50, description="Items per page")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class VersionListResponse(BaseModel):
|
|
314
|
+
"""Response model for listing versions of a spec."""
|
|
315
|
+
|
|
316
|
+
versions: list[SpecVersionResponse] = Field(description="List of versions")
|
|
317
|
+
spec_id: str = Field(description="Spec ID these versions belong to")
|
|
318
|
+
total: int = Field(description="Total number of versions")
|
|
319
|
+
page: int = Field(default=1, description="Current page number")
|
|
320
|
+
page_size: int = Field(default=50, description="Items per page")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
class FileListResponse(BaseModel):
|
|
324
|
+
"""Response model for listing files in a version."""
|
|
325
|
+
|
|
326
|
+
files: list[SpecFileResponse] = Field(description="List of files")
|
|
327
|
+
version_id: str = Field(description="Version ID these files belong to")
|
|
328
|
+
total: int = Field(description="Total number of files")
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class SpecCreateResponse(BaseModel):
|
|
332
|
+
"""Response after creating a spec with initial version."""
|
|
333
|
+
|
|
334
|
+
spec: SpecResponse = Field(description="Created spec details")
|
|
335
|
+
version: SpecVersionResponse = Field(
|
|
336
|
+
description="Initial version in uploading state"
|
|
337
|
+
)
|
|
338
|
+
upload_url: str | None = Field(
|
|
339
|
+
default=None, description="Base URL for file uploads"
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
class VersionCreateResponse(BaseModel):
|
|
344
|
+
"""Response after creating a new version."""
|
|
345
|
+
|
|
346
|
+
version: SpecVersionResponse = Field(
|
|
347
|
+
description="Created version in uploading state"
|
|
348
|
+
)
|
|
349
|
+
upload_url: str | None = Field(
|
|
350
|
+
default=None, description="Base URL for file uploads"
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
class FileUploadResponse(BaseModel):
|
|
355
|
+
"""Response after uploading a file."""
|
|
356
|
+
|
|
357
|
+
file: SpecFileResponse = Field(description="Uploaded file details")
|
|
358
|
+
upload_url: str = Field(description="Pre-signed URL to upload file content to")
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
class VersionCloseResponse(BaseModel):
|
|
362
|
+
"""Response after closing a version."""
|
|
363
|
+
|
|
364
|
+
version: SpecVersionResponse = Field(description="Closed version details")
|
|
365
|
+
web_url: str = Field(description="Web URL to view this version")
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
class VersionWithFilesResponse(BaseModel):
|
|
369
|
+
"""Response for GET /api/versions/{version_id} endpoint.
|
|
370
|
+
|
|
371
|
+
This is a convenience endpoint for CLI that returns version info
|
|
372
|
+
plus all files without requiring workspace_id/spec_id in the path.
|
|
373
|
+
"""
|
|
374
|
+
|
|
375
|
+
version: SpecVersionResponse = Field(description="Version details")
|
|
376
|
+
spec_name: str = Field(description="Name of the parent spec")
|
|
377
|
+
spec_id: str = Field(description="Parent spec ID")
|
|
378
|
+
workspace_id: str = Field(description="Workspace ID")
|
|
379
|
+
files: list[SpecFileResponse] = Field(description="Files in this version")
|
|
380
|
+
download_urls_expire_at: datetime | None = Field(
|
|
381
|
+
default=None,
|
|
382
|
+
description="When presigned download URLs expire (UTC)",
|
|
383
|
+
)
|
|
384
|
+
web_url: str | None = Field(
|
|
385
|
+
default=None,
|
|
386
|
+
description="URL to view this version in the web UI",
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
class PermissionCheckResponse(BaseModel):
|
|
391
|
+
"""Response for permission check."""
|
|
392
|
+
|
|
393
|
+
workspace_id: str = Field(description="Workspace ID")
|
|
394
|
+
user_role: WorkspaceRole = Field(description="User's role in workspace")
|
|
395
|
+
can_create_specs: bool = Field(description="Whether user can create specs")
|
|
396
|
+
can_upload_versions: bool = Field(description="Whether user can upload versions")
|
|
397
|
+
can_set_latest: bool = Field(description="Whether user can set latest version")
|
|
398
|
+
can_change_visibility: bool = Field(
|
|
399
|
+
description="Whether user can change spec visibility (public/team)"
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
# ============================================================================
|
|
404
|
+
# Specs API Error Response Models
|
|
405
|
+
# ============================================================================
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
class ErrorDetail(BaseModel):
|
|
409
|
+
"""Detailed error information."""
|
|
410
|
+
|
|
411
|
+
field: str | None = Field(default=None, description="Field that caused error")
|
|
412
|
+
message: str = Field(description="Error message")
|
|
413
|
+
code: str | None = Field(default=None, description="Machine-readable error code")
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
class ErrorResponse(BaseModel):
|
|
417
|
+
"""Standard error response."""
|
|
418
|
+
|
|
419
|
+
error: str = Field(description="Error type or category")
|
|
420
|
+
message: str = Field(description="Human-readable error message")
|
|
421
|
+
details: list[ErrorDetail] | None = Field(
|
|
422
|
+
default=None,
|
|
423
|
+
description="Detailed error information",
|
|
424
|
+
)
|
|
425
|
+
request_id: str | None = Field(
|
|
426
|
+
default=None,
|
|
427
|
+
description="Request ID for debugging",
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
# ============================================================================
|
|
432
|
+
# Custom Exceptions
|
|
433
|
+
# ============================================================================
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
class WorkspaceNotFoundError(Exception):
|
|
437
|
+
"""Raised when user has no workspaces."""
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Shared specs file utilities.
|
|
2
|
+
|
|
3
|
+
This module provides utilities for scanning and hashing files in the
|
|
4
|
+
.shotgun/ directory for upload to the shared specs API.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from shotgun.shotgun_web.shared_specs.file_scanner import (
|
|
8
|
+
get_shotgun_directory,
|
|
9
|
+
scan_shotgun_directory,
|
|
10
|
+
)
|
|
11
|
+
from shotgun.shotgun_web.shared_specs.hasher import (
|
|
12
|
+
calculate_sha256,
|
|
13
|
+
calculate_sha256_with_size,
|
|
14
|
+
)
|
|
15
|
+
from shotgun.shotgun_web.shared_specs.models import (
|
|
16
|
+
UploadProgress,
|
|
17
|
+
UploadResult,
|
|
18
|
+
)
|
|
19
|
+
from shotgun.shotgun_web.shared_specs.upload_pipeline import run_upload_pipeline
|
|
20
|
+
from shotgun.shotgun_web.shared_specs.utils import UploadPhase, format_bytes
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"UploadPhase",
|
|
24
|
+
"UploadProgress",
|
|
25
|
+
"UploadResult",
|
|
26
|
+
"calculate_sha256",
|
|
27
|
+
"calculate_sha256_with_size",
|
|
28
|
+
"format_bytes",
|
|
29
|
+
"get_shotgun_directory",
|
|
30
|
+
"run_upload_pipeline",
|
|
31
|
+
"scan_shotgun_directory",
|
|
32
|
+
]
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""File scanner for .shotgun/ directory."""
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from shotgun.logging_config import get_logger
|
|
7
|
+
from shotgun.shotgun_web.models import FileMetadata
|
|
8
|
+
from shotgun.shotgun_web.shared_specs.models import ScanResult
|
|
9
|
+
|
|
10
|
+
logger = get_logger(__name__)
|
|
11
|
+
|
|
12
|
+
# Patterns to ignore when scanning .shotgun/ directory
|
|
13
|
+
IGNORE_PATTERNS = [
|
|
14
|
+
# Shotgun metadata (created by `shotgun spec pull`)
|
|
15
|
+
"meta.json",
|
|
16
|
+
# OS-generated files
|
|
17
|
+
".DS_Store",
|
|
18
|
+
"Thumbs.db",
|
|
19
|
+
# Python cache
|
|
20
|
+
"__pycache__",
|
|
21
|
+
"*.pyc",
|
|
22
|
+
"*.pyo",
|
|
23
|
+
# Editor files
|
|
24
|
+
".vscode",
|
|
25
|
+
".idea",
|
|
26
|
+
"*.swp",
|
|
27
|
+
"*~",
|
|
28
|
+
"*.bak",
|
|
29
|
+
# Git
|
|
30
|
+
".git",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _should_ignore(path: Path) -> bool:
|
|
35
|
+
"""Check if a path should be ignored based on ignore patterns.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
path: Path to check (can be file or directory)
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
True if path matches any ignore pattern
|
|
42
|
+
"""
|
|
43
|
+
name = path.name
|
|
44
|
+
|
|
45
|
+
for pattern in IGNORE_PATTERNS:
|
|
46
|
+
if fnmatch.fnmatch(name, pattern):
|
|
47
|
+
return True
|
|
48
|
+
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _is_in_ignored_directory(path: Path, base_path: Path) -> bool:
|
|
53
|
+
"""Check if a path is inside an ignored directory.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
path: Path to check
|
|
57
|
+
base_path: Base path to check relative to
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
True if path is inside an ignored directory
|
|
61
|
+
"""
|
|
62
|
+
relative = path.relative_to(base_path)
|
|
63
|
+
for part in relative.parts[
|
|
64
|
+
:-1
|
|
65
|
+
]: # Check all parent directories, not the file itself
|
|
66
|
+
if any(fnmatch.fnmatch(part, pattern) for pattern in IGNORE_PATTERNS):
|
|
67
|
+
return True
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def scan_shotgun_directory(project_root: Path) -> list[FileMetadata]:
|
|
72
|
+
"""Recursively scan .shotgun/ directory and return file metadata.
|
|
73
|
+
|
|
74
|
+
Applies ignore patterns to exclude common unwanted files like:
|
|
75
|
+
- .DS_Store, Thumbs.db
|
|
76
|
+
- __pycache__, *.pyc, *.pyo
|
|
77
|
+
- .vscode, .idea, *.swp
|
|
78
|
+
- *~, *.bak
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
project_root: Path to project root containing .shotgun/ directory
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
List of FileMetadata with relative paths (relative to .shotgun/)
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
FileNotFoundError: If .shotgun/ directory does not exist
|
|
88
|
+
"""
|
|
89
|
+
result = await scan_shotgun_directory_with_counts(project_root)
|
|
90
|
+
return result.files
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def scan_shotgun_directory_with_counts(project_root: Path) -> ScanResult:
|
|
94
|
+
"""Recursively scan .shotgun/ directory and return file metadata with counts.
|
|
95
|
+
|
|
96
|
+
Like scan_shotgun_directory, but also returns the total number of files
|
|
97
|
+
found before filtering. This helps distinguish between:
|
|
98
|
+
- Empty directory (no files at all)
|
|
99
|
+
- All files filtered by ignore patterns
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
project_root: Path to project root containing .shotgun/ directory
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
ScanResult with files list and total_files_before_filter count
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
FileNotFoundError: If .shotgun/ directory does not exist
|
|
109
|
+
"""
|
|
110
|
+
shotgun_dir = project_root / ".shotgun"
|
|
111
|
+
|
|
112
|
+
if not shotgun_dir.exists():
|
|
113
|
+
raise FileNotFoundError(f".shotgun/ directory not found at {shotgun_dir}")
|
|
114
|
+
|
|
115
|
+
if not shotgun_dir.is_dir():
|
|
116
|
+
raise NotADirectoryError(f"{shotgun_dir} is not a directory")
|
|
117
|
+
|
|
118
|
+
files: list[FileMetadata] = []
|
|
119
|
+
total_before_filter = 0
|
|
120
|
+
|
|
121
|
+
logger.debug("Scanning directory: %s", shotgun_dir)
|
|
122
|
+
|
|
123
|
+
for path in shotgun_dir.rglob("*"):
|
|
124
|
+
# Skip directories
|
|
125
|
+
if path.is_dir():
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
# Count all files before filtering
|
|
129
|
+
total_before_filter += 1
|
|
130
|
+
|
|
131
|
+
# Skip ignored files
|
|
132
|
+
if _should_ignore(path):
|
|
133
|
+
logger.debug("Ignoring file (pattern match): %s", path)
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
# Skip files in ignored directories
|
|
137
|
+
if _is_in_ignored_directory(path, shotgun_dir):
|
|
138
|
+
logger.debug("Ignoring file (in ignored directory): %s", path)
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
# Calculate relative path from .shotgun/
|
|
142
|
+
relative_path = str(path.relative_to(shotgun_dir))
|
|
143
|
+
|
|
144
|
+
files.append(
|
|
145
|
+
FileMetadata(
|
|
146
|
+
relative_path=relative_path,
|
|
147
|
+
absolute_path=path,
|
|
148
|
+
size_bytes=path.stat().st_size,
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
logger.info(
|
|
153
|
+
"Found %d files in .shotgun/ (%d before filtering)",
|
|
154
|
+
len(files),
|
|
155
|
+
total_before_filter,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# Sort by relative path for consistent ordering
|
|
159
|
+
files.sort(key=lambda f: f.relative_path)
|
|
160
|
+
|
|
161
|
+
return ScanResult(files=files, total_files_before_filter=total_before_filter)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def get_shotgun_directory(project_root: Path | None = None) -> Path:
|
|
165
|
+
"""Get the .shotgun/ directory path.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
project_root: Optional project root path. If None, uses current directory.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
Path to .shotgun/ directory
|
|
172
|
+
"""
|
|
173
|
+
if project_root is None:
|
|
174
|
+
project_root = Path.cwd()
|
|
175
|
+
return project_root / ".shotgun"
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Async SHA-256 file hashing utilities."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import aiofiles
|
|
7
|
+
|
|
8
|
+
from shotgun.logging_config import get_logger
|
|
9
|
+
|
|
10
|
+
logger = get_logger(__name__)
|
|
11
|
+
|
|
12
|
+
# Chunk sizes for reading files
|
|
13
|
+
SMALL_FILE_CHUNK_SIZE = 65536 # 64KB for files < 10MB
|
|
14
|
+
LARGE_FILE_CHUNK_SIZE = 1048576 # 1MB for files >= 10MB
|
|
15
|
+
LARGE_FILE_THRESHOLD = 10 * 1024 * 1024 # 10MB
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _get_chunk_size(file_size: int) -> int:
|
|
19
|
+
"""Determine optimal chunk size based on file size.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
file_size: Size of file in bytes
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
Chunk size in bytes (64KB for small files, 1MB for large files)
|
|
26
|
+
"""
|
|
27
|
+
if file_size >= LARGE_FILE_THRESHOLD:
|
|
28
|
+
return LARGE_FILE_CHUNK_SIZE
|
|
29
|
+
return SMALL_FILE_CHUNK_SIZE
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def calculate_sha256(file_path: Path) -> str:
|
|
33
|
+
"""Calculate SHA-256 hash of a file using streaming.
|
|
34
|
+
|
|
35
|
+
Reads file in chunks to avoid loading entire file into memory.
|
|
36
|
+
Uses adaptive chunk sizing: 64KB for files <10MB, 1MB for larger files.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
file_path: Path to file to hash
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Hex-encoded SHA-256 hash string (64 characters)
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
FileNotFoundError: If file does not exist
|
|
46
|
+
PermissionError: If file is not readable
|
|
47
|
+
"""
|
|
48
|
+
file_size = file_path.stat().st_size
|
|
49
|
+
chunk_size = _get_chunk_size(file_size)
|
|
50
|
+
|
|
51
|
+
logger.debug(
|
|
52
|
+
"Calculating SHA-256 for %s (size=%d, chunk_size=%d)",
|
|
53
|
+
file_path,
|
|
54
|
+
file_size,
|
|
55
|
+
chunk_size,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
sha256_hash = hashlib.sha256()
|
|
59
|
+
|
|
60
|
+
async with aiofiles.open(file_path, "rb") as f:
|
|
61
|
+
while chunk := await f.read(chunk_size):
|
|
62
|
+
sha256_hash.update(chunk)
|
|
63
|
+
|
|
64
|
+
hex_digest = sha256_hash.hexdigest()
|
|
65
|
+
logger.debug("SHA-256 for %s: %s", file_path, hex_digest)
|
|
66
|
+
|
|
67
|
+
return hex_digest
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def calculate_sha256_with_size(file_path: Path) -> tuple[str, int]:
|
|
71
|
+
"""Calculate SHA-256 hash and get file size.
|
|
72
|
+
|
|
73
|
+
Convenience function that returns both hash and size in one call.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
file_path: Path to file to hash
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Tuple of (hex-encoded SHA-256 hash, file size in bytes)
|
|
80
|
+
"""
|
|
81
|
+
file_size = file_path.stat().st_size
|
|
82
|
+
content_hash = await calculate_sha256(file_path)
|
|
83
|
+
return content_hash, file_size
|