array-api-extra 0.10.0__py3-none-any.whl → 0.10.3__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.
@@ -1,13 +1,16 @@
1
1
  """Extra array functions built on top of the array API standard."""
2
2
 
3
+ from . import testing
3
4
  from ._delegation import (
4
5
  argpartition,
5
6
  atleast_nd,
7
+ broadcast_shapes,
6
8
  cov,
7
9
  create_diagonal,
8
10
  expand_dims,
9
11
  isclose,
10
12
  isin,
13
+ kron,
11
14
  nan_to_num,
12
15
  one_hot,
13
16
  pad,
@@ -19,19 +22,19 @@ from ._delegation import (
19
22
  )
20
23
  from ._lib._at import at
21
24
  from ._lib._funcs import (
25
+ angle,
22
26
  apply_where,
23
- broadcast_shapes,
24
27
  default_dtype,
25
- kron,
26
28
  nunique,
27
29
  )
28
30
  from ._lib._lazy import lazy_apply
29
31
 
30
- __version__ = "0.10.0"
32
+ __version__ = "0.10.3"
31
33
 
32
34
  # pylint: disable=duplicate-code
33
35
  __all__ = [
34
36
  "__version__",
37
+ "angle",
35
38
  "apply_where",
36
39
  "argpartition",
37
40
  "at",
@@ -53,5 +56,6 @@ __all__ = [
53
56
  "searchsorted",
54
57
  "setdiff1d",
55
58
  "sinc",
59
+ "testing",
56
60
  "union1d",
57
61
  ]
@@ -20,10 +20,12 @@ from ._lib._utils._typing import Array, DType
20
20
 
21
21
  __all__ = [
22
22
  "atleast_nd",
23
+ "broadcast_shapes",
23
24
  "cov",
24
25
  "create_diagonal",
25
26
  "expand_dims",
26
27
  "isclose",
28
+ "kron",
27
29
  "nan_to_num",
28
30
  "one_hot",
29
31
  "pad",
@@ -81,6 +83,66 @@ def atleast_nd(x: Array, /, *, ndim: int, xp: ModuleType | None = None) -> Array
81
83
  return _funcs.atleast_nd(x, ndim=ndim, xp=xp)
82
84
 
83
85
 
86
+ def broadcast_shapes(
87
+ *shapes: tuple[float | None, ...], xp: ModuleType | None = None
88
+ ) -> tuple[int | None, ...]:
89
+ """
90
+ Compute the shape of the broadcasted arrays.
91
+
92
+ Duplicates :func:`numpy.broadcast_shapes`, with additional support for
93
+ None and NaN sizes.
94
+
95
+ This is equivalent to ``xp.broadcast_arrays(arr1, arr2, ...)[0].shape``
96
+ without needing to worry about the backend potentially deep copying
97
+ the arrays.
98
+
99
+ Parameters
100
+ ----------
101
+ *shapes : tuple[int | None, ...]
102
+ Shapes of the arrays to broadcast.
103
+ xp : array_namespace, optional
104
+ The standard-compatible namespace to use for native delegation.
105
+ Default: use the array-agnostic implementation.
106
+
107
+ Returns
108
+ -------
109
+ tuple[int | None, ...]
110
+ The shape of the broadcasted arrays.
111
+
112
+ See Also
113
+ --------
114
+ numpy.broadcast_shapes : Equivalent NumPy function.
115
+ array_api.broadcast_arrays : Function to broadcast actual arrays.
116
+
117
+ Notes
118
+ -----
119
+ This function accepts the Array API's ``None`` for unknown sizes,
120
+ as well as Dask's non-standard ``math.nan``.
121
+ Regardless of input, the output always contains ``None`` for unknown sizes.
122
+
123
+ Examples
124
+ --------
125
+ >>> import array_api_extra as xpx
126
+ >>> xpx.broadcast_shapes((2, 3), (2, 1))
127
+ (2, 3)
128
+ >>> xpx.broadcast_shapes((4, 2, 3), (2, 1), (1, 3))
129
+ (4, 2, 3)
130
+ """
131
+ if (
132
+ xp is not None
133
+ and all(isinstance(size, int) for shape in shapes for size in shape)
134
+ and (
135
+ is_numpy_namespace(xp)
136
+ or is_cupy_namespace(xp)
137
+ or is_jax_namespace(xp)
138
+ or is_torch_namespace(xp)
139
+ )
140
+ ):
141
+ return xp.broadcast_shapes(*shapes)
142
+
143
+ return _funcs.broadcast_shapes(*shapes)
144
+
145
+
84
146
  def cov(m: Array, /, *, xp: ModuleType | None = None) -> Array:
85
147
  """
86
148
  Estimate a covariance matrix (or a stack of covariance matrices).
@@ -418,6 +480,101 @@ def isclose(
418
480
  return _funcs.isclose(a, b, rtol=rtol, atol=atol, equal_nan=equal_nan, xp=xp)
419
481
 
420
482
 
483
+ def kron(
484
+ a: Array | complex,
485
+ b: Array | complex,
486
+ /,
487
+ *,
488
+ xp: ModuleType | None = None,
489
+ ) -> Array:
490
+ """
491
+ Kronecker product of two arrays.
492
+
493
+ Computes the Kronecker product, a composite array made of blocks of the
494
+ second array scaled by the first.
495
+
496
+ Equivalent to ``numpy.kron`` for NumPy arrays.
497
+
498
+ Parameters
499
+ ----------
500
+ a, b : Array | int | float | complex
501
+ Input arrays or scalars. At least one must be an array.
502
+ xp : array_namespace, optional
503
+ The standard-compatible namespace for `a` and `b`. Default: infer.
504
+
505
+ Returns
506
+ -------
507
+ array
508
+ The Kronecker product of `a` and `b`.
509
+
510
+ Notes
511
+ -----
512
+ The function assumes that the number of dimensions of `a` and `b`
513
+ are the same, if necessary prepending the smallest with ones.
514
+ If ``a.shape = (r0,r1,..,rN)`` and ``b.shape = (s0,s1,...,sN)``,
515
+ the Kronecker product has shape ``(r0*s0, r1*s1, ..., rN*SN)``.
516
+ The elements are products of elements from `a` and `b`, organized
517
+ explicitly by::
518
+
519
+ kron(a,b)[k0,k1,...,kN] = a[i0,i1,...,iN] * b[j0,j1,...,jN]
520
+
521
+ where::
522
+
523
+ kt = it * st + jt, t = 0,...,N
524
+
525
+ In the common 2-D case (N=1), the block structure can be visualized::
526
+
527
+ [[ a[0,0]*b, a[0,1]*b, ... , a[0,-1]*b ],
528
+ [ ... ... ],
529
+ [ a[-1,0]*b, a[-1,1]*b, ... , a[-1,-1]*b ]]
530
+
531
+ Examples
532
+ --------
533
+ >>> import array_api_strict as xp
534
+ >>> import array_api_extra as xpx
535
+ >>> xpx.kron(xp.asarray([1, 10, 100]), xp.asarray([5, 6, 7]), xp=xp)
536
+ Array([ 5, 6, 7, 50, 60, 70, 500,
537
+ 600, 700], dtype=array_api_strict.int64)
538
+
539
+ >>> xpx.kron(xp.asarray([5, 6, 7]), xp.asarray([1, 10, 100]), xp=xp)
540
+ Array([ 5, 50, 500, 6, 60, 600, 7,
541
+ 70, 700], dtype=array_api_strict.int64)
542
+
543
+ >>> xpx.kron(xp.eye(2), xp.ones((2, 2)), xp=xp)
544
+ Array([[1., 1., 0., 0.],
545
+ [1., 1., 0., 0.],
546
+ [0., 0., 1., 1.],
547
+ [0., 0., 1., 1.]], dtype=array_api_strict.float64)
548
+
549
+ >>> a = xp.reshape(xp.arange(100), (2, 5, 2, 5))
550
+ >>> b = xp.reshape(xp.arange(24), (2, 3, 4))
551
+ >>> c = xpx.kron(a, b, xp=xp)
552
+ >>> c.shape
553
+ (2, 10, 6, 20)
554
+ >>> I = (1, 3, 0, 2)
555
+ >>> J = (0, 2, 1)
556
+ >>> J1 = (0,) + J # extend to ndim=4
557
+ >>> S1 = (1,) + b.shape
558
+ >>> K = tuple(xp.asarray(I) * xp.asarray(S1) + xp.asarray(J1))
559
+ >>> c[K] == a[I]*b[J]
560
+ Array(True, dtype=array_api_strict.bool)
561
+ """
562
+ if xp is None:
563
+ xp = array_namespace(a, b)
564
+
565
+ a, b = asarrays(a, b, xp=xp)
566
+
567
+ if (
568
+ is_cupy_namespace(xp)
569
+ or is_jax_namespace(xp)
570
+ or is_numpy_namespace(xp)
571
+ or is_torch_namespace(xp)
572
+ ):
573
+ return xp.kron(a, b)
574
+
575
+ return _funcs.kron(a, b, xp=xp)
576
+
577
+
421
578
  def nan_to_num(
422
579
  x: Array | float | complex,
423
580
  /,
@@ -37,7 +37,7 @@ class _AtOp(Enum):
37
37
  MAX = "max"
38
38
 
39
39
  # @override from Python 3.12
40
- def __str__(self) -> str: # pyright: ignore[reportImplicitOverride]
40
+ def __str__(self) -> str: # pyright: ignore[reportImplicitOverride] # pyrefly: ignore[missing-override-decorator]
41
41
  """
42
42
  Return string representation (useful for pytest logs).
43
43
 
@@ -381,7 +381,7 @@ class at: # pylint: disable=invalid-name # numpydoc ignore=PR02
381
381
  # Note for this and all other methods based on _iop:
382
382
  # operator.iadd and operator.add subtly differ in behaviour, as
383
383
  # only iadd will trigger exceptions when y has an incompatible dtype.
384
- return self._op(_AtOp.ADD, operator.iadd, operator.add, y, copy=copy, xp=xp)
384
+ return self._op(_AtOp.ADD, operator.iadd, operator.add, y, copy=copy, xp=xp) # pyright: ignore[reportUnknownArgumentType]
385
385
 
386
386
  def subtract(
387
387
  self,
@@ -392,7 +392,12 @@ class at: # pylint: disable=invalid-name # numpydoc ignore=PR02
392
392
  ) -> Array: # numpydoc ignore=PR01,RT01
393
393
  """Apply ``x[idx] -= y`` and return the updated array."""
394
394
  return self._op(
395
- _AtOp.SUBTRACT, operator.isub, operator.sub, y, copy=copy, xp=xp
395
+ _AtOp.SUBTRACT,
396
+ operator.isub, # pyright: ignore[reportUnknownArgumentType]
397
+ operator.sub,
398
+ y,
399
+ copy=copy,
400
+ xp=xp,
396
401
  )
397
402
 
398
403
  def multiply(
@@ -404,7 +409,12 @@ class at: # pylint: disable=invalid-name # numpydoc ignore=PR02
404
409
  ) -> Array: # numpydoc ignore=PR01,RT01
405
410
  """Apply ``x[idx] *= y`` and return the updated array."""
406
411
  return self._op(
407
- _AtOp.MULTIPLY, operator.imul, operator.mul, y, copy=copy, xp=xp
412
+ _AtOp.MULTIPLY,
413
+ operator.imul, # pyright: ignore[reportUnknownArgumentType]
414
+ operator.mul,
415
+ y,
416
+ copy=copy,
417
+ xp=xp,
408
418
  )
409
419
 
410
420
  def divide(
@@ -416,7 +426,12 @@ class at: # pylint: disable=invalid-name # numpydoc ignore=PR02
416
426
  ) -> Array: # numpydoc ignore=PR01,RT01
417
427
  """Apply ``x[idx] /= y`` and return the updated array."""
418
428
  return self._op(
419
- _AtOp.DIVIDE, operator.itruediv, operator.truediv, y, copy=copy, xp=xp
429
+ _AtOp.DIVIDE,
430
+ operator.itruediv, # pyright: ignore[reportUnknownArgumentType]
431
+ operator.truediv, # pyright: ignore[reportUnknownArgumentType]
432
+ y,
433
+ copy=copy,
434
+ xp=xp,
420
435
  )
421
436
 
422
437
  def power(
@@ -427,7 +442,7 @@ class at: # pylint: disable=invalid-name # numpydoc ignore=PR02
427
442
  xp: ModuleType | None = None,
428
443
  ) -> Array: # numpydoc ignore=PR01,RT01
429
444
  """Apply ``x[idx] **= y`` and return the updated array."""
430
- return self._op(_AtOp.POWER, operator.ipow, operator.pow, y, copy=copy, xp=xp)
445
+ return self._op(_AtOp.POWER, operator.ipow, operator.pow, y, copy=copy, xp=xp) # pyright: ignore[reportUnknownArgumentType]
431
446
 
432
447
  def min(
433
448
  self,
@@ -47,11 +47,12 @@ class Backend(Enum): # numpydoc ignore=PR02
47
47
 
48
48
  def pytest_param(self) -> Any:
49
49
  """
50
- Backend as a pytest parameter
50
+ Backend as a pytest parameter.
51
51
 
52
52
  Returns
53
53
  -------
54
54
  pytest.mark.ParameterSet
55
+ The backend as a pytest parameter.
55
56
  """
56
57
  id_ = (
57
58
  self.name.lower().replace("_gpu", ":gpu").replace("_readonly", ":readonly")
@@ -23,6 +23,7 @@ from ._utils._helpers import (
23
23
  from ._utils._typing import Array, Device, DType
24
24
 
25
25
  __all__ = [
26
+ "angle",
26
27
  "apply_where",
27
28
  "atleast_nd",
28
29
  "broadcast_shapes",
@@ -220,46 +221,10 @@ def atleast_nd(x: Array, /, *, ndim: int, xp: ModuleType) -> Array:
220
221
 
221
222
  # `float` in signature to accept `math.nan` for Dask.
222
223
  # `int`s are still accepted as `float` is a superclass of `int` in typing
223
- def broadcast_shapes(*shapes: tuple[float | None, ...]) -> tuple[int | None, ...]:
224
- """
225
- Compute the shape of the broadcasted arrays.
226
-
227
- Duplicates :func:`numpy.broadcast_shapes`, with additional support for
228
- None and NaN sizes.
229
-
230
- This is equivalent to ``xp.broadcast_arrays(arr1, arr2, ...)[0].shape``
231
- without needing to worry about the backend potentially deep copying
232
- the arrays.
233
-
234
- Parameters
235
- ----------
236
- *shapes : tuple[int | None, ...]
237
- Shapes of the arrays to broadcast.
238
-
239
- Returns
240
- -------
241
- tuple[int | None, ...]
242
- The shape of the broadcasted arrays.
243
-
244
- See Also
245
- --------
246
- numpy.broadcast_shapes : Equivalent NumPy function.
247
- array_api.broadcast_arrays : Function to broadcast actual arrays.
248
-
249
- Notes
250
- -----
251
- This function accepts the Array API's ``None`` for unknown sizes,
252
- as well as Dask's non-standard ``math.nan``.
253
- Regardless of input, the output always contains ``None`` for unknown sizes.
254
-
255
- Examples
256
- --------
257
- >>> import array_api_extra as xpx
258
- >>> xpx.broadcast_shapes((2, 3), (2, 1))
259
- (2, 3)
260
- >>> xpx.broadcast_shapes((4, 2, 3), (2, 1), (1, 3))
261
- (4, 2, 3)
262
- """
224
+ def broadcast_shapes( # numpydoc ignore=PR01,RT01
225
+ *shapes: tuple[float | None, ...],
226
+ ) -> tuple[int | None, ...]:
227
+ """See docstring in array_api_extra._delegation."""
263
228
  if not shapes:
264
229
  return () # Match NumPy output
265
230
 
@@ -291,7 +256,7 @@ def cov(m: Array, /, *, xp: ModuleType) -> Array: # numpydoc ignore=PR01,RT01
291
256
  m = atleast_nd(m, ndim=2, xp=xp)
292
257
  m = xp.astype(m, dtype)
293
258
 
294
- avg = _helpers.mean(m, axis=-1, keepdims=True, xp=xp)
259
+ avg = xp.mean(m, axis=-1, keepdims=True)
295
260
 
296
261
  m_shape = eager_shape(m)
297
262
  fact = m_shape[-1] - 1
@@ -440,87 +405,13 @@ def isclose(
440
405
 
441
406
 
442
407
  def kron(
443
- a: Array | complex,
444
- b: Array | complex,
408
+ a: Array,
409
+ b: Array,
445
410
  /,
446
411
  *,
447
- xp: ModuleType | None = None,
448
- ) -> Array:
449
- """
450
- Kronecker product of two arrays.
451
-
452
- Computes the Kronecker product, a composite array made of blocks of the
453
- second array scaled by the first.
454
-
455
- Equivalent to ``numpy.kron`` for NumPy arrays.
456
-
457
- Parameters
458
- ----------
459
- a, b : Array | int | float | complex
460
- Input arrays or scalars. At least one must be an array.
461
- xp : array_namespace, optional
462
- The standard-compatible namespace for `a` and `b`. Default: infer.
463
-
464
- Returns
465
- -------
466
- array
467
- The Kronecker product of `a` and `b`.
468
-
469
- Notes
470
- -----
471
- The function assumes that the number of dimensions of `a` and `b`
472
- are the same, if necessary prepending the smallest with ones.
473
- If ``a.shape = (r0,r1,..,rN)`` and ``b.shape = (s0,s1,...,sN)``,
474
- the Kronecker product has shape ``(r0*s0, r1*s1, ..., rN*SN)``.
475
- The elements are products of elements from `a` and `b`, organized
476
- explicitly by::
477
-
478
- kron(a,b)[k0,k1,...,kN] = a[i0,i1,...,iN] * b[j0,j1,...,jN]
479
-
480
- where::
481
-
482
- kt = it * st + jt, t = 0,...,N
483
-
484
- In the common 2-D case (N=1), the block structure can be visualized::
485
-
486
- [[ a[0,0]*b, a[0,1]*b, ... , a[0,-1]*b ],
487
- [ ... ... ],
488
- [ a[-1,0]*b, a[-1,1]*b, ... , a[-1,-1]*b ]]
489
-
490
- Examples
491
- --------
492
- >>> import array_api_strict as xp
493
- >>> import array_api_extra as xpx
494
- >>> xpx.kron(xp.asarray([1, 10, 100]), xp.asarray([5, 6, 7]), xp=xp)
495
- Array([ 5, 6, 7, 50, 60, 70, 500,
496
- 600, 700], dtype=array_api_strict.int64)
497
-
498
- >>> xpx.kron(xp.asarray([5, 6, 7]), xp.asarray([1, 10, 100]), xp=xp)
499
- Array([ 5, 50, 500, 6, 60, 600, 7,
500
- 70, 700], dtype=array_api_strict.int64)
501
-
502
- >>> xpx.kron(xp.eye(2), xp.ones((2, 2)), xp=xp)
503
- Array([[1., 1., 0., 0.],
504
- [1., 1., 0., 0.],
505
- [0., 0., 1., 1.],
506
- [0., 0., 1., 1.]], dtype=array_api_strict.float64)
507
-
508
- >>> a = xp.reshape(xp.arange(100), (2, 5, 2, 5))
509
- >>> b = xp.reshape(xp.arange(24), (2, 3, 4))
510
- >>> c = xpx.kron(a, b, xp=xp)
511
- >>> c.shape
512
- (2, 10, 6, 20)
513
- >>> I = (1, 3, 0, 2)
514
- >>> J = (0, 2, 1)
515
- >>> J1 = (0,) + J # extend to ndim=4
516
- >>> S1 = (1,) + b.shape
517
- >>> K = tuple(xp.asarray(I) * xp.asarray(S1) + xp.asarray(J1))
518
- >>> c[K] == a[I]*b[J]
519
- Array(True, dtype=array_api_strict.bool)
520
- """
521
- if xp is None:
522
- xp = array_namespace(a, b)
523
- a, b = asarrays(a, b, xp=xp)
412
+ xp: ModuleType,
413
+ ) -> Array: # numpydoc ignore=PR01,RT01
414
+ """See docstring in array_api_extra._delegation."""
524
415
 
525
416
  singletons = (1,) * (b.ndim - a.ndim)
526
417
  a = cast(Array, xp.broadcast_to(a, singletons + a.shape))
@@ -818,3 +709,51 @@ def union1d(a: Array, b: Array, /, *, xp: ModuleType) -> Array:
818
709
  b = xp.reshape(b, (-1,))
819
710
  # XXX: `sparse` returns NumPy arrays from `unique_values`
820
711
  return xp.asarray(xp.unique_values(xp.concat([a, b])))
712
+
713
+
714
+ def angle(z: Array, /, *, deg: bool = False, xp: ModuleType | None = None) -> Array:
715
+ """
716
+ Return the angle of the complex argument.
717
+
718
+ Parameters
719
+ ----------
720
+ z : Array
721
+ Input array.
722
+ deg : bool, optional
723
+ Return angle in degrees if True, radians if False (default).
724
+ xp : array_namespace, optional
725
+ The standard-compatible namespace for `z`. Default: infer.
726
+
727
+ Returns
728
+ -------
729
+ array
730
+ The counterclockwise angle from the positive real axis on the complex
731
+ plane in the range ``(-pi, pi]``.
732
+
733
+ Notes
734
+ -----
735
+ Real input ``x`` is interpreted as ``x + 0j``.
736
+
737
+ Examples
738
+ --------
739
+ >>> import array_api_strict as xp
740
+ >>> import array_api_extra as xpx
741
+ >>> xpx.angle(xp.asarray([1.0, 1.0j, 1 + 1j]), xp=xp)
742
+ Array([0. , 1.57079633, 0.78539816], dtype=array_api_strict.float64)
743
+ >>> xpx.angle(xp.asarray([1.0, 1.0j, 1 + 1j]), deg=True, xp=xp)
744
+ Array([ 0., 90., 45.], dtype=array_api_strict.float64)
745
+ """
746
+ if xp is None:
747
+ xp = array_namespace(z)
748
+ if xp.isdtype(z.dtype, "complex floating"):
749
+ zimag = xp.imag(z)
750
+ zreal = xp.real(z)
751
+ else:
752
+ if not xp.isdtype(z.dtype, "real floating"):
753
+ z = xp.astype(z, default_dtype(xp, device=_compat.device(z)))
754
+ zimag = xp.zeros_like(z)
755
+ zreal = z
756
+ a = xp.atan2(zimag, zreal)
757
+ if deg:
758
+ a = a * 180 / xp.pi
759
+ return a
@@ -30,7 +30,7 @@ else:
30
30
  P = ParamSpec("P")
31
31
 
32
32
 
33
- @overload
33
+ @overload # pyrefly: ignore[invalid-param-spec]
34
34
  def lazy_apply( # type: ignore[valid-type]
35
35
  func: Callable[P, Array | ArrayLike],
36
36
  *args: Array | complex | None,
@@ -42,7 +42,7 @@ def lazy_apply( # type: ignore[valid-type]
42
42
  ) -> Array: ... # numpydoc ignore=GL08
43
43
 
44
44
 
45
- @overload
45
+ @overload # pyrefly: ignore[invalid-param-spec]
46
46
  def lazy_apply( # type: ignore[valid-type]
47
47
  func: Callable[P, Sequence[Array | ArrayLike]],
48
48
  *args: Array | complex | None,
@@ -54,7 +54,7 @@ def lazy_apply( # type: ignore[valid-type]
54
54
  ) -> tuple[Array, ...]: ... # numpydoc ignore=GL08
55
55
 
56
56
 
57
- def lazy_apply( # type: ignore[valid-type] # numpydoc ignore=GL07,SA04
57
+ def lazy_apply( # type: ignore[valid-type] # pyrefly: ignore[invalid-param-spec] # numpydoc ignore=GL07,SA04
58
58
  func: Callable[P, Array | ArrayLike | Sequence[Array | ArrayLike]],
59
59
  *args: Array | complex | None,
60
60
  shape: tuple[int | None, ...] | Sequence[tuple[int | None, ...]] | None = None,
@@ -240,7 +240,7 @@ def lazy_apply( # type: ignore[valid-type] # numpydoc ignore=GL07,SA04
240
240
  if is_dask_namespace(xp):
241
241
  import dask
242
242
 
243
- metas: list[Array] = [arg._meta for arg in array_args] # pylint: disable=protected-access # pyright: ignore[reportAttributeAccessIssue]
243
+ metas: list[Array] = [arg._meta for arg in array_args] # pylint: disable=protected-access # pyright: ignore[reportAttributeAccessIssue] # pyrefly: ignore[missing-attribute]
244
244
  meta_xp = array_namespace(*metas)
245
245
 
246
246
  wrapped = dask.delayed( # type: ignore[attr-defined] # pyright: ignore[reportPrivateImportUsage]