Coverage for src/dummypy/grid.py: 100%

27 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-02 20:30 +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 Args: 

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

49 

50 Returns: 

51 An ``(x, y)`` tuple of DataFrames, where ``x == y.T``. 

52 """ 

53 nn = np.arange(n + 1) 

54 cols = [str(i) for i in nn] 

55 data = np.tile(nn, (n + 1, 1)) 

56 y = pd.DataFrame(data, index=pd.Index(cols), columns=pd.Index(cols)) 

57 return y.T, y 

58 

59 

60@attrs.frozen 

61class Grid: 

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

63 

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

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

66 

67 Instances are **immutable**. ``x`` and ``y`` are derived from ``n``, so 

68 letting any of the three be reassigned would break the invariants the 

69 validator and :func:`_build_grids` establish at construction: a new ``x`` 

70 need not be ``y.T``, and a new ``n`` would not rebuild the frames it is 

71 supposed to describe. Build a new :class:`Grid` instead. 

72 

73 Args: 

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

75 integer. 

76 

77 Raises: 

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

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

80 

81 Examples: 

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

83 of ``y``: 

84 

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

86 >>> grid.x.shape 

87 (4, 4) 

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

89 True 

90 

91 Only ``n`` is part of the repr, since ``x`` and ``y`` are derived: 

92 

93 >>> grid 

94 Grid(n=3) 

95 

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

97 

98 >>> Grid(n=-1) 

99 Traceback (most recent call last): 

100 ... 

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

102 

103 >>> Grid(n=2.0) 

104 Traceback (most recent call last): 

105 ... 

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

107 

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

109 

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

111 Traceback (most recent call last): 

112 ... 

113 attr.exceptions.FrozenInstanceError 

114 """ 

115 

116 n: int = attrs.field(init=True, repr=True, default=10, validator=_check_n) 

117 x: pd.DataFrame = attrs.field(repr=False, init=False) 

118 y: pd.DataFrame = attrs.field(repr=False, init=False) 

119 

120 def __attrs_post_init__(self) -> None: 

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

122 

123 Uses :func:`object.__setattr__` because the class is frozen — the 

124 standard attrs idiom for a derived attribute on an immutable class. 

125 Keeping the single :func:`_build_grids` call here preserves the 

126 generation-vs-model split described in the module docstring. 

127 """ 

128 x, y = _build_grids(self.n) 

129 object.__setattr__(self, "x", x) 

130 object.__setattr__(self, "y", y) 

131 

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

133 """Returns a grid of differences. 

134 

135 Returns: 

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

137 anew on each call. 

138 

139 Examples: 

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

141 and its diagonal is zero: 

142 

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

144 >>> grid.diff().loc["2", "1"] 

145 np.int64(1) 

146 >>> grid.diff().loc["1", "2"] 

147 np.int64(-1) 

148 

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

150 grid it came from: 

151 

152 >>> grid.diff() is grid.diff() 

153 False 

154 """ 

155 return self.x - self.y