• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mozilla / fx-private-relay / be80e3cb-74f4-4055-a9bf-1ba63417990f

11 Feb 2025 05:23PM CUT coverage: 85.49%. First build
be80e3cb-74f4-4055-a9bf-1ba63417990f

push

circleci

jwhitlock
WIP: add timing, exceptions

2650 of 3801 branches covered (69.72%)

Branch coverage included in aggregate %.

17 of 43 new or added lines in 1 file covered. (39.53%)

17913 of 20252 relevant lines covered (88.45%)

9.49 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

89.72
/privaterelay/crux.py
1
"""
2
Interface to Chrome User Experience (CrUX) API
3

4
https://developer.chrome.com/docs/crux/api
5
"""
6

7
from __future__ import annotations
1✔
8

9
import json
1✔
10
from abc import ABC, abstractmethod
1✔
11
from collections import deque
1✔
12
from collections.abc import Iterable
1✔
13
from datetime import date
1✔
14
from itertools import product
1✔
15
from typing import Any, Generic, Literal, NamedTuple, Self, TypeVar, cast, get_args
1✔
16

17
import requests
1✔
18
from codetiming import Timer
1✔
19

20
CRUX_FORM_FACTOR = Literal["PHONE", "TABLET", "DESKTOP"]
1✔
21
CRUX_METRIC = Literal[
1✔
22
    "cumulative_layout_shift",
23
    "first_contentful_paint",
24
    "interaction_to_next_paint",
25
    "largest_contentful_paint",
26
    "experimental_time_to_first_byte",
27
    "navigation_types",
28
    "form_factors",
29
    "round_trip_time",
30
]
31

32

33
class CruxQuery:
1✔
34
    """Represents a CrUX API query body"""
35

36
    def __init__(
1✔
37
        self,
38
        origin: str,
39
        form_factor: CRUX_FORM_FACTOR | None = None,
40
        metrics: Iterable[CRUX_METRIC] | None = None,
41
    ) -> None:
42
        self.origin = origin
1✔
43
        self.form_factor = form_factor
1✔
44
        self.metrics = sorted(metrics) if metrics else None
1✔
45

46
    def __repr__(self) -> str:
1✔
47
        args = [repr(self.origin)]
1✔
48
        if self.form_factor is not None:
1✔
49
            args.append(f"form_factor={self.form_factor!r}")
1✔
50
        if self.metrics is not None:
1✔
51
            args.append(f"metrics={self.metrics!r}")
1✔
52
        return f"{self.__class__.__name__}({', '.join(args)})"
1✔
53

54
    def __eq__(self, other: Any) -> bool:
1✔
55
        if not isinstance(other, CruxQuery):
1!
56
            return NotImplemented
×
57
        return (
1✔
58
            self.origin == other.origin
59
            and self.form_factor == other.form_factor
60
            and self.metrics == other.metrics
61
        )
62

63
    def as_dict(self) -> dict[str, Any]:
1✔
64
        result: dict[str, Any] = {"origin": self.origin}
1✔
65
        if self.form_factor is not None:
1✔
66
            result["form_factor"] = self.form_factor
1✔
67
        if self.metrics is not None:
1✔
68
            result["metrics"] = self.metrics
1✔
69
        return result
1✔
70

71

72
CRUX_PATH_SPECIFICATION = Iterable[str] | Literal["COMBINED"]
1✔
73
CRUX_FORM_FACTOR_SPECIFICATION = CRUX_FORM_FACTOR | Literal["COMBINED", "EACH_FORM"]
1✔
74
CRUX_METRICS_SPECIFICATION = Iterable[CRUX_METRIC] | Literal["ALL"]
1✔
75

76

77
class CruxQuerySpecification:
1✔
78
    """Represents a family of CrUX API queries"""
79

80
    def __init__(
1✔
81
        self,
82
        origin: str,
83
        paths: CRUX_PATH_SPECIFICATION = "COMBINED",
84
        form_factor: CRUX_FORM_FACTOR_SPECIFICATION = "COMBINED",
85
        metrics: CRUX_METRICS_SPECIFICATION = "ALL",
86
    ) -> None:
87
        if not (origin.startswith("http://") or origin.startswith("https://")):
1✔
88
            raise ValueError("origin should start with 'http://' or 'https://'")
1✔
89
        if origin.endswith("/"):
1✔
90
            raise ValueError("origin should not end with a slash")
1✔
91
        if origin.count("/") > 2:
1✔
92
            raise ValueError("origin should not include a path")
1✔
93
        if isinstance(paths, str) and paths != "COMBINED":
1✔
94
            raise ValueError("paths should be a list of path strings")
1✔
95
        if (
1✔
96
            isinstance(paths, Iterable)
97
            and not isinstance(paths, str)
98
            and not all(path.startswith("/") for path in paths)
99
        ):
100
            raise ValueError("in paths, every path should start with a slash")
1✔
101

102
        self.origin = origin
1✔
103
        self.paths = sorted(paths) if paths != "COMBINED" else "COMBINED"
1✔
104
        self.form_factor = form_factor
1✔
105
        self.metrics: CRUX_METRICS_SPECIFICATION = (
1✔
106
            sorted(metrics) if metrics != "ALL" else "ALL"
107
        )
108

109
    def __repr__(self) -> str:
