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

1"""The :class:`Grid` value type for the dummypy analytics library. 

2 

3Responsibility split: 

4 

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`). 

9 

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""" 

13 

14import attrs 

15import numpy as np 

16import pandas as pd 

17 

18 

19def _check_n(_instance: object, _attribute: "attrs.Attribute[int]", value: object) -> None: 

20 """Reject non-integer or negative grid sizes with a clear error. 

21 

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``. 

26 

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) 

38 

39 

40def _build_grids(n: int) -> tuple[pd.DataFrame, pd.DataFrame]: 

41 """Build the ``(x, y)`` coordinate frames for a grid of size ``n``. 

42 

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. 

46 

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. 

50 

51 Args: 

52 n: Non-negative grid size (already validated by the caller). 

53 

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 

63 

64 

65@attrs.frozen 

66class Grid: 

67 """A grid representing data points for analytics calculations. 

68 

69 Holds two coordinate DataFrames, ``x`` and ``y`` (with ``x == y.T``), 

70 generated from the grid size ``n`` by :func:`_build_grids`. 

71 

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. 

77 

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. 

84 

85 Args: 

86 n: Maximum size for the grid (default: 10). Must be a non-negative 

87 integer. 

88 

89 Raises: 

90 TypeError: If ``n`` is not an integer (e.g. a float or a bool). 

91 ValueError: If ``n`` is negative. 

92 

93 Examples: 

94 Both frames are square with side ``n + 1``, and ``x`` is the transpose 

95 of ``y``: 

96 

97 >>> grid = Grid(n=3) 

98 >>> grid.x.shape 

99 (4, 4) 

100 >>> bool((grid.x == grid.y.T).all().all()) 

101 True 

102 

103 Only ``n`` is part of the repr, equality and hash, since ``x`` and 

104 ``y`` are derived: 

105 

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 

112 

113 A negative size is rejected, and so is a float or a bool: 

114 

115 >>> Grid(n=-1) 

116 Traceback (most recent call last): 

117 ... 

118 ValueError: Grid size n must be non-negative, got -1 

119 

120 >>> Grid(n=2.0) 

121 Traceback (most recent call last): 

122 ... 

123 TypeError: Grid size n must be an integer, got float 

124 

125 Instances are immutable — build a new grid rather than reassigning: 

126 

127 >>> grid.n = 5 # doctest: +IGNORE_EXCEPTION_DETAIL 

128 Traceback (most recent call last): 

129 ... 

130 attr.exceptions.FrozenInstanceError 

131 """ 

132 

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) 

136 

137 def __attrs_post_init__(self) -> None: 

138 """Populate the x and y coordinate frames from ``n``. 

139 

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) 

148 

149 @property 

150 def x(self) -> pd.DataFrame: 

151 """The x-coordinate frame, ``y`` transposed. 

152 

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) 

158 

159 @property 

160 def y(self) -> pd.DataFrame: 

161 """The y-coordinate frame, each row equal to ``0..n``. 

162 

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) 

168 

169 def diff(self) -> pd.DataFrame: 

170 """Returns a grid of differences. 

171 

172 Returns: 

173 A fresh DataFrame of element-wise differences (x - y), computed 

174 anew on each call. 

175 

176 Examples: 

177 The value at ``(i, j)`` is ``i - j``, so the frame is antisymmetric 

178 and its diagonal is zero: 

179 

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) 

185 

186 Each call returns a fresh frame, so mutating one cannot corrupt the 

187 grid it came from: 

188 

189 >>> grid.diff() is grid.diff() 

190 False 

191 """ 

192 return self._x - self._y