Coverage for src/seqrule/dsl.py: 58%

40 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-02-27 10:56 -0600

1""" 

2DSL layer for building sequence rules. 

3 

4This module provides a high-level DSL for constructing sequence rules, 

5including combinators and common rule patterns. 

6""" 

7 

8from typing import TypeVar, Union 

9 

10from .core import FormalRule, FormalRuleProtocol, Sequence 

11from .types import PredicateFunction 

12 

13T = TypeVar("T") 

14 

15 

16class DSLRule: 

17 """ 

18 DSLRule wraps a formal rule with a human-readable description. 

19 

20 This DSL layer allows domain experts to build high-level rules that are automatically 

21 translated into the underlying formal model. 

22 

23 Attributes: 

24 func: The underlying formal rule function 

25 description: Human-readable description of the rule 

26 _original_func: The original unwrapped function for inspection 

27 

28 Examples: 

29 >>> def ascending_values(seq): 

30 ... return all(seq[i]["value"] < seq[i+1]["value"] for i in range(len(seq)-1)) 

31 ... 

32 >>> rule = DSLRule(ascending_values, "values must be ascending") 

33 >>> rule(sequence) # Returns True if values ascend, False otherwise 

34 """ 

35 

36 def __init__( 

37 self, func: Union[FormalRule, FormalRuleProtocol], description: str = "" 

38 ): 

39 """ 

40 Initialize a DSL rule with a function and description. 

41 

42 Args: 

43 func: The rule function that takes a sequence and returns a boolean 

44 description: Human-readable description of the rule's purpose 

45 """ 

46 self.func = func 

47 self.description = description 

48 # Store the original function for inspection 

49 if hasattr(func, "__wrapped__"): 

50 self._original_func = func.__wrapped__ 

51 else: 

52 self._original_func = func 

53 

54 def __call__(self, seq: Sequence) -> bool: 

55 """ 

56 Apply the rule to a sequence. 

57 

58 Args: 

59 seq: The sequence to evaluate 

60 

61 Returns: 

62 bool: True if the sequence satisfies the rule, False otherwise 

63 """ 

64 return self.func(seq) 

65 

66 def __and__(self, other: "DSLRule") -> "DSLRule": 

67 """ 

68 Combine two rules with a logical AND operation. 

69 

70 Args: 

71 other: Another DSL rule to combine with this one 

72 

73 Returns: 

74 DSLRule: A new rule that requires both rules to be satisfied 

75 """ 

76 return DSLRule( 

77 lambda seq: self(seq) and other(seq), 

78 f"({self.description} AND {other.description})", 

79 ) 

80 

81 def __or__(self, other: "DSLRule") -> "DSLRule": 

82 """ 

83 Combine two rules with a logical OR operation. 

84 

85 Args: 

86 other: Another DSL rule to combine with this one 

87 

88 Returns: 

89 DSLRule: A new rule that requires either rule to be satisfied 

90 """ 

91 return DSLRule( 

92 lambda seq: self(seq) or other(seq), 

93 f"({self.description} OR {other.description})", 

94 ) 

95 

96 def __invert__(self) -> "DSLRule": 

97 """ 

98 Negate a rule with a logical NOT operation. 

99 

100 Returns: 

101 DSLRule: A new rule that is satisfied when this rule is not 

102 """ 

103 return DSLRule(lambda seq: not self(seq), f"(NOT {self.description})") 

104 

105 def __repr__(self) -> str: 

106 """ 

107 String representation of the rule. 

108 

109 Returns: 

110 str: A string showing the rule description 

111 """ 

112 return f"DSLRule({self.description})" 

113 

114 def __get_original_func__(self): 

115 """ 

116 Return the original function for inspection. 

117 

118 Returns: 

119 Callable: The original unwrapped function 

120 """ 

121 return self._original_func 

122 

123 

124def if_then_rule( 

125 condition: PredicateFunction, consequence: PredicateFunction 

126) -> DSLRule: 

127 """ 

128 Constructs a DSLRule that applies to every adjacent pair in a sequence. 

129 

130 If an object satisfies 'condition', then its immediate successor must satisfy 'consequence'. 

131 

132 Args: 

133 condition: Predicate that must be satisfied by the first object 

134 consequence: Predicate that must be satisfied by the second object 

135 

136 Returns: 

137 DSLRule: A rule enforcing the if-then relationship 

138 

139 Examples: 

140 >>> # If an object is red, then the next object must have value > 5 

141 >>> rule = if_then_rule( 

142 ... lambda obj: obj["color"] == "red", 

143 ... lambda obj: obj["value"] > 5 

144 ... ) 

145 """ 

146 desc = "if [condition] then [consequence]" 

147 

148 def rule(seq: Sequence) -> bool: 

149 for i in range(len(seq) - 1): 

150 if condition(seq[i]) and not consequence(seq[i + 1]): 

151 return False 

152 return True 

153 

154 return DSLRule(rule, desc) 

155 

156 

157def check_range( 

158 seq: Sequence, start: int, length: int, condition: PredicateFunction 

159) -> bool: 

160 """ 

161 Checks if a slice of the sequence satisfies a condition. 

162 

163 Args: 

164 seq: The sequence to check 

165 start: Starting index 

166 length: Length of the slice 

167 condition: Predicate that must be satisfied by each object 

168 

169 Returns: 

170 bool: True if the slice satisfies the condition 

171 """ 

172 if len(seq) < start + length: 

173 return False 

174 return all(condition(seq[i]) for i in range(start, start + length)) 

175 

176 

177def range_rule(start: int, length: int, condition: PredicateFunction) -> DSLRule: 

178 """ 

179 Constructs a DSLRule requiring elements in an index range satisfy a condition. 

180 

181 Args: 

182 start: Starting index 

183 length: Length of the range 

184 condition: Predicate that must be satisfied by each object 

185 

186 Returns: 

187 DSLRule: A rule enforcing the condition over the range 

188 

189 Examples: 

190 >>> # Require the first three elements to have even values 

191 >>> rule = range_rule(0, 3, lambda obj: obj["value"] % 2 == 0) 

192 """ 

193 desc = f"elements[{start}:{start+length}] satisfy condition" 

194 return DSLRule(lambda seq: check_range(seq, start, length, condition), desc) 

195 

196 

197def and_atomic(*conditions: PredicateFunction) -> PredicateFunction: 

198 """ 

199 Combines multiple atomic predicates into one using logical AND. 

200 

201 Args: 

202 *conditions: Variable number of predicates to combine 

203 

204 Returns: 

205 Callable: A new predicate that is the conjunction of all inputs 

206 

207 Examples: 

208 >>> # Object must be red AND have value > 5 

209 >>> predicate = and_atomic( 

210 ... lambda obj: obj["color"] == "red", 

211 ... lambda obj: obj["value"] > 5 

212 ... ) 

213 """ 

214 return lambda obj: all(cond(obj) for cond in conditions)