1✔
110
        args = [f"{self.origin!r}"]
1✔
111
        if self.paths != "COMBINED":
1✔
112
            args.append(f"paths={self.paths!r}")
1✔
113
        if self.form_factor != "COMBINED":
1✔
114
            args.append(f"form_factor={self.form_factor!r}")
1✔
115
        if self.metrics != "ALL":
1✔
116
            args.append(f"metrics={self.metrics!r}")
1✔
117
        return f"{self.__class__.__name__}({', '.join(args)})"
1✔
118

119
    def queries(self) -> list[CruxQuery]:
1✔
120
        path_options: list[str] = [""]
1✔
121
        if isinstance(self.paths, list):
1✔
122
            path_options = self.paths
1✔
123

124
        if self.form_factor == "COMBINED":
1✔
125
            form_options: list[CRUX_FORM_FACTOR] | list[None] = [None]
1✔
126
        elif self.form_factor == "EACH_FORM":
1✔
127
            form_options = sorted(get_args(CRUX_FORM_FACTOR))
1✔
128
        else:
129
            form_options = [self.form_factor]
1✔
130

131
        metrics = None if self.metrics == "ALL" else self.metrics
1✔
132

133
        return [
1✔
134
            CruxQuery(self.origin + path, form_factor=form_factor, metrics=metrics)
135
            for path, form_factor in product(path_options, form_options)
136
        ]
137

138

139
class RequestEngine(ABC):
1✔
140
    @abstractmethod
1✔
141
    def post(
1✔
142
        self, url: str, params: dict[str, str], data: dict[str, Any], timeout: float
143
    ) -> ResponseWrapper:
144
        pass
×
145

146

147
class ResponseWrapper(ABC):
1✔
148
    @property
1✔
149
    @abstractmethod
1✔
150
    def status_code(self) -> int:
1✔
151
        pass
×
152

153
    @abstractmethod
1✔
154
    def json(self) -> dict[str, Any]:
1✔
155
        pass
×
156

157
    @property
1✔
158
    @abstractmethod
1✔
159
    def text(self) -> str:
1✔
160
        pass
×
161

162

163
class RequestsEngine(RequestEngine):
1✔
164
    def post(
1✔
165
        self, url: str, params: dict[str, str], data: dict[str, Any], timeout: float
166
    ) -> ResponseWrapper:
167
        return RequestsResponse(
1✔
168
            requests.post(url=url, params=params, data=data, timeout=timeout)
169
        )
170

171

172
class RequestsResponse(ResponseWrapper):
1✔
173
    def __init__(self, response: requests.Response) -> None:
1✔
174
        self._response = response
1✔
175

176
    @property
1✔
177
    def status_code(self) -> int:
1✔
178
        return self._response.status_code
1✔
179

180
    def json(self) -> dict[str, Any]:
1✔
181
        data = self._response.json()
1✔
182
        if not isinstance(data, dict):
1✔
183
            raise ValueError(f"response.json() returned {type(data)}, not dict")
1✔
184
        return data
1✔
185

186
    @property
1✔
187
    def text(self) -> str:
1✔
188
        return self._response.text
1✔
189

190

191
class StubbedRequest(NamedTuple):
1✔
192
    url: str
1✔
193
    params: dict[str, str]
1✔
194
    data: dict[str, Any]
1✔
195
    timeout: float
1✔
196

197

198
class StubbedRequestAction(NamedTuple):
1✔
199
    status_code: int
1✔
200
    data: dict[str, Any] | None = None
1✔
201
    text: str | None = None
1✔
202

203

204
class StubbedEngine(RequestEngine):
1✔
205
    def __init__(self) -> None:
1✔
206
        self.requests: list[StubbedRequest] = []
1✔
207
        self._expected_requests: deque[tuple[StubbedRequest, StubbedRequestAction]] = (
1✔
208
            deque()
209
        )
210

211
    def expect_request(
1✔
212
        self, request: StubbedRequest, action: StubbedRequestAction
213
    ) -> None:
214
        if action.data is None and action.text is None:
1!
215
            raise ValueError("Set either action.data or action.text")
×
216
        self._expected_requests.append((request, action))
1✔
217

218
    def post(
1✔
219
        self,
220
        url: str,
221
        params: dict[str, str],
222
        data: dict[str, Any],
223
        timeout: float = 1.0,
224
    ) -> ResponseWrapper:
225
        request = StubbedRequest(url, params, data, timeout)
1✔
226
        self.requests.append(request)
1✔
227
        expected_request, action = self._expected_requests.popleft()
1✔
228
        if request != expected_request:
1✔
229
            raise RuntimeError(f"Expected {expected_request}, got {request}")
1✔
230
        return StubbedResponse(action.status_code, action.data or action.text or "")
1✔
231

232

233
class StubbedResponse(ResponseWrapper):
1✔
234
    def __init__(self, status_code: int, data: dict[str, Any] | str) -> None:
1✔
235
        self._status_code = status_code
1✔
236
        self._data = data
1✔
237

238
    @property
1✔
239
    def status_code(self) -> int:
1✔
240
        return self._status_code
1✔
241

242
    def json(self) -> dict[str, Any]:
1✔
243
        if isinstance(self._data, str):
1✔
244
            raise ValueError("JSON decoding error")
