Coverage for src/dummypy/grid.py: 100%
34 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-27 05:46 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-27 05:46 +0000
1"""The :class:`Grid` value type for the dummypy analytics library.
3Responsibility split:
5- :func:`_build_grids` owns the *data-generation* concern — turning a size
6 ``n`` into the two coordinate DataFrames. It is a pure function of ``n``.
7- :class:`Grid` owns the *model* concern — validating ``n``, holding the
8 generated ``x``/``y`` frames, and exposing behaviour (:meth:`Grid.diff`).
10Keeping generation in a standalone function keeps ``Grid`` a thin, testable
11value type rather than a class that both stores and manufactures its data.
12"""
14import attrs
15import numpy as np
16import pandas as pd
19def _check_n(_instance: object, _attribute: "attrs.Attribute[int]", value: object) -> None:
20 """Reject non-integer or negative grid sizes with a clear error.
22 Args:
23 _instance: The Grid instance being validated (unused).
24 _attribute: The attrs attribute being validated (unused).
25 value: The proposed value for ``n``.
27 Raises:
28 TypeError: If ``value`` is not an integer (bool is rejected too).
29 ValueError: If ``value`` is negative.
30 """
31 # bool is a subclass of int; reject it to avoid Grid(n=True) surprises.
32 if not isinstance(value, int) or isinstance(value, bool):
33 msg = f"Grid size n must be an integer, got {type(value).__name__}"
34 raise TypeError(msg)
35 if value < 0:
36 msg = f"Grid size n must be non-negative, got {value}"
37 raise ValueError(msg)
40def _build_grids(n: int) -> tuple[pd.DataFrame, pd.DataFrame]:
41 """Build the ``(x, y)`` coordinate frames for a grid of size ``n``.
43 This is the data-generation concern, kept separate from the :class:`Grid`
44 model. ``y`` has each row equal to ``0..n``; ``x`` is its transpose. Both
45 are square with side ``n + 1`` and share string coordinate labels.
47 The frames share one backing array, which is marked read-only so that no
48 in-place write can reach it — the data :class:`Grid` holds cannot drift
49 from the ``n`` it was built from.
51 Args:
52 n: Non-negative grid size (already validated by the caller).
54 Returns:
55 An ``(x, y)`` tuple of DataFrames, where ``x == y.T``.
56 """
57 nn = np.arange(n + 1)
58 cols = [str(i) for i in nn]
59 data = np.tile(nn, (n + 1, 1))
60 data.setflags(write=False)
61 y = pd.DataFrame(data, index=pd.Index(cols), columns=pd.Index(cols))
62 return y.T, y
65@attrs.frozen
66class Grid:
67 """A grid representing data points for analytics calculations.
69 Holds two coordinate DataFrames, ``x`` and ``y`` (with ``x == y.T``),
70 generated from the grid size ``n`` by :func:`_build_grids`.
72 Instances are **immutable**. ``x`` and ``y`` are derived from ``n``, so
73 letting any of the three be reassigned would break the invariants the
74 validator and :func:`_build_grids` establish at construction: a new ``x``
75 need not be ``y.T``, and a new ``n`` would not rebuild the frames it is
76 supposed to describe. Build a new :class:`Grid` instead.
78 The frames are protected from in-place edits too. ``x`` and ``y`` hand out
79 a fresh, lazily-copied view of read-only data on every access, so a write
80 such as ``grid.x.iloc[0, 1] = 99`` can never corrupt the grid: pandas 2
81 rejects it with ``ValueError``, and pandas 3's copy-on-write confines it to
82 the copy that was handed out. Equality and hashing use ``n`` alone, which
83 is sound because both frames are derived from it.
85 Args:
86 n: Maximum size for the grid (default: 10). Must be a non-negative
87 integer.
89 Raises:
90 TypeError: If ``n`` is not an integer (e.g. a float or a bool).
91 ValueError: If ``n`` is negative.
93 Examples:
94 Both frames are square with side ``n + 1``, and ``x`` is the transpose
95 of ``y``:
97 >>> grid = Grid(n=3)
98 >>> grid.x.shape
99 (4, 4)
100 >>> bool((grid.x == grid.y.T).all().all())
101 True
103 Only ``n`` is part of the repr, equality and hash, since ``x`` and
104 ``y`` are derived:
106 >>> grid
107 Grid(n=3)
108 >>> Grid(n=3) == grid, Grid(n=4) == grid
109 (True, False)
110 >>> len({Grid(n=3), Grid(n=3)})
111 1
113 A negative size is rejected, and so is a float or a bool:
115 >>> Grid(n=-1)
116 Traceback (most recent call last):
117 ...
118 ValueError: Grid size n must be non-negative, got -1
120 >>> Grid(n=2.0)
121 Traceback (most recent call last):
122 ...
123 TypeError: Grid size n must be an integer, got float
125 Instances are immutable — build a new grid rather than reassigning:
127 >>> grid.n = 5 # doctest: +IGNORE_EXCEPTION_DETAIL
128 Traceback (most recent call last):
129 ...
130 attr.exceptions.FrozenInstanceError
131 """
133 n: int = attrs.field(init=True, repr=True, default=10, validator=_check_n)
134 _x: pd.DataFrame = attrs.field(repr=False, init=False, eq=False)
135 _y: pd.DataFrame = attrs.field(repr=False, init=False, eq=False)
137 def __attrs_post_init__(self) -> None:
138 """Populate the x and y coordinate frames from ``n``.
140 Uses :func:`object.__setattr__` because the class is frozen — the
141 standard attrs idiom for a derived attribute on an immutable class.
142 Keeping the single :func:`_build_grids` call here preserves the
143 generation-vs-model split described in the module docstring.
144 """
145 x, y = _build_grids(self.n)
146 object.__setattr__(self, "_x", x)
147 object.__setattr__(self, "_y", y)
149 @property
150 def x(self) -> pd.DataFrame:
151 """The x-coordinate frame, ``y`` transposed.
153 Returns:
154 A shallow copy of the grid's read-only frame. The copy is cheap
155 (no data is duplicated), and writing to it cannot affect the grid.
156 """
157 return self._x.copy(deep=False)
159 @property
160 def y(self) -> pd.DataFrame:
161 """The y-coordinate frame, each row equal to ``0..n``.
163 Returns:
164 A shallow copy of the grid's read-only frame. The copy is cheap
165 (no data is duplicated), and writing to it cannot affect the grid.
166 """
167 return self._y.copy(deep=False)
169 def diff(self) -> pd.DataFrame:
170 """Returns a grid of differences.
172 Returns:
173 A fresh DataFrame of element-wise differences (x - y), computed
174 anew on each call.
176 Examples:
177 The value at ``(i, j)`` is ``i - j``, so the frame is antisymmetric
178 and its diagonal is zero:
180 >>> grid = Grid(n=3)
181 >>> grid.diff().loc["2", "1"]
182 np.int64(1)
183 >>> grid.diff().loc["1", "2"]
184 np.int64(-1)
186 Each call returns a fresh frame, so mutating one cannot corrupt the
187 grid it came from:
189 >>> grid.diff() is grid.diff()
190 False
191 """
192 return self._x - self._y