Skip to content

Commit 435f96b

Browse files
Merge branch 'master' into chore/test-upgrade-ci-postgres-to-v18
2 parents ea465ad + 976931a commit 435f96b

12 files changed

Lines changed: 1152 additions & 388 deletions

src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot.py

Lines changed: 72 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,14 @@
4343
BuildDetailsVcsInfo,
4444
)
4545
from sentry.preprod.api.models.public.snapshots import (
46+
SnapshotApproverResponseDict,
4647
SnapshotCreateResponseDict,
4748
SnapshotDetailsResponseDict,
49+
SnapshotImageResponseDict,
50+
VcsInfoResponseDict,
4851
)
4952
from sentry.preprod.api.models.snapshots.project_preprod_snapshot_models import (
5053
SnapshotApprover,
51-
SnapshotDetailsApiResponse,
52-
SnapshotImageResponse,
5354
)
5455
from sentry.preprod.api.models.snapshots.snapshot_status import (
5556
SnapshotStatusInput,
@@ -66,12 +67,8 @@
6667
MISSING_BASE_GRACE_PERIOD_SECONDS,
6768
SNAPSHOT_ARCHIVE_MANIFEST_FILENAME,
6869
)
69-
from sentry.preprod.snapshots.manifest import (
70-
ComparisonManifest,
71-
ImageMetadata,
72-
SnapshotManifest,
73-
image_metadata_extras,
74-
)
70+
from sentry.preprod.snapshots.image_serialization import build_head_image_dict
71+
from sentry.preprod.snapshots.manifest import SnapshotManifest
7572
from sentry.preprod.snapshots.models import (
7673
PreprodSnapshotComparison,
7774
PreprodSnapshotMetrics,
@@ -130,28 +127,6 @@ def _strip_to_compact(img: dict[str, Any]) -> dict[str, Any]:
130127
return {k: img[k] for k in _COMPACT_FIELDS if k in img}
131128

132129

133-
def build_snapshot_image_response(
134-
image_file_name: str,
135-
metadata: ImageMetadata,
136-
global_diff_threshold: float | None,
137-
) -> SnapshotImageResponse:
138-
return SnapshotImageResponse(
139-
**image_metadata_extras(metadata, exclude={"key", "image_file_name"}),
140-
key=metadata.content_hash,
141-
display_name=metadata.display_name,
142-
image_file_name=image_file_name,
143-
group=metadata.group,
144-
width=metadata.width,
145-
height=metadata.height,
146-
diff_threshold=metadata.diff_threshold
147-
if metadata.diff_threshold is not None
148-
else global_diff_threshold,
149-
description=metadata.description,
150-
tags=metadata.tags,
151-
canvas_theme=metadata.canvas_theme,
152-
)
153-
154-
155130
MAX_SNAPSHOT_REQUEST_BODY_SIZE = 256 * 1024 * 1024
156131

157132

@@ -387,8 +362,10 @@ def get(
387362
with start_span(
388363
op="preprod.snapshot.parse_manifest", name="parse_head_manifest"
389364
) as span:
390-
manifest = SnapshotManifest(**orjson.loads(raw_manifest))
391-
set_span_data(span, "image_count", len(manifest.images))
365+
head_manifest = orjson.loads(raw_manifest)
366+
head_images: dict[str, Any] = head_manifest.get("images", {})
367+
head_diff_threshold = head_manifest.get("diff_threshold")
368+
set_span_data(span, "image_count", len(head_images))
392369
except Exception:
393370
logger.exception(
394371
"Failed to retrieve snapshot manifest",
@@ -415,8 +392,8 @@ def get(
415392
else:
416393
vcs_info = BuildDetailsVcsInfo()
417394

418-
comparison_manifest: ComparisonManifest | None = None
419-
base_manifest: SnapshotManifest | None = None
395+
comparison_manifest: dict[str, Any] | None = None
396+
base_manifest: dict[str, Any] | None = None
420397
all_comparisons = list(
421398
PreprodSnapshotComparison.objects.select_related("base_snapshot_metrics")
422399
.filter(head_snapshot_metrics=snapshot_metrics)
@@ -441,10 +418,12 @@ def get(
441418
with start_span(
442419
op="preprod.snapshot.parse_manifest", name="parse_comparison_manifest"
443420
) as span:
444-
comparison_manifest = ComparisonManifest(
445-
**orjson.loads(raw_comparison_manifest)
421+
comparison_manifest = orjson.loads(raw_comparison_manifest)
422+
if "base_artifact_id" not in comparison_manifest:
423+
raise ValueError("comparison manifest missing base_artifact_id")
424+
set_span_data(
425+
span, "image_count", len(comparison_manifest.get("images", {}))
446426
)
447-
set_span_data(span, "image_count", len(comparison_manifest.images))
448427
except Exception:
449428
comparison_manifest = None
450429
logger.exception(
@@ -466,8 +445,8 @@ def get(
466445
with start_span(
467446
op="preprod.snapshot.parse_manifest", name="parse_base_manifest"
468447
) as span:
469-
base_manifest = SnapshotManifest(**orjson.loads(raw_base_manifest))
470-
set_span_data(span, "image_count", len(base_manifest.images))
448+
base_manifest = orjson.loads(raw_base_manifest)
449+
set_span_data(span, "image_count", len(base_manifest.get("images", {})))
471450
except Exception:
472451
logger.exception(
473452
"Failed to fetch base manifest",
@@ -509,26 +488,29 @@ def get(
509488
with start_span(
510489
op="preprod.snapshot.serialize_images", name="serialize_head_images"
511490
) as span:
512-
set_span_data(span, "image_count", len(manifest.images))
513-
image_list = [
514-
build_snapshot_image_response(key, metadata, manifest.diff_threshold)
515-
for key, metadata in sorted(manifest.images.items())
491+
set_span_data(span, "image_count", len(head_images))
492+
image_list: list[SnapshotImageResponseDict] = [
493+
build_head_image_dict(key, metadata, head_diff_threshold)
494+
for key, metadata in sorted(head_images.items())
516495
]
517496

518-
images_by_file_name: dict[str, SnapshotImageResponse] = {
519-
img.image_file_name: img for img in image_list
497+
images_by_file_name: dict[str, SnapshotImageResponseDict] = {
498+
img["image_file_name"]: img for img in image_list
520499
}
521500

522501
base_artifact_id: str | None = None
523502

524503
if comparison_manifest is not None:
525-
base_artifact_id = str(comparison_manifest.base_artifact_id)
504+
base_artifact_id = str(comparison_manifest["base_artifact_id"])
505+
comparison_images = comparison_manifest.get("images", {})
526506
with start_span(
527507
op="preprod.snapshot.categorize_comparison", name="categorize_comparison_images"
528508
) as span:
529-
set_span_data(span, "image_count", len(comparison_manifest.images))
509+
set_span_data(span, "image_count", len(comparison_images))
530510
categorized = categorize_comparison_images(
531-
comparison_manifest, images_by_file_name, base_manifest
511+
comparison_images,
512+
images_by_file_name,
513+
base_manifest.get("images", {}) if base_manifest else None,
532514
)
533515
else:
534516
if comparison is not None:
@@ -628,51 +610,55 @@ def get(
628610
op="preprod.snapshot.serialize_response", name="serialize_response_body"
629611
) as span:
630612
set_span_data(span, "image_count", len(image_list))
631-
response_data = SnapshotDetailsApiResponse(
632-
head_artifact_id=str(artifact.id),
633-
base_artifact_id=base_artifact_id,
634-
project_id=str(artifact.project_id),
635-
comparison_type=comparison_type,
636-
state=artifact.state,
637-
vcs_info=vcs_info,
638-
app_id=artifact.app_id,
639-
is_selective=snapshot_metrics.is_selective,
640-
images=image_list if comparison_type != "diff" else [],
641-
image_count=snapshot_metrics.image_count,
642-
changed=categorized.changed,
643-
changed_count=len(categorized.changed),
644-
added=categorized.added,
645-
added_count=len(categorized.added),
646-
removed=categorized.removed,
647-
removed_count=len(categorized.removed),
648-
renamed=categorized.renamed,
649-
renamed_count=len(categorized.renamed),
650-
unchanged=categorized.unchanged,
651-
unchanged_count=len(categorized.unchanged),
652-
errored=categorized.errored,
653-
errored_count=len(categorized.errored),
654-
skipped=categorized.skipped,
655-
skipped_count=len(categorized.skipped),
656-
diff_threshold=manifest.diff_threshold,
657-
comparison_state=derived_status.comparison_state,
658-
approval_status=derived_status.approval_status,
659-
comparison_error_message=derived_status.comparison_error_message,
660-
approvers=approver_list if approved else [],
661-
).dict()
613+
response_data: SnapshotDetailsResponseDict = {
614+
"head_artifact_id": str(artifact.id),
615+
"base_artifact_id": base_artifact_id,
616+
"project_id": str(artifact.project_id),
617+
"comparison_type": comparison_type,
618+
"state": cast(str, artifact.state),
619+
"vcs_info": cast(VcsInfoResponseDict, vcs_info.dict()),
620+
"app_id": artifact.app_id,
621+
"is_selective": snapshot_metrics.is_selective,
622+
"images": image_list if comparison_type != "diff" else [],
623+
"image_count": snapshot_metrics.image_count,
624+
"added": categorized.added,
625+
"added_count": len(categorized.added),
626+
"removed": categorized.removed,
627+
"removed_count": len(categorized.removed),
628+
"renamed": categorized.renamed,
629+
"renamed_count": len(categorized.renamed),
630+
"changed": categorized.changed,
631+
"changed_count": len(categorized.changed),
632+
"unchanged": categorized.unchanged,
633+
"unchanged_count": len(categorized.unchanged),
634+
"errored": categorized.errored,
635+
"errored_count": len(categorized.errored),
636+
"skipped": categorized.skipped,
637+
"skipped_count": len(categorized.skipped),
638+
"diff_threshold": head_diff_threshold,
639+
"comparison_state": derived_status.comparison_state,
640+
"approval_status": derived_status.approval_status,
641+
"comparison_error_message": derived_status.comparison_error_message,
642+
"approvers": (
643+
[cast(SnapshotApproverResponseDict, a.dict()) for a in approver_list]
644+
if approved
645+
else []
646+
),
647+
}
662648

663649
if compact:
650+
# Compact mode strips images to a subset of keys, producing a shape that
651+
# is intentionally looser than the response TypedDict; mutate via a plain
652+
# dict view.
653+
compact_data = cast(dict[str, Any], response_data)
664654
for key in _COMPACT_IMAGE_LIST_KEYS:
665-
response_data[key] = [_strip_to_compact(img) for img in response_data[key]]
655+
compact_data[key] = [_strip_to_compact(img) for img in compact_data[key]]
666656
for key in _COMPACT_PAIR_LIST_KEYS:
667-
for pair in response_data[key]:
657+
for pair in compact_data[key]:
668658
pair["base_image"] = _strip_to_compact(pair["base_image"])
669659
pair["head_image"] = _strip_to_compact(pair["head_image"])
670660

671-
# cast() sanctioned here: pydantic .dict() returns dict[str, Any] with no
672-
# static link back to SnapshotDetailsResponseDict. The TypedDict and the
673-
# Pydantic model are kept in sync by hand at the source of truth.
674-
body = cast(SnapshotDetailsResponseDict, response_data)
675-
return Response(body)
661+
return Response(response_data)
676662

677663

678664
@extend_schema(tags=["Snapshots"])

src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_image_detail.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ def _to_response_dict(resp: SnapshotImageDetailResponse) -> SnapshotImageDetailR
122122

123123

124124
# Intentionally uses a flat response format (nullable fields, no conditional shapes)
125-
# rather than matching the details endpoint's SnapshotDiffPair/SnapshotImageResponse split.
125+
# rather than the details endpoint's categorized diff-pair/image split.
126126
# This endpoint is designed for LLM/MCP consumers that benefit from a single uniform shape.
127127
@extend_schema(tags=["Snapshots"])
128128
@cell_silo_endpoint

src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_latest_base.py

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,13 @@
3131
from sentry.preprod.analytics import PreprodArtifactApiGetLatestBaseSnapshotEvent
3232
from sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot import (
3333
_strip_to_compact,
34-
build_snapshot_image_response,
3534
)
36-
from sentry.preprod.api.models.public.snapshots import LatestBaseSnapshotResponseDict
35+
from sentry.preprod.api.models.public.snapshots import (
36+
LatestBaseSnapshotImageResponseDict,
37+
LatestBaseSnapshotResponseDict,
38+
)
3739
from sentry.preprod.models import PreprodArtifact
38-
from sentry.preprod.snapshots.manifest import SnapshotManifest
40+
from sentry.preprod.snapshots.image_serialization import build_head_image_dict
3941

4042
logger = logging.getLogger(__name__)
4143

@@ -204,7 +206,8 @@ def get(
204206
if response is None:
205207
raise FileNotFoundError("Manifest does not exist in objectstore")
206208
manifest_data = orjson.loads(response.payload.read())
207-
manifest = SnapshotManifest(**manifest_data)
209+
manifest_images = manifest_data.get("images", {})
210+
manifest_diff_threshold = manifest_data.get("diff_threshold")
208211
except Exception:
209212
logger.exception(
210213
"Failed to retrieve snapshot manifest",
@@ -217,20 +220,23 @@ def get(
217220

218221
image_base_url = f"/api/0/projects/{organization.slug}/{artifact.project.slug}/files/images"
219222

220-
images = []
221-
for key, metadata in sorted(manifest.images.items()):
222-
img = build_snapshot_image_response(key, metadata, manifest.diff_threshold).dict()
223-
img["image_url"] = f"{image_base_url}/{metadata.content_hash}/"
223+
images: list[LatestBaseSnapshotImageResponseDict] = []
224+
for key, metadata in sorted(manifest_images.items()):
225+
img = cast(
226+
LatestBaseSnapshotImageResponseDict,
227+
build_head_image_dict(key, metadata, manifest_diff_threshold),
228+
)
229+
img["image_url"] = f"{image_base_url}/{metadata['content_hash']}/"
224230
images.append(img)
225231

226-
response_data: dict[str, Any] = {
232+
response_data: LatestBaseSnapshotResponseDict = {
227233
"head_artifact_id": str(artifact.id),
228234
"project_id": str(artifact.project_id),
229235
"project_slug": artifact.project.slug,
230236
"app_id": artifact.app_id,
231237
"image_count": snapshot_metrics.image_count,
232238
"images": images,
233-
"diff_threshold": manifest.diff_threshold,
239+
"diff_threshold": manifest_diff_threshold,
234240
"date_added": artifact.date_added.isoformat(),
235241
}
236242

@@ -246,13 +252,10 @@ def get(
246252
}
247253

248254
if compact:
249-
response_data["images"] = [
255+
compact_data = cast(dict[str, Any], response_data)
256+
compact_data["images"] = [
250257
{**_strip_to_compact(img), "image_url": img["image_url"]}
251-
for img in response_data["images"]
258+
for img in compact_data["images"]
252259
]
253260

254-
# cast() sanctioned: response_data is a hand-built dict[str, Any] whose
255-
# shape mirrors LatestBaseSnapshotResponseDict. The TypedDict and the
256-
# builder are kept in sync by hand at the source of truth.
257-
body = cast(LatestBaseSnapshotResponseDict, response_data)
258-
return Response(body)
261+
return Response(response_data)

0 commit comments

Comments
 (0)