1✔
245
        return self._data
1✔
246

247
    @property
1✔
248
    def text(self) -> str:
1✔
249
        if isinstance(self._data, dict):
1!
250
            return json.dumps(self._data)
×
251
        return self._data
1✔
252

253

254
class CruxRecordKey:
1✔
255
    def __init__(
1✔
256
        self,
257
        origin: str | None = None,
258
        url: str | None = None,
259
        form_factor: CRUX_FORM_FACTOR | None = None,
260
    ) -> None:
261
        if origin is None and url is None:
1✔
262
            raise ValueError("Either origin or url must be set")
1✔
263
        if origin is not None and url is not None:
1✔
264
            raise ValueError("Can not set both origin and url")
1✔
265
        self.origin = origin
1✔
266
        self.url = url
1✔
267
        self.form_factor = form_factor
1✔
268

269
    def __repr__(self) -> str:
1✔
270
        attrs = ["origin", "url", "form_factor"]
1✔
271
        args = [
1✔
272
            f"{attr}={getattr(self, attr)!r}"
273
            for attr in attrs
274
            if getattr(self, attr, None) is not None
275
        ]
276
        return f"{self.__class__.__name__}({', '.join(args)})"
1✔
277

278
    def __eq__(self, other: Any) -> bool:
1✔
279
        if not isinstance(other, CruxRecordKey):
1!
280
            return NotImplemented
×
281
        return (
1✔
282
            self.origin == other.origin
283
            and self.url == other.url
284
            and self.form_factor == other.form_factor
285
        )
286

287
    @classmethod
1✔
288
    def from_raw_query(cls, data: dict[str, str]) -> Self:
1✔
289
        origin: str | None = None
1✔
290
        url: str | None = None
1✔
291
        form_factor: CRUX_FORM_FACTOR | None = None
1✔
292

293
        for key, val in data.items():
1✔
294
            if key == "origin":
1✔
295
                origin = val
1✔
296
            elif key == "url":
1✔
297
                url = val
1✔
298
            elif key == "formFactor":
1✔
299
                if val in get_args(CRUX_FORM_FACTOR):
1✔
300
                    form_factor = cast(CRUX_FORM_FACTOR, val)
1✔
301
                else:
302
                    raise ValueError(f"{val!r} is not a valid formFactor")
1✔
303
            else:
304
                raise ValueError(f"Unknown key {key!r}")
1✔
305
        return cls(origin=origin, url=url, form_factor=form_factor)
1✔
306

307

308
FloatOrInt = TypeVar("FloatOrInt", float, int)
1✔
309

310

311
class GenericCruxPercentiles(Generic[FloatOrInt]):
1✔
312
    """Generic base class for CrUX (float or int) percentiles"""
313

314
    def __init__(self, p75: FloatOrInt) -> None:
1✔
315
        self.p75: FloatOrInt = p75
1✔
316

317
    def __repr__(self) -> str:
1✔
318
        return f"{self.__class__.__name__}(p75={self.p75!r})"
1✔
319

320
    def __eq__(self, other: Any) -> bool:
1✔
321
        if not isinstance(other, GenericCruxPercentiles):
1!
322
            return NotImplemented
×
323
        return bool(self.p75 == other.p75)
1✔
324

325
    @classmethod
1✔
326
    def from_raw_query(cls, data: dict[str, Any]) -> Self:
1✔
327
        p75: FloatOrInt | None = None
1✔
328

329
        for key, val in data.items():
1✔
330
            if key == "percentiles":
1✔
331
                p75 = cls._parse_percentiles(val)
1✔
332
            else:
333
                raise ValueError(f"Percentiles has unknown key {key!r}")
1✔
334

335
        if p75 is None:
1!
336
            raise ValueError("Percentiles has no key 'percentiles'")
×
337

338
        return cls(p75=p75)
1✔
339

340
    @classmethod
1✔
341
    def _convert(cls, val: Any) -> FloatOrInt:
1✔
342
        raise NotImplementedError()
343

344
    @classmethod
1✔
345
    def _parse_percentiles(cls, data: dict[str, FloatOrInt]) -> FloatOrInt:
1✔
346
        p75: FloatOrInt | None = None
1✔
347

348
        for key, val in data.items():
1✔
349
            if key == "p75":
1!
350
                p75 = cls._convert(val)
1✔
351
            else:
352
                raise ValueError(f"Percentiles has unknown key {key!r}")
×
353

354
        if p75 is None:
1✔
355
            raise ValueError("Percentiles has no key 'p75'")
1✔
356
        return p75
1✔
357

358

359
class CruxFloatPercentiles(GenericCruxPercentiles[float]):
1✔
360
    """Represents a CrUX percentiles with a floating point p75."""
361

362
    @classmethod
1✔
363
    def _convert(cls, val: Any) -> float:
1✔
364
        return float(val)
1✔
365

366

367
class CruxIntPercentiles(GenericCruxPercentiles[float]):
1✔
368
    """Represents a CrUX percentiles with an integer p75."""
369

370
    @classmethod
1✔
371
    def _convert(cls, val: Any) -> int:
1✔
372
        return int(val)
1✔
373

374

