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