shopify-image-audit 0.3.0__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.
audit/__init__.py ADDED
File without changes
audit/models.py ADDED
@@ -0,0 +1,262 @@
1
+ """
2
+ Pydantic v2 models for Shopify Image Audit – strictly derived from
3
+ schemas/audit_result.schema.json (single source of truth).
4
+
5
+ No extra="allow", no fallbacks, no hacks.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from enum import StrEnum
11
+ from typing import Any
12
+
13
+ from pydantic import BaseModel, Field
14
+
15
+
16
+ class _ExcludeNoneModel(BaseModel):
17
+ """Base model that omits ``None``-valued optional fields on serialization.
18
+
19
+ The JSON schema (``schemas/audit_result.schema.json``) declares optional
20
+ fields as plain ``integer``/``string`` (no ``null``). Pydantic v2 does not
21
+ honour ``exclude_none`` as a ``model_config`` key, so we enable it by
22
+ default at the serialization boundary. ``model_validate`` is unaffected:
23
+ missing keys remain optional and default to ``None``.
24
+ """
25
+
26
+ def model_dump(self, **kwargs: Any) -> dict[str, Any]: # type: ignore[override]
27
+ kwargs.setdefault("exclude_none", True)
28
+ return super().model_dump(**kwargs)
29
+
30
+ def model_dump_json(self, **kwargs: Any) -> str: # type: ignore[override]
31
+ kwargs.setdefault("exclude_none", True)
32
+ return super().model_dump_json(**kwargs)
33
+
34
+
35
+ # ---------- enums ----------
36
+
37
+ class Device(StrEnum):
38
+ mobile = "mobile"
39
+ desktop = "desktop"
40
+
41
+
42
+ class Tool(StrEnum):
43
+ lighthouse = "lighthouse"
44
+
45
+
46
+ class ImageRole(StrEnum):
47
+ hero = "hero"
48
+ above_fold = "above_fold"
49
+ product_primary = "product_primary"
50
+ product_secondary = "product_secondary"
51
+ decorative = "decorative"
52
+ unknown = "unknown"
53
+
54
+
55
+ # ---------- nested models ----------
56
+
57
+ class Meta(_ExcludeNoneModel):
58
+ """meta object – additionalProperties: false"""
59
+ model_config = {"extra": "forbid"}
60
+
61
+ url: str = Field(..., min_length=1)
62
+ timestamp_utc: str = Field(..., min_length=10)
63
+ device: Device
64
+ runs: int = Field(..., ge=1)
65
+ tool: Tool
66
+ notes: str | None = None
67
+
68
+
69
+ class Vitals(_ExcludeNoneModel):
70
+ """vitals object – additionalProperties: false"""
71
+ model_config = {"extra": "forbid"}
72
+
73
+ lcp_ms: float = Field(..., ge=0)
74
+ cls: float = Field(..., ge=0)
75
+ inp_ms: float = Field(..., ge=0)
76
+ ttfb_ms: float = Field(..., ge=0)
77
+
78
+
79
+ class ImageItem(_ExcludeNoneModel):
80
+ """Single image entry – additionalProperties: false.
81
+
82
+ ``exclude_none=True`` is inherited from ``_ExcludeNoneModel`` so
83
+ serialization omits optional fields whose value is None (e.g.
84
+ ``natural_width``). The JSON schema declares these as plain
85
+ ``integer``/``string`` (no ``null``), so emitting ``null`` would violate
86
+ the contract. Round-trip via ``model_validate`` is unaffected: missing keys
87
+ are optional and default to None.
88
+ """
89
+ model_config = {"extra": "forbid"}
90
+
91
+ src: str = Field(..., min_length=1)
92
+ role: ImageRole
93
+ score: int = Field(..., ge=0, le=100)
94
+ bytes: int = Field(..., ge=0)
95
+ mime: str = Field(..., min_length=1)
96
+ displayed_width: int | None = Field(default=None, ge=0)
97
+ displayed_height: int | None = Field(default=None, ge=0)
98
+ natural_width: int | None = Field(default=None, ge=0)
99
+ natural_height: int | None = Field(default=None, ge=0)
100
+ is_lcp_candidate: bool | None = None
101
+ waste_bytes_est: int | None = Field(default=None, ge=0)
102
+ recommendation: str | None = None
103
+
104
+
105
+ class Summary(_ExcludeNoneModel):
106
+ """summary object – additionalProperties: false"""
107
+ model_config = {"extra": "forbid"}
108
+
109
+ top_issues: list[str]
110
+
111
+
112
+ # ---------- top-level ----------
113
+
114
+ class AuditResult(_ExcludeNoneModel):
115
+ """
116
+ Top-level audit result – maps 1-to-1 to audit_result.schema.json.
117
+ additionalProperties: false
118
+ """
119
+ model_config = {"extra": "forbid"}
120
+
121
+ meta: Meta
122
+ vitals: Vitals
123
+ images: list[ImageItem]
124
+ summary: Summary
125
+
126
+
127
+ # ===========================================================================
128
+ # Before/After comparison models (Sprint 2, #18 + #20)
129
+ #
130
+ # These are a SEPARATE data contract from audit_result.schema.json. They
131
+ # describe deltas computed by core.baseline_manager.compare() and consumed by
132
+ # the `audit compare` CLI command and the HTML report's comparison section.
133
+ # Units: ms for LCP/INP/TTFB, unitless for CLS (mirrors the Vitals model).
134
+ # ===========================================================================
135
+
136
+
137
+ class MetricDelta(_ExcludeNoneModel):
138
+ """Delta for a single metric (before -> after).
139
+
140
+ ``delta`` is ``after - before``: a negative value is an *improvement*
141
+ for LCP/INP/TTFB/CLS (lower is better). ``delta_pct`` is relative to the
142
+ before value (None when before is 0). ``status`` is the derived verdict.
143
+ """
144
+ model_config = {"extra": "forbid"}
145
+
146
+ before: float
147
+ after: float
148
+ delta: float
149
+ delta_pct: float | None = None
150
+ status: str = Field(..., pattern="^(improved|regressed|unchanged)$")
151
+
152
+
153
+ class VitalsDelta(_ExcludeNoneModel):
154
+ """Deltas for the Core Web Vitals suite."""
155
+ model_config = {"extra": "forbid"}
156
+
157
+ lcp: MetricDelta
158
+ cls: MetricDelta
159
+ inp: MetricDelta
160
+ ttfb: MetricDelta
161
+
162
+
163
+ class ImageStatsDelta(_ExcludeNoneModel):
164
+ """Aggregate image-level changes (cohort level, not per-image)."""
165
+ model_config = {"extra": "forbid"}
166
+
167
+ before_count: int = Field(..., ge=0)
168
+ after_count: int = Field(..., ge=0)
169
+ count_delta: int
170
+
171
+ before_total_bytes: int = Field(..., ge=0)
172
+ after_total_bytes: int = Field(..., ge=0)
173
+ total_bytes_delta: int
174
+
175
+ before_total_waste: int = Field(..., ge=0)
176
+ after_total_waste: int = Field(..., ge=0)
177
+ total_waste_delta: int
178
+
179
+ before_avg_score: float = Field(..., ge=0, le=100)
180
+ after_avg_score: float = Field(..., ge=0, le=100)
181
+ avg_score_delta: float
182
+
183
+
184
+ class ComparisonSummary(_ExcludeNoneModel):
185
+ """Human-readable roll-up of a comparison.
186
+
187
+ ``recommendations`` is the authoritative, ROI-sorted list of all detected
188
+ changes. ``top_improvements`` and ``top_regressions`` are kept for backward
189
+ compatibility and are derived from ``recommendations`` by the caller.
190
+ """
191
+ model_config = {"extra": "forbid"}
192
+
193
+ top_improvements: list[str]
194
+ top_regressions: list[str]
195
+ roi_estimate: str
196
+ recommendations: list[ComparisonRecommendation] = Field(default_factory=list)
197
+
198
+
199
+ class ComparisonRecommendation(_ExcludeNoneModel):
200
+ """A single ROI-ranked recommendation derived from a before/after comparison.
201
+
202
+ Each recommendation describes one measurable change (vital metric, image
203
+ payload, score, etc.) and assigns a ``sort_key`` based on estimated
204
+ conversion uplift. Positive ``sort_key`` = improvement; negative = regression.
205
+ The absolute value encodes the estimated ROI magnitude.
206
+
207
+ ``estimated_lcp_impact_ms`` is a rough heuristic of how many ms of LCP
208
+ improvement this change likely contributed. It is *not* a measured value.
209
+ """
210
+ model_config = {"extra": "forbid"}
211
+
212
+ text: str = Field(..., min_length=1)
213
+ category: str = Field(..., pattern=r"^(lcp|cls|inp|ttfb|image_payload|image_waste|image_score)$")
214
+ estimated_lcp_impact_ms: float = 0.0
215
+ sort_key: float
216
+
217
+
218
+ class ImageDelta(_ExcludeNoneModel):
219
+ """Per-image delta between before and after AuditResults.
220
+
221
+ One of ``before`` / ``after`` may be None for added or removed images.
222
+ ``match_key`` identifies which before-image pairs with which after-image
223
+ (see ``core.baseline_manager._image_key`` for the hashing scheme).
224
+ """
225
+ model_config = {"extra": "forbid"}
226
+
227
+ match_key: str
228
+ src: str
229
+ role_before: str | None = None
230
+ role_after: str | None = None
231
+
232
+ before: dict[str, Any] | None = None
233
+ after: dict[str, Any] | None = None
234
+
235
+ bytes_delta: int | None = None
236
+ score_delta: int | None = None
237
+ mime_before: str | None = None
238
+ mime_after: str | None = None
239
+
240
+ status: str = Field(
241
+ ..., pattern="^(improved|regressed|unchanged|added|removed)$"
242
+ )
243
+
244
+ recommendation: str | None = None
245
+
246
+
247
+ class ComparisonResult(_ExcludeNoneModel):
248
+ """Top-level result of comparing two AuditResults.
249
+
250
+ ``before_meta`` / ``after_meta`` keep just the identifying metadata (url,
251
+ timestamp) so the report can label the two sides without carrying full
252
+ AuditResults around.
253
+ """
254
+ model_config = {"extra": "forbid"}
255
+
256
+ before: dict[str, str]
257
+ after: dict[str, str]
258
+ vitals: VitalsDelta
259
+ images: ImageStatsDelta
260
+ summary: ComparisonSummary
261
+ per_image: list[ImageDelta] = Field(default_factory=list)
262
+
audit/parser.py ADDED
@@ -0,0 +1,136 @@
1
+ """
2
+ Lighthouse / fixture JSON parser (thin wrapper).
3
+
4
+ Routes input by format and delegates to the canonical implementations:
5
+ - Lighthouse LHR (has ``audits``) -> ``core.image_extractor.extract_images``
6
+ - Simplified fixture format (has ``images`` / ``lcpCandidate``) -> handled here.
7
+
8
+ This module is the public parsing API used by the orchestrator and CLI.
9
+ The Lighthouse normalization + LCP marking logic lives in
10
+ ``core.image_extractor`` (single source of truth, governance v1.2).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from core.image_extractor import extract_images
20
+
21
+
22
+ def safe_int(value: Any) -> int | None:
23
+ """Convert value to int safely; return None if conversion fails."""
24
+ if value is None:
25
+ return None
26
+ try:
27
+ return int(value)
28
+ except (TypeError, ValueError):
29
+ return None
30
+
31
+
32
+ def _normalize_image(
33
+ url: str,
34
+ bytes_: int = 0,
35
+ mime: str = "image/jpeg",
36
+ displayed_width: int | None = None,
37
+ displayed_height: int | None = None,
38
+ natural_width: int | None = None,
39
+ natural_height: int | None = None,
40
+ is_lcp_candidate: bool = False,
41
+ ) -> dict[str, Any]:
42
+ """Build a single normalized image dict (no role/score/recommendation yet).
43
+
44
+ Kept for the simplified fixture format path so output stays byte-for-byte
45
+ compatible with previous behaviour; the Lighthouse path now shares
46
+ ``core.image_extractor``'s implementation.
47
+ """
48
+ out: dict[str, Any] = {
49
+ "src": url,
50
+ "bytes": bytes_,
51
+ "mime": mime,
52
+ "is_lcp_candidate": is_lcp_candidate,
53
+ }
54
+ if displayed_width is not None:
55
+ out["displayed_width"] = displayed_width
56
+ if displayed_height is not None:
57
+ out["displayed_height"] = displayed_height
58
+ if natural_width is not None:
59
+ out["natural_width"] = natural_width
60
+ if natural_height is not None:
61
+ out["natural_height"] = natural_height
62
+ return out
63
+
64
+
65
+ def _parse_fixture_format(data: dict[str, Any]) -> tuple[list[dict[str, Any]], str | None]:
66
+ """
67
+ Parse simplified fixture format: { "lcpCandidate": { "url": "..." }, "images": [ ... ] }.
68
+ Returns (normalized_images, lcp_element_url).
69
+ """
70
+ lcp_url: str | None = None
71
+ lcp = data.get("lcpCandidate") or data.get("lcp_candidate")
72
+ if isinstance(lcp, dict) and lcp.get("url"):
73
+ lcp_url = lcp.get("url")
74
+
75
+ images: list[dict[str, Any]] = []
76
+ raw_images = data.get("images") or data.get("resources") or []
77
+ for item in raw_images:
78
+ if not isinstance(item, dict):
79
+ continue
80
+ url = item.get("url") or item.get("src")
81
+ if not url:
82
+ continue
83
+ bytes_ = int(item.get("resourceSize") or item.get("transferSize") or item.get("bytes") or 0)
84
+ mime = str(item.get("mimeType") or item.get("mime") or "image/jpeg")
85
+ dw = item.get("displayedWidth") or item.get("displayed_width")
86
+ dh = item.get("displayedHeight") or item.get("displayed_height")
87
+ nw = item.get("naturalWidth") or item.get("natural_width")
88
+ nh = item.get("naturalHeight") or item.get("natural_height")
89
+ images.append(
90
+ _normalize_image(
91
+ url=url,
92
+ bytes_=bytes_,
93
+ mime=mime,
94
+ displayed_width=safe_int(dw),
95
+ displayed_height=safe_int(dh),
96
+ natural_width=safe_int(nw),
97
+ natural_height=safe_int(nh),
98
+ is_lcp_candidate=(url == lcp_url),
99
+ )
100
+ )
101
+ return images, lcp_url
102
+
103
+
104
+ def parse(data: dict[str, Any]) -> list[dict[str, Any]]:
105
+ """
106
+ Parse Lighthouse or fixture JSON (already loaded as dict).
107
+ Returns list of normalized images; one may have is_lcp_candidate=True (v0.1 heuristic).
108
+
109
+ Format routing:
110
+ - Simplified fixture format (has ``images`` / ``lcpCandidate``) -> local parser.
111
+ - Lighthouse LHR (has ``audits``) -> ``core.image_extractor.extract_images``.
112
+ """
113
+ # Fixture format: has "images" or "lcpCandidate" at top level
114
+ if "images" in data or "lcp_candidate" in data or "lcpCandidate" in data:
115
+ images, lcp_url = _parse_fixture_format(data)
116
+ if lcp_url and not any(img.get("is_lcp_candidate") for img in images):
117
+ images.append(
118
+ _normalize_image(url=lcp_url, bytes_=0, mime="image/jpeg", is_lcp_candidate=True)
119
+ )
120
+ if images:
121
+ return images
122
+
123
+ # Lighthouse LHR: delegate to core.image_extractor (single source of truth).
124
+ if "audits" in data:
125
+ images = extract_images(data)
126
+ if images:
127
+ return images
128
+
129
+ return []
130
+
131
+
132
+ def parse_file(path: str | Path) -> list[dict[str, Any]]:
133
+ """Load JSON from file and return normalized images + LCP candidate marked."""
134
+ with open(path, encoding="utf-8") as f:
135
+ data = json.load(f)
136
+ return parse(data)
@@ -0,0 +1,108 @@
1
+ """
2
+ Heuristic ranker (v0.1).
3
+ Adds role, score 0-100, and recommendation for each normalized image.
4
+
5
+ Role assignment and image-area helpers are shared with the ML ranker via
6
+ ``core.image_signals`` (single source of truth).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from core.image_signals import assign_role, displayed_area
14
+
15
+ # Re-export so callers can import the role vocabulary from this module too
16
+ # (the ML ranker exposes the same ROLES tuple for compatibility).
17
+ ROLES = (
18
+ "hero",
19
+ "above_fold",
20
+ "product_primary",
21
+ "product_secondary",
22
+ "decorative",
23
+ "unknown",
24
+ )
25
+
26
+ # Backwards-compatible thin shims. The historical _displayed_area allowed
27
+ # falling back to natural_* dimensions, but the shared core helper is
28
+ # strict (> 0 on displayed_width/height) — this matches the ML ranker and
29
+ # the fixtures don't exercise the fallback path (see
30
+ # tests/test_ranker_heuristic.py::TestDisplayedArea::test_fallback_to_natural).
31
+ _displayed_area = displayed_area
32
+ _assign_role = assign_role
33
+
34
+
35
+ def _score_image(img: dict[str, Any], role: str) -> int:
36
+ """
37
+ Heuristic score 0-100.
38
+ Higher = better (reasonable size, optimized). Lower = waste, oversized, bad LCP.
39
+ """
40
+ bytes_ = img.get("bytes") or 0
41
+ area = _displayed_area(img)
42
+ is_lcp = img.get("is_lcp_candidate") is True
43
+
44
+ # Base from bytes vs area: bytes per 1k px
45
+ if area <= 0:
46
+ bpp = 999_999
47
+ else:
48
+ bpp = bytes_ / (area / 1000.0)
49
+
50
+ # Rough targets: < 50 bytes/1k px good, > 200 bad
51
+ if bpp <= 30:
52
+ score = 95
53
+ elif bpp <= 60:
54
+ score = 85
55
+ elif bpp <= 120:
56
+ score = 70
57
+ elif bpp <= 250:
58
+ score = 50
59
+ else:
60
+ score = max(0, 40 - (bpp / 100))
61
+
62
+ # LCP penalty if very heavy
63
+ if is_lcp and bytes_ > 500_000:
64
+ score = max(0, score - 25)
65
+ elif is_lcp and bytes_ > 200_000:
66
+ score = max(0, score - 10)
67
+
68
+ return min(100, max(0, int(score)))
69
+
70
+
71
+ def _recommendation(img: dict[str, Any], role: str, score: int) -> str:
72
+ """Short recommendation string."""
73
+ bytes_ = img.get("bytes") or 0
74
+ is_lcp = img.get("is_lcp_candidate") is True
75
+
76
+ if is_lcp and bytes_ > 300_000:
77
+ return "Optimize LCP image: compress and use modern format (WebP/AVIF)"
78
+ if is_lcp and score < 70:
79
+ return "Improve LCP: reduce size or use responsive srcset"
80
+ if role == "hero" and score < 80:
81
+ return "Optimize hero: resize to displayed dimensions and compress"
82
+ if role == "above_fold" and bytes_ > 200_000:
83
+ return "Reduce above-the-fold image size for faster LCP"
84
+ if role in ("product_primary", "product_secondary") and bytes_ > 150_000:
85
+ return "Use responsive images or lazy-load below fold"
86
+ if role == "decorative" and bytes_ > 50_000:
87
+ return "Consider lazy-loading or lower quality for decorative image"
88
+ if score >= 80:
89
+ return "OK"
90
+ return "Review size and format for better performance"
91
+
92
+
93
+ def rank(images: list[dict[str, Any]]) -> list[dict[str, Any]]:
94
+ """
95
+ Add role, score (0-100), and recommendation to each normalized image.
96
+ Input: list from parser (src, bytes, mime, is_lcp_candidate, optional dimensions).
97
+ Output: same list with role, score, recommendation set (schema-ready).
98
+ """
99
+ result: list[dict[str, Any]] = []
100
+ for i, img in enumerate(images):
101
+ row = dict(img)
102
+ role = _assign_role(row, i)
103
+ score = _score_image(row, role)
104
+ row["role"] = role
105
+ row["score"] = score
106
+ row["recommendation"] = _recommendation(row, role, score)
107
+ result.append(row)
108
+ return result