375
class GenericCruxHistogram(Generic[FloatOrInt]):
1✔
376
    """
377
    Generic base class for CrUX histograms.
378

379
    CrUX histograms have 3 intervals:
380
    * "Good" (intervals[0] to intervals[1])
381
    * "Needs Improvement" (intervals[1] to intervals[2])
382
    * "Poor" (intervals[2] and above)
383

384
    The densities are the fraction of traffic in that interval, and add up to ~1.0
385

386
    Most histograms have interval boundaries in integer milliseconds.
387
    One (cumulative layout shift) has boundaries with unitless float values.
388
    """
389

390
    def __init__(
1✔
391
        self,
392
        intervals: list[FloatOrInt],
393
        densities: list[float],
394
        percentiles: CruxFloatPercentiles,
395
    ) -> None:
396
        if len(intervals) != 3:
1✔
397
            raise ValueError(f"len(intervals) should be 3, is {len(intervals)}")
1✔
398
        if len(densities) != 3:
1✔
399
            raise ValueError(f"len(densities) should be 3, is {len(densities)}")
1✔
400
        total = sum(densities)
1✔
401
        if not (0.998 < total < 1.002):
1✔
402
            raise ValueError(f"sum(densities) should be 1.0, is {total}")
1✔
403

404
        self.intervals: list[FloatOrInt] = intervals
1✔
405
        self.densities = densities
1✔
406
        self.percentiles = percentiles
1✔
407

408
    def __repr__(self) -> str:
1✔
409
        return (
1✔
410
            f"{self.__class__.__name__}("
411
            f"intervals={self.intervals!r}, "
412
            f"densities={self.densities!r}, "
413
            f"percentiles={self.percentiles!r})"
414
        )
415

416
    def __eq__(self, other: Any) -> bool:
1✔
417
        if not isinstance(other, GenericCruxHistogram):
1!
418
            return NotImplemented
×
419
        return (
1✔
420
            self.intervals == other.intervals
421
            and self.densities == other.densities
422
            and self.percentiles == other.percentiles
423
        )
424

425
    @classmethod
1✔
426
    def from_raw_query(cls, data: dict[str, Any]) -> Self:
1✔
427
        intervals: list[FloatOrInt] = []
1✔
428
        densities: list[float] = []
1✔
429
        percentiles: CruxFloatPercentiles | None = None
1✔
430

431
        for key, val in data.items():
1✔
432
            if key == "histogram":
1✔
433
                intervals, densities = cls._parse_bin_list(val)
1✔
434
            elif key == "percentiles":
1✔
435
                percentiles = CruxFloatPercentiles.from_raw_query({"percentiles": val})
1✔
436
            else:
437
                raise ValueError(f"Unknown key {key!r}")
1✔
438

439
        if not intervals or not densities:
1✔
440
            raise ValueError("No key 'histogram'")
1✔
441
        if percentiles is None:
1✔
442
            raise ValueError("No key 'percentiles'")
1✔
443

444
        return cls(intervals=intervals, densities=densities, percentiles=percentiles)
1✔
445

446
    @classmethod
1✔
447
    def _parse_bin_list(
1✔
448
        cls, data: list[dict[str, Any]]
449
    ) -> tuple[list[FloatOrInt], list[float]]:
450
        bins: list[tuple[FloatOrInt, FloatOrInt | None, float]] = []
1✔
451
        for bin in data:
1✔
452
            bins.append(cls._parse_bin(bin))
1✔
453

454
        if len(bins) != 3:
1✔
455
            raise ValueError(f"Expected 3 bins, got {len(bins)}")
1✔
456
        if bins[0][1] != bins[1][0]:
1✔
457
            raise ValueError(
1✔
458
                f"Bin 1 end {bins[0][1]} does not match Bin 2 start {bins[1][0]}"
459
            )
460
        if bins[1][1] != bins[2][0]:
1✔
461
            raise ValueError(
1✔
462
                f"Bin 2 end {bins[1][1]} does not match Bin 3 start {bins[2][0]}"
463
            )
464
        if bins[2][1] is not None:
1✔
465
            raise ValueError("Bin 3 has end, none expected")
1✔
466

467
        intervals = [bin[0] for bin in bins]
1✔
468
        densities = [bin[2] for bin in bins]
1✔
469
        return intervals, densities
1✔
470

471
    @classmethod
1✔
472
    def _convert(cls, val: Any) -> FloatOrInt:
1✔
473
        raise NotImplementedError()
474

475
    @classmethod
1✔
476
    def _parse_bin(
1✔
477
        cls, data: dict[str, Any]
478
    ) -> tuple[FloatOrInt, FloatOrInt | None, float]:
479
        start: FloatOrInt | None = None
1✔
480
        end: FloatOrInt | None = None
1✔
481
        density: float | None = None
1✔
482

483
        for key, val in data.items():
1✔
484
            if key == "start":
1✔
485
                start = cls._convert(val)
1✔
486
            elif key == "end":
1✔
487
                end = cls._convert(val)
1✔
488
            elif key == "density":
1✔
489
                density = float(val)
1✔
490
            else:
491
                raise ValueError(f"Unknown key {key!r}")
1✔
492

493
        if start is None:
1✔
494
            raise ValueError("Bin has no key 'start'")
1✔
495
        if density is None:
1✔
496
            raise ValueError("Bin has no key 'density'")
1✔
497

