python-constraint2 2.7.0__cp314-cp314-win_arm64.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.
constraint/parser.py ADDED
@@ -0,0 +1,431 @@
1
+ """Module containing the code for parsing string constraints."""
2
+
3
+ import re
4
+ from types import FunctionType
5
+
6
+ from constraint.constraints import (
7
+ AllDifferentConstraint,
8
+ AllEqualConstraint,
9
+ CompilableFunctionConstraint,
10
+ Constraint,
11
+ ExactProdConstraint,
12
+ ExactSumConstraint,
13
+ FunctionConstraint,
14
+ MaxProdConstraint,
15
+ MaxSumConstraint,
16
+ MinProdConstraint,
17
+ MinSumConstraint,
18
+ VariableExactProdConstraint,
19
+ VariableExactSumConstraint,
20
+ VariableMaxProdConstraint,
21
+ # TODO implement parsing for these constraints:
22
+ # InSetConstraint,
23
+ # NotInSetConstraint,
24
+ # SomeInSetConstraint,
25
+ # SomeNotInSetConstraint,
26
+ VariableMaxSumConstraint,
27
+ VariableMinProdConstraint,
28
+ VariableMinSumConstraint,
29
+ )
30
+
31
+
32
+ def parse_restrictions(restrictions: list[str], tune_params: dict) -> list[tuple[Constraint | str, list[str]]]:
33
+ """Parses restrictions (constraints in string format) from a list of strings into compilable functions and constraints. Returns a list of tuples of (strings or constraints) and parameters.""" # noqa: E501
34
+ # rewrite the restrictions so variables are singled out
35
+ regex_match_variable = r"([a-zA-Z_$][a-zA-Z_$0-9]*)"
36
+ regex_match_variable_or_constant = r"([a-zA-Z_$0-9]*)"
37
+
38
+ def replace_params(match_object):
39
+ key = match_object.group(1)
40
+ if key in tune_params:
41
+ param = str(key)
42
+ return "params[params_index['" + param + "']]"
43
+ else:
44
+ return key
45
+
46
+ def replace_params_split(match_object):
47
+ # careful: has side-effect of adding to set `params_used`
48
+ key = match_object.group(1)
49
+ if key in tune_params:
50
+ param = str(key)
51
+ params_used.add(param)
52
+ return param
53
+ else:
54
+ return key
55
+
56
+ def to_multiple_restrictions(restrictions: list[str]) -> list[str]:
57
+ """Split the restrictions into multiple restriction where possible (e.g. 3 <= x * y < 9 <= z -> [(MinProd(3), [x, y]), (MaxProd(9-1), [x, y]), (MinProd(9), [z])]).""" # noqa: E501
58
+ split_restrictions = list()
59
+ for res in restrictions:
60
+ # if there are logic chains in the restriction, skip splitting further
61
+ if " and " in res or " or " in res:
62
+ split_restrictions.append(res)
63
+ continue
64
+ # find the indices of splittable comparators
65
+ comparators = ["<=", ">=", ">", "<"]
66
+ comparators_indices = [(m.start(0), m.end(0)) for m in re.finditer("|".join(comparators), res)]
67
+ if len(comparators_indices) <= 1:
68
+ # this can't be split further
69
+ split_restrictions.append(res)
70
+ continue
71
+ # split the restrictions from the previous to the next comparator
72
+ for index in range(len(comparators_indices)):
73
+ temp_copy = res
74
+ prev_stop = comparators_indices[index - 1][1] + 1 if index > 0 else 0
75
+ next_stop = (
76
+ comparators_indices[index + 1][0] if index < len(comparators_indices) - 1 else len(temp_copy)
77
+ )
78
+ split_restrictions.append(temp_copy[prev_stop:next_stop].strip())
79
+ return split_restrictions
80
+
81
+ def to_numeric_constraint(restriction: str, params: list[str]) -> MinSumConstraint | VariableMinSumConstraint | ExactSumConstraint | VariableExactSumConstraint | MaxSumConstraint | VariableMaxSumConstraint | MinProdConstraint | VariableMinProdConstraint | ExactProdConstraint | VariableExactProdConstraint | MaxProdConstraint | VariableMaxProdConstraint | None: # noqa: E501
82
+ """Converts a restriction to a built-in numeric constraint if possible."""
83
+ # first check if all parameters have only numbers as values
84
+ if len(params) == 0 or not all(all(isinstance(v, (int, float)) for v in tune_params[p]) for p in params):
85
+ return None
86
+
87
+ comparators = ["<=", "==", ">=", ">", "<"]
88
+ comparators_found = re.findall("|".join(comparators), restriction)
89
+ # check if there is exactly one comparator, if not, return None
90
+ if len(comparators_found) != 1:
91
+ return None
92
+ comparator = comparators_found[0]
93
+
94
+ # split the string on the comparison and remove leading and trailing whitespace
95
+ left, right = tuple(s.strip() for s in restriction.split(comparator))
96
+
97
+ # if we have an inverse operation, rewrite to the other side
98
+ operators_left = extract_operators(left)
99
+ operators_right = extract_operators(right)
100
+ if len(operators_left) > 0 and len(operators_right) > 0:
101
+ # if there are operators on both sides, we can't handle this yet
102
+ return None
103
+ unique_operators_left = set(operators_left)
104
+ unique_operators_right = set(operators_right)
105
+ unique_operators = unique_operators_left.union(unique_operators_right)
106
+ if len(unique_operators) == 1:
107
+ variables_on_left = len(unique_operators_left) > 0
108
+ swapped_side_first_component = re.search(
109
+ regex_match_variable_or_constant, left if variables_on_left else right
110
+ ) # noqa: E501
111
+ if swapped_side_first_component is None:
112
+ # if there is no variable on the left side, we can't handle this yet
113
+ return None
114
+ else:
115
+ swapped_side_first_component = swapped_side_first_component.group(0)
116
+ if "-" in unique_operators:
117
+ if not variables_on_left:
118
+ # e.g. "G == B-M" becomes "G+M == B"
119
+ right_remainder = right[len(swapped_side_first_component) :]
120
+ left_swap = right_remainder.replace("-", "+")
121
+ restriction = f"{left}{left_swap}{comparator}{swapped_side_first_component}"
122
+ else:
123
+ # e.g. "B-M == G" becomes "B == G+M"
124
+ left_remainder = left[len(swapped_side_first_component) :]
125
+ right_swap = left_remainder.replace("-", "+")
126
+ restriction = f"{swapped_side_first_component}{comparator}{right}{right_swap}"
127
+ if "/" in unique_operators:
128
+ if not variables_on_left:
129
+ # e.g. "G == B/M" becomes "G*M == B"
130
+ right_remainder = right[len(swapped_side_first_component) :]
131
+ left_swap = right_remainder.replace("/", "*")
132
+ restriction = f"{left}{left_swap}{comparator}{swapped_side_first_component}"
133
+ else:
134
+ # e.g. "B/M == G" becomes "B == G*M"
135
+ left_remainder = left[len(swapped_side_first_component) :]
136
+ right_swap = left_remainder.replace("/", "*")
137
+ restriction = f"{swapped_side_first_component}{comparator}{right}{right_swap}"
138
+
139
+ # we have a potentially rewritten restriction, split again
140
+ left, right = tuple(s.strip() for s in restriction.split(comparator))
141
+ operators_left = extract_operators(left)
142
+ operators_right = extract_operators(right)
143
+ unique_operators_left = set(operators_left)
144
+ unique_operators_right = set(operators_right)
145
+ unique_operators = unique_operators_left.union(unique_operators_right)
146
+
147
+ # find out which side is the constant number
148
+ # either the left or right side of the equation must evaluate to a constant number, otherwise we use a VariableConstraint # noqa: E501
149
+ left_num = is_or_evals_to_number(left)
150
+ right_num = is_or_evals_to_number(right)
151
+ if (left_num is None and right_num is None) or (left_num is not None and right_num is not None):
152
+ # if both sides are parameters, try to use the VariableConstraints
153
+ variable_supported_operators = ["+", "*"]
154
+ # variables = [s.strip() for s in list(left + right) if s not in variable_supported_operators]
155
+ variables = re.findall(regex_match_variable, restriction)
156
+
157
+ # if the restriction contains more than the variables and supported operators, return None
158
+ if len(variables) == 0:
159
+ return None
160
+ if any(var.strip() not in tune_params for var in variables):
161
+ raise ValueError(f"Variables {variables} not in tune_params {tune_params.keys()}")
162
+ if (
163
+ len(re.findall(r"[+-]?\d+", restriction)) > 0
164
+ ): # TODO adjust when we support modifiers such as multipliers (see roadmap) # noqa: E501
165
+ # if the restriction contains numbers, return None
166
+ return None
167
+
168
+ # find all unique variable_supported_operators in the restriction, can have at most one
169
+ variable_operators_left = list(s.strip() for s in list(left) if s in variable_supported_operators)
170
+ variable_operators_right = list(s.strip() for s in list(right) if s in variable_supported_operators)
171
+ variable_unique_operators = list(set(variable_operators_left).union(set(variable_operators_right)))
172
+ # if there is a mix of operators (e.g. 'x + y * z == a') or multiple variables on both sides, return None
173
+ if (
174
+ len(variable_unique_operators) <= 1
175
+ and all(s.strip() in params for s in variables)
176
+ and (len(unique_operators_left) == 0 or len(unique_operators_right) == 0)
177
+ ): # noqa: E501
178
+ variables_on_left = len(unique_operators_left) > 0
179
+ if len(variable_unique_operators) == 0 or variable_unique_operators[0] == "+":
180
+ if comparator == "==":
181
+ return (
182
+ VariableExactSumConstraint(variables[-1], variables[:-1])
183
+ if variables_on_left
184
+ else VariableExactSumConstraint(variables[0], variables[1:])
185
+ ) # noqa: E501
186
+ elif comparator == "<=":
187
+ # "B+C <= A" (maxsum) if variables_on_left else "A <= B+C" (minsum)
188
+ return (
189
+ VariableMaxSumConstraint(variables[-1], variables[:-1])
190
+ if variables_on_left
191
+ else VariableMinSumConstraint(variables[0], variables[1:])
192
+ ) # noqa: E501
193
+ elif comparator == ">=":
194
+ # "B+C >= A" (minsum) if variables_on_left else "A >= B+C" (maxsum)
195
+ return (
196
+ VariableMinSumConstraint(variables[-1], variables[:-1])
197
+ if variables_on_left
198
+ else VariableMaxSumConstraint(variables[0], variables[1:])
199
+ ) # noqa: E501
200
+ elif variable_unique_operators[0] == "*":
201
+ if comparator == "==":
202
+ return (
203
+ VariableExactProdConstraint(variables[-1], variables[:-1])
204
+ if variables_on_left
205
+ else VariableExactProdConstraint(variables[0], variables[1:])
206
+ ) # noqa: E501
207
+ elif comparator == "<=":
208
+ # "B*C <= A" (maxprod) if variables_on_left else "A <= B*C" (minprod)
209
+ return (
210
+ VariableMaxProdConstraint(variables[-1], variables[:-1])
211
+ if variables_on_left
212
+ else VariableMinProdConstraint(variables[0], variables[1:])
213
+ ) # noqa: E501
214
+ elif comparator == ">=":
215
+ # "B*C >= A" (minprod) if variables_on_left else "A >= B*C" (maxprod)
216
+ return (
217
+ VariableMinProdConstraint(variables[-1], variables[:-1])
218
+ if variables_on_left
219
+ else VariableMaxProdConstraint(variables[0], variables[1:])
220
+ ) # noqa: E501
221
+
222
+ # left_num and right_num can't both be constants, or for other reasons we can't use a VariableConstraint
223
+ return None
224
+
225
+ # if one side is a number, the other side must be a variable or expression
226
+ number, variables, variables_on_left = (
227
+ (left_num, right.strip(), False) if left_num is not None else (right_num, left.strip(), True)
228
+ )
229
+
230
+ # we can map '>' to '>=' and '<' to '<=' by adding a tiny offset to the number
231
+ offset = 1e-12
232
+ if comparator == "<":
233
+ if variables_on_left:
234
+ # (x < 2) == (x <= 2-offset)
235
+ number -= offset
236
+ else:
237
+ # (2 < x) == (2+offset <= x)
238
+ number += offset
239
+ elif comparator == ">":
240
+ if variables_on_left:
241
+ # (x > 2) == (x >= 2+offset)
242
+ number += offset
243
+ else:
244
+ # (2 > x) == (2-offset >= x)
245
+ number -= offset
246
+
247
+ # check if an operator is applied on the variables, if not return
248
+ operators = [r"\*\*", r"\*", r"\+"]
249
+ operators_found = re.findall(str("|".join(operators)), variables)
250
+ if len(operators_found) == 0:
251
+ # no operators found, return only based on comparator
252
+ if len(params) != 1 or variables not in params:
253
+ # there were more than one variable but no operator
254
+ return None
255
+ # map to a Constraint
256
+ # if there are restrictions with a single variable, it will be used to prune the domain at the start
257
+ elif comparator == "==":
258
+ return ExactSumConstraint(number)
259
+ elif comparator == "<=" or comparator == "<":
260
+ return MaxSumConstraint(number) if variables_on_left else MinSumConstraint(number)
261
+ elif comparator == ">=" or comparator == ">":
262
+ return MinSumConstraint(number) if variables_on_left else MaxSumConstraint(number)
263
+ raise ValueError(f"Invalid comparator {comparator}")
264
+
265
+ # check which operator is applied on the variables
266
+ operator = operators_found[0]
267
+ if not all(o == operator for o in operators_found):
268
+ # if there is a mix of operators (e.g. 'x + y * z == 3'), return None
269
+ return None
270
+
271
+ # split the string on the comparison
272
+ splitted = variables.split(operator)
273
+ # check if there are only pure, non-recurring variables (no operations or constants) in the restriction
274
+ if len(splitted) == len(params) and all(s.strip() in params for s in splitted):
275
+ # map to a Constraint
276
+ if operator == "**":
277
+ # power operations are not (yet) supported, added to avoid matching the double asterisk
278
+ return None
279
+ elif operator == "*":
280
+ if comparator == "==":
281
+ return ExactProdConstraint(number)
282
+ elif comparator == "<=" or comparator == "<":
283
+ return MaxProdConstraint(number) if variables_on_left else MinProdConstraint(number)
284
+ elif comparator == ">=" or comparator == ">":
285
+ return MinProdConstraint(number) if variables_on_left else MaxProdConstraint(number)
286
+ elif operator == "+":
287
+ if comparator == "==":
288
+ return ExactSumConstraint(number)
289
+ elif comparator == "<=" or comparator == "<":
290
+ # raise ValueError(restriction, comparator)
291
+ return MaxSumConstraint(number) if variables_on_left else MinSumConstraint(number)
292
+ elif comparator == ">=" or comparator == ">":
293
+ return MinSumConstraint(number) if variables_on_left else MaxSumConstraint(number)
294
+ else:
295
+ raise ValueError(f"Invalid operator {operator}")
296
+ return None
297
+
298
+ def to_equality_constraint(
299
+ restriction: str, params: list[str]
300
+ ) -> AllEqualConstraint | AllDifferentConstraint | None:
301
+ """Converts a restriction to either an equality or inequality constraint on all the parameters if possible."""
302
+ # check if all parameters are involved
303
+ if len(params) != len(tune_params):
304
+ return None
305
+
306
+ # find whether (in)equalities appear in this restriction
307
+ equalities_found = re.findall("==", restriction)
308
+ inequalities_found = re.findall("!=", restriction)
309
+ # check if one of the two have been found, if none or both have been found, return None
310
+ if not (bool(len(equalities_found) > 0) ^ bool(len(inequalities_found) > 0)):
311
+ return None
312
+ comparator = equalities_found[0] if len(equalities_found) > 0 else inequalities_found[0]
313
+
314
+ # split the string on the comparison
315
+ splitted = restriction.split(comparator)
316
+ # check if there are only pure, non-recurring variables (no operations or constants) in the restriction
317
+ if len(splitted) == len(params) and all(s.strip() in params for s in splitted):
318
+ # map to a Constraint
319
+ if comparator == "==":
320
+ return AllEqualConstraint()
321
+ elif comparator == "!=":
322
+ return AllDifferentConstraint()
323
+ return ValueError(f"Not possible: comparator should be '==' or '!=', is {comparator}")
324
+ return None
325
+
326
+ # remove functionally duplicate restrictions (preserves order and whitespace)
327
+ if all(isinstance(r, str) for r in restrictions):
328
+ # clean the restriction strings to functional equivalence
329
+ restrictions_cleaned = [r.replace(" ", "") for r in restrictions]
330
+ restrictions_cleaned_unique = list(dict.fromkeys(restrictions_cleaned)) # dict preserves order
331
+ # get the indices of the unique restrictions, use these to build a new list of restrictions
332
+ restrictions_unique_indices = [restrictions_cleaned.index(r) for r in restrictions_cleaned_unique]
333
+ restrictions = [restrictions[i] for i in restrictions_unique_indices]
334
+
335
+ # create the parsed restrictions, split into multiple restrictions where possible
336
+ restrictions = to_multiple_restrictions(restrictions)
337
+ # split into functions that only take their relevant parameters
338
+ parsed_restrictions = list()
339
+ for res in restrictions:
340
+ params_used: set[str] = set()
341
+ parsed_restriction = re.sub(regex_match_variable, replace_params_split, res).strip()
342
+ params_used_list = list(params_used)
343
+ finalized_constraint = None
344
+ if " or " not in res and " and " not in res:
345
+ # if applicable, strip the outermost round brackets
346
+ while (
347
+ parsed_restriction[0] == "("
348
+ and parsed_restriction[-1] == ")"
349
+ and "(" not in parsed_restriction[1:]
350
+ and ")" not in parsed_restriction[:1]
351
+ ):
352
+ parsed_restriction = parsed_restriction[1:-1]
353
+ # check if we can turn this into the built-in numeric comparison constraint
354
+ finalized_constraint = to_numeric_constraint(parsed_restriction, params_used_list)
355
+ if finalized_constraint is None:
356
+ # check if we can turn this into the built-in equality comparison constraint
357
+ finalized_constraint = to_equality_constraint(parsed_restriction, params_used_list)
358
+ if finalized_constraint is None:
359
+ # we must turn it into a general function
360
+ finalized_constraint = f"def r({', '.join(params_used_list)}): return {parsed_restriction} \n"
361
+ parsed_restrictions.append((finalized_constraint, params_used_list))
362
+
363
+ return parsed_restrictions
364
+
365
+
366
+ def compile_to_constraints(
367
+ constraints: list[str], domains: dict, picklable=False
368
+ ) -> list[tuple[Constraint, list[str], str | None]]: # noqa: E501
369
+ """Parses constraints in string format (referred to as restrictions) from a list of strings into a list of Constraints, parameters used, and source if applicable.
370
+
371
+ Args:
372
+ constraints (list[str]): list of constraints in string format to compile.
373
+ domains (dict): the domains to use.
374
+ picklable (bool, optional): whether to keep constraints such that they can be pickled for parallel solvers. Defaults to False.
375
+
376
+ Returns:
377
+ list of tuples with restrictions, parameters used (list[str]), and source (str) if applicable. Returned restrictions are strings, functions, or Constraints depending on the options provided.
378
+ """ # noqa: E501
379
+ parsed_restrictions = parse_restrictions(constraints, domains)
380
+ compiled_constraints: list[tuple[Constraint, list[str], str | None]] = list()
381
+ for restriction, params_used in parsed_restrictions:
382
+ if isinstance(restriction, str):
383
+ # if it's a string, wrap it in a (compilable or compiled) function constraint
384
+ if picklable:
385
+ constraint = CompilableFunctionConstraint(restriction)
386
+ else:
387
+ code_object = compile(restriction, "<string>", "exec")
388
+ func = FunctionType(code_object.co_consts[0], globals())
389
+ constraint = FunctionConstraint(func)
390
+ compiled_constraints.append((constraint, params_used, restriction))
391
+ elif isinstance(restriction, Constraint):
392
+ # otherwise it already is a Constraint, pass it directly
393
+ compiled_constraints.append((restriction, params_used, None))
394
+ else:
395
+ raise ValueError(f"Restriction {restriction} is neither a string or Constraint {type(restriction)}")
396
+
397
+ # return the restrictions and used parameters
398
+ return compiled_constraints
399
+
400
+
401
+ # Utility functions
402
+
403
+
404
+ def is_or_evals_to_number(s: str) -> int | float | None:
405
+ """Check if the string is a number or can be evaluated to a number."""
406
+ try:
407
+ # check if it's a number or solvable to a number (e.g. '32*2')
408
+ number = eval(s)
409
+ assert isinstance(number, (int, float))
410
+ return number
411
+ except Exception:
412
+ # it's not a solvable subexpression, return None
413
+ return None
414
+
415
+
416
+ def extract_operators(expr: str) -> list[str]:
417
+ """Extracts all operators from an expression string."""
418
+ # Regex for all supported binary operators:
419
+ # supported_operators = ["**", "*", "+", "-", "/"]
420
+
421
+ # remove any whitespace from the expression
422
+ expr = expr.strip().replace(" ", "")
423
+
424
+ # Match ** first to avoid matching * twice
425
+ pattern = r"""
426
+ (?<!\*)\*\* | # match ** but not ***
427
+ (?<=[\w)\d])\- | # binary -: preceded by var/number/closing )
428
+ [+\*/] # match +, *, /
429
+ """
430
+ matches = re.findall(pattern, expr, re.VERBOSE)
431
+ return [m.strip() for m in matches]