498
        return start, end, density
1✔
499

500

501
class CruxHistogram(GenericCruxHistogram[int]):
1✔
502
    """
503
    Represents a CrUX Histogram with millisecond intervals.
504

505
    See GenericCruxHistogram for more information.
506
    """
507

508
    @classmethod
1✔
509
    def _convert(cls, val: Any) -> int:
1✔
510
        return int(val)
1✔
511

512

513
class CruxFloatHistogram(GenericCruxHistogram[float]):
1✔
514
    """
515
    Represents a CrUX Histogram with unitless float intervals.
516

517
    This is just used by cumulative layout shift.
518
    See GenericCruxHistogram for more information.
519
    """
520

521
    @classmethod
1✔
522
    def _convert(cls, val: Any) -> float:
1✔
523
        return float(val)
1✔
524

525

526
class CruxFractions:
1✔
527
    def __init__(self, phone: float, tablet: float, desktop: float) -> None:
1✔
528
        self.phone = phone
1✔
529
        self.tablet = tablet
1✔
530
        self.desktop = desktop
1✔
531

532
    def __repr__(self) -> str:
1✔
533
        args = [
1✔
534
            f"phone={self.phone!r}",
535
            f"tablet={self.tablet!r}",
536
            f"desktop={self.desktop!r}",
537
        ]
538
        return f"{self.__class__.__name__}({', '.join(args)})"
1✔
539

540
    def __eq__(self, other: Any) -> bool:
1✔
541
        if not isinstance(other, CruxFractions):
1!
542
            return NotImplemented
×
543
        return (
1✔
544
            self.phone == other.phone
545
            and self.tablet == other.tablet
546
            and self.desktop == other.desktop
547
        )
548

549
    @classmethod
1✔
550
    def from_raw_query(cls, data: dict[str, Any]) -> Self:
1✔
551
        phone: float | None = None
1✔
552
        tablet: float | None = None
1✔
553
        desktop: float | None = None
1✔
554

555
        for key, val in data.items():
1✔
556
            if key == "fractions":
1✔
557
                phone, tablet, desktop = cls._parse_devices(val)
1✔
558
            else:
559
                raise ValueError(f"Unknown key {key!r}")
1✔
560

561
        if phone is None or tablet is None or desktop is None:
1✔
562
            raise ValueError("No key 'fractions'")
1✔
563

564
        return cls(phone=phone, tablet=tablet, desktop=desktop)
1✔
565

566
    @classmethod
1✔
567
    def _parse_devices(cls, data: dict[str, float]) -> tuple[float, float, float]:
1✔
568
        phone: float | None = None
1✔
569
        tablet: float | None = None
1✔
570
        desktop: float | None = None
1✔
571

572
        for key, val in data.items():
1✔
573
            if key == "phone":
1✔
574
                phone = float(val)
1✔
575
            elif key == "tablet":
1✔
576
                tablet = float(val)
1✔
577
            elif key == "desktop":
1✔
578
                desktop = float(val)
1✔
579
            else:
580
                raise ValueError(f"In fractions, unknown key {key!r}")
1✔
581

582
        if phone is None:
1✔
583
            raise ValueError("In fractions, no key 'phone'")
1✔
584
        if tablet is None:
1✔
585
            raise ValueError("In fractions, no key 'tablet'")
1✔
586
        if desktop is None:
1✔
587
            raise ValueError("In fractions, no key 'desktop'")
1✔
588

589
        return phone, tablet, desktop
1✔
590

591

592
class CruxMetrics:
1✔
593
    def __init__(
1✔
594
        self,
595
        experimental_time_to_first_byte: CruxHistogram | None = None,
596
        first_contentful_paint: CruxHistogram | None = None,
597
        form_factors: CruxFractions | None = None,
598
        interaction_to_next_paint: CruxHistogram | None = None,
599
        largest_contentful_paint: CruxHistogram | None = None,
600
        round_trip_time: CruxIntPercentiles | None = None,
601
        cumulative_layout_shift: CruxFloatHistogram | None = None,
602
    ) -> None:
603
        self.experimental_time_to_first_byte = experimental_time_to_first_byte
1✔
604
        self.first_contentful_paint = first_contentful_paint
1✔
605
        self.form_factors = form_factors
1✔
606
        self.interaction_to_next_paint = interaction_to_next_paint
1✔
607
        self.largest_contentful_paint = largest_contentful_paint
1✔
608
        self.round_trip_time = round_trip_time
1✔
609
        self.cumulative_layout_shift = cumulative_layout_shift
1✔
610

611
    def __repr__(self) -> str:
1✔
612
        attrs = (
×
613
            "experimental_time_to_first_byte",
614
            "first_contentful_paint",
615
            "form_factors",
616
            "interaction_to_next_paint",
617
            "largest_contentful_paint",
618
            "round_trip_time",
619
            "cumulative_layout_shift",
620
        )
621
        args = [
×
622
            f"{_attr}={val!r}"
623
            for _attr in attrs
624
            if (val := getattr(self, _attr, None)) is not None
625
        ]
626
        return f"{self.__class__.__name__}({', '.join(args)})"
×
627

628
    def __eq__(self, other: Any) -> bool:
1✔
629
        if not isinstance(other, CruxMetrics):
1!
630
            return NotImplemented
×
631
        return (
1✔
632
            self.experimental_time_to_first_byte
633
            == other.experimental_time_to_first_byte
634
            and self.first_contentful_paint == other.first_contentful_paint
635
            and self.form_factors == other.form_factors
636
            and self.interaction_to_next_paint == other.interaction_to_next_paint
637
            and self.largest_contentful_paint == other.largest_contentful_paint
638
            and self.round_trip_time == other.round_trip_time
639
            and self.cumulative_layout_shift == other.cumulative_layout_shift
640
        )
641

642
    @classmethod
1✔
643
    def from_raw_query(cls, data: dict[str, Any]) -> Self:
1✔
644
        experimental_time_to_first_byte: CruxHistogram | None = None
1✔
645
        first_contentful_paint: CruxHistogram | None = None
1✔
646
        form_factors: CruxFractions | None = None
1✔
647
        interaction_to_next_paint: CruxHistogram | None = None
1✔
648
        largest_contentful_paint: CruxHistogram | None = None
1✔
649
        round_trip_time: CruxIntPercentiles | None = None
1✔
650
        cumulative_layout_shift: CruxFloatHistogram | None = None
1✔
651

652
        for key, val in data.items():
1✔
653
            if key == "experimental_time_to_first_byte":
1✔
654
                experimental_time_to_first_byte = CruxHistogram.from_raw_query(val)
1✔
655
            elif key == "first_contentful_paint":
1✔
656
                first_contentful_paint = CruxHistogram.from_raw_query(val)
1✔
657
            elif key == "form_factors":
1✔
658
                form_factors = CruxFractions.from_raw_query(val)
1✔
659
            elif key == "interaction_to_next_paint":
1✔
660
                interaction_to_next_paint = CruxHistogram.from_raw_query(val)
1✔
661
            elif key == "largest_contentful_paint":
1✔
662
                largest_contentful_paint = CruxHistogram.from_raw_query(val)
1✔
663
            elif key == "round_trip_time":
1✔
664
                round_trip_time = CruxIntPercentiles.from_raw_query(val)
1✔
665
            elif key == "cumulative_layout_shift":
1✔
666
                cumulative_layout_shift = CruxFloatHistogram.from_raw_query(val)
1✔
667
            else:
668
                raise ValueError(f"In metrics, unknown key {key!r}")
1✔
669

670
        return cls(
1✔
671
            experimental_time_to_first_byte=experimental_time_to_first_byte,
672
            first_contentful_paint=first_contentful_paint,
673
            form_factors=form_factors,
674
            interaction_to_next_paint=interaction_to_next_paint,
675
            largest_contentful_paint=largest_contentful_paint,
676
            round_trip_time=round_trip_time,
677
            cumulative_layout_shift=cumulative_layout_shift,
678
        )
679

680

681
class CruxUrlNormalizationDetails:
1✔
682
    def __init__(self, original_url: str, normalized_url: str) -> None:
1✔
683
        self.original_url = original_url
1✔
684
        self.normalized_url = normalized_url
1✔
685

686
    def __repr__(self) -> str:
1✔
687
        args = [
1✔
688
            f"original_url={self.original_url!r}",
689
            f"normalized_url={self.normalized_url!r}",
690
        ]
691
        return f"{self.__class__.__name__}({', '.join(args)})"
1✔
692

693
    def __eq__(self, other: Any) -> bool:
1✔
694
        if not isinstance(other, CruxUrlNormalizationDetails):
1!
695
            return NotImplemented
×
696
        return (
1✔
697
            self.original_url == other.original_url
698
            and self.normalized_url == other.normalized_url
699
        )
700

701
    @classmethod
1✔
702
    def from_raw_query(cls, data: dict[str, Any]) -> Self:
1✔
703
        original_url: str | None = None
1✔
704
        normalized_url: str | None = None
1✔
705

706
        for key, val in data.items():
1✔
707
            if key == "originalUrl":
1✔
708
                original_url = val
1✔
709
            elif key == "normalizedUrl":
1✔
710
                normalized_url = val
1✔
711
            else:
712
                raise ValueError(f"In urlNormalizationDetails, unknown key {key!r}")
1✔
713

714
        if original_url is None:
1✔
715
            raise ValueError("In urlNormalizationDetails, missing key 'originalUrl'")
1✔
716
        if normalized_url is None:
1✔
717
            raise ValueError("In urlNormalizationDetails, missing key 'normalizedUrl'")
1✔
718

719
        return cls(original_url=original_url, normalized_url=normalized_url)
1✔
720

721

722
class CruxCollectionPeriod:
1✔
723
    def __init__(self, first_date: date, last_date: date) -> None:
1✔
724
        self.first_date = first_date
1✔
725
        self.last_date = last_date
1✔
726

727
    def __repr__(self) -> str:
1✔
728
        args = [
1✔
729
            f"first_date={self.first_date!r}",
730
            f"last_date={self.last_date!r}",
731
        ]
732
        return f"{self.__class__.__name__}({', '.join(args)})"
1✔
733

734
    def __eq__(self, other: Any) -> bool:
1✔
735
        if not isinstance(other, CruxCollectionPeriod):
1!
736
            return NotImplemented
×
737
        return self.first_date == other.first_date and self.last_date == other.last_date
1✔
738

739
    @classmethod
1✔
740
    def from_raw_query(cls, data: dict[str, Any]) -> Self:
1✔
741
        first_date: date | None = None
1✔
742
        last_date: date | None = None
1✔
743

744
        for key, val in data.items():
1✔
745
            if key == "firstDate":
1✔
746
                first_date = cls._parse_date(val)
1✔
747
            elif key == "lastDate":
1✔
748
                last_date = cls._parse_date(val)
1✔
749
            else:
750
                raise ValueError(f"In collectionPeriod, unknown key {key!r}")
1✔
751

752
        if first_date is None:
1✔
753
            raise ValueError("In collectionPeriod, no key 'firstDate'")
1✔
754
        if last_date is None:
1✔
755
            raise ValueError("In collectionPeriod, no key 'lastDate'")
1✔
756

757
        return cls(first_date=first_date, last_date=last_date)
1✔
758

759
    @classmethod
1✔
760
    def _parse_date(cls, data: dict[str, int]) -> date:
1✔
761
        """Parse a date dict, like {"year": 2025, "month": 1, "day": 30}"""
762
        year: int | None = None
1✔
763
        month: int | None = None
1✔
764
        day: int | None = None
1✔
765

766
        for key, val in data.items():
1✔
767
            if key == "year":
1✔
768
                year = val
1✔
769
            elif key == "month":
1✔
770
                month = val
1✔
771
            elif key == "day":
1✔
772
                day = val
1✔
773
            else:
774
                raise ValueError(f"In date, unknown key {key!r}")
1✔
775

776
        if year is None:
1✔
777
            raise ValueError("In date, no key 'year'")
1✔
778
        if month is None:
1✔
779
            raise ValueError("In date, no key 'month'")
1✔
780
        if day is None:
1✔
781
            raise ValueError("In date, no key 'day'")
1✔
782

783
        return date(year=year, month=month, day=day)
1✔
784

785

786
class CruxResult:
1✔
787
    def __init__(
1✔
788
        self,
789
        key: CruxRecordKey,
790
        metrics: CruxMetrics,
791
        collection_period: CruxCollectionPeriod,
792
        url_normalization_details: CruxUrlNormalizationDetails | None = None,
793
        query_time: float | None = None,
794
    ) -> None:
795
        self.key = key
1✔
796
        self.metrics = metrics
1✔
797
        self.collection_period = collection_period
1✔
798
        self.url_normalization_details = url_normalization_details
1✔
799
        self.query_time = query_time
1✔
800

801
    def __repr__(self) -> str:
1✔
802
        args = [
×
803
            f"key={self.key!r}",
804
            f"metrics={self.metrics!r}",
805
            f"collection_period={self.collection_period!r}",
806
        ]
NEW
807
        if self.url_normalization_details is not None:
×
808
            args.append(f"url_normalization_details={self.url_normalization_details!r}")
×
NEW
809
        if self.query_time is not None:
×
NEW
810
            args.append(f"query_time={self.query_time!r}")
×
811
        return f"{self.__class__.__name__}({', '.join(args)})"
×
812

813
    def __eq__(self, other: Any) -> bool:
1✔
814
        if not isinstance(other, CruxResult):
1!
815
            return NotImplemented
×
816
        return (
1✔
817
            self.key == other.key
818
            and self.metrics == other.metrics
819
            and self.collection_period == other.collection_period
820
            and self.url_normalization_details == other.url_normalization_details
821
            and self.query_time == other.query_time
822
        )
823

824
    @classmethod
1✔
825
    def from_raw_query(
1✔
826
        cls, data: dict[str, Any], query_time: float | None = None
827
    ) -> Self:
828
        """Parse a JSON response from the CrUX API into a CruxResult"""
829
        record: CruxResult._RecordItems | None = None
1✔
830

831
        for key, value in data.items():
1✔
832
            if key == "record":
1✔
833
                record = cls._parse_record(value)
1✔
834
            else:
835
                raise ValueError(f"At top level, unexpected key {key!r}")
1✔
836

837
        if record is None:
1✔
838
            raise ValueError("At top level, no key 'record'")
1✔
839
        return cls(
1✔
840
            key=record.key,
841
            metrics=record.metrics,
842
            collection_period=record.collection_period,
843
            url_normalization_details=record.url_normalization_details,
844
        )
845

846
    class _RecordItems(NamedTuple):
1✔
847
        key: CruxRecordKey
1✔
848
        metrics: CruxMetrics
1✔
849
        collection_period: CruxCollectionPeriod
1✔
850
        url_normalization_details: CruxUrlNormalizationDetails | None = None
1✔
851

852
    @classmethod
1✔
853
    def _parse_record(cls, record: dict[str, Any]) -> _RecordItems:
1✔
854
        record_key: CruxRecordKey | None = None
1✔
855
        metrics: CruxMetrics | None = None
1✔
856
        collection_period: CruxCollectionPeriod | None = None
1✔
857
        url_normalization_details: CruxUrlNormalizationDetails | None = None
1✔
858

859
        for key, val in record.items():
1✔
860
            if key == "key":
1✔
861
                record_key = CruxRecordKey.from_raw_query(val)
1✔
862
            elif key == "metrics":
1✔
863
                metrics = CruxMetrics.from_raw_query(val)
1✔
864
            elif key == "collectionPeriod":
1✔
865
                collection_period = CruxCollectionPeriod.from_raw_query(val)
1✔
866
            elif key == "urlNormalizationDetails":
1✔
867
                url_normalization_details = CruxUrlNormalizationDetails.from_raw_query(
1✔
868
                    val
869
                )
870
            else:
871
                raise ValueError(f"In record, unknown key {key!r}")
1✔
872

873
        if record_key is None:
1✔
874
            raise ValueError("In record, no key 'key'")
1✔
875
        if metrics is None:
1✔
876
            raise ValueError("In record, no key 'metrics'")
1✔
877
        if collection_period is None:
1✔
878
            raise ValueError("In record, no key 'collectionPeriod'")
1✔
879

880
        return CruxResult._RecordItems(
1✔
881
            key=record_key,
882
            metrics=metrics,
883
            collection_period=collection_period,
884
            url_normalization_details=url_normalization_details,
885
        )
886

887

888
class CruxError(Exception):
1✔
889
    """Base class for CrUX errors."""
890

891
    def __init__(self, query_time: float | None = None):
1✔
NEW
892
        self.query_time = query_time
×
893

894

895
class CruxDidNotReturnJSON(CruxError):
1✔
896
    def __init__(self, response_text: str, query_time: float | None = None):
1✔
NEW
897
        super().__init__(query_time)
×
NEW
898
        self.response_text = response_text
×
899

900

901
class CruxTimeout(CruxError):
1✔
902
    pass
1✔
903

904

905
class CruxReturnedError(CruxError):
1✔
906
    def __init__(
1✔
907
        self,
908
        status_code: int,
909
        error_data: dict[str, Any],
910
        query_time: float | None = None,
911
    ):
NEW
912
        super().__init__(query_time)
×
913
        self.status_code = status_code
×
NEW
914
        self.error_data = error_data
×
915

916

917
class CruxUnknownData(CruxError):
1✔
918
    def __init__(
1✔
919
        self,
920
        raw_data: dict[str, Any],
921
        query_time: float | None = None,
922
    ):
NEW
923
        super().__init__(query_time)
×
NEW
924
        self.raw_data = raw_data
×
925

926

927
class CruxApiRequester:
1✔
928
    API_URL = "https://chromeuxreport.googleapis.com/v1/records:queryRecord"
1✔
929
    DEFAULT_TIMEOUT = 1.0
1✔
930

931
    def __init__(self, api_key: str, engine: RequestEngine) -> None:
1✔
932
        self._api_key = api_key
1✔
933
        self._engine = engine
1✔
934

935
    def raw_query(self, query: CruxQuery) -> tuple[int, dict[str, Any]]:
1✔
936
        """Request JSON data from the CrUX API."""
937
        resp = self._engine.post(
1✔
938
            url=self.API_URL,
939
            params={"key": self._api_key},
940
            data=query.as_dict(),
941
            timeout=self.DEFAULT_TIMEOUT,
942
        )
943
        try:
1✔
944
            data = resp.json()
1✔
NEW
945
        except requests.exceptions.JSONDecodeError:
×
NEW
946
            raise CruxDidNotReturnJSON(resp.text)
×
947
        return resp.status_code, data
1✔
948

949
    def query(self, query: CruxQuery) -> CruxResult:
1✔
950
        """
951
        Request and decode data from the CrUX API
952

953
        If successful, a CruxResult is returned. On failure, an exception
954
        derived from CruxError is raised.
955
        """
NEW
956
        with Timer(logger=None) as timer:
×
NEW
957
            try:
×
NEW
958
                status_code, data = self.raw_query(query)
×
NEW
959
            except requests.Timeout as err:
×
NEW
960
                raise CruxTimeout(query_time=round(timer.last, 3)) from err
×
NEW
961
            except CruxDidNotReturnJSON as err:
×
NEW
962
                raise CruxDidNotReturnJSON(
×
963
                    err.response_text, query_time=round(timer.last, 3)
964
                ) from err
965

NEW
966
        query_time = round(timer.last, 3)
×
NEW
967
        if status_code != 200:
×
NEW
968
            raise CruxReturnedError(
×
969
                status_code=status_code, error_data=data, query_time=query_time
970
            )
NEW
971
        try:
×
NEW
972
            return CruxResult.from_raw_query(data, query_time=query_time)
×
NEW
973
        except ValueError as error:
×
NEW
974
            raise CruxUnknownData(data, query_time=query_time) from error
×
975

976

977
def main(domain: str, requester: CruxApiRequester) -> str:
1✔
978
    query = CruxQuerySpecification(domain, paths=["/"]).queries()[0]
1✔
979
    status_code, data = requester.raw_query(query)
1✔
980
    return json.dumps(data)
1✔
981

982

983
if __name__ == "__main__":
1!
984
    import os
×
985
    import sys
×
986

987
    api_key = os.environ.get("CRUX_API_KEY")
×
988
    if not api_key:
×
989
        print("Set CRUX_API_KEY to the API key")
×
990
        sys.exit(1)
×
991

992
    engine = RequestsEngine()
×
993
    requester = CruxApiRequester(api_key, engine)
×
994
    result = main("https://relay.firefox.com", requester)
×
995
    print(result)
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc