Skip to content

Commit f8958b8

Browse files
Merge branch 'master' into fix-markDoNotAggrVar-test
2 parents 807f047 + 7c9b301 commit f8958b8

13 files changed

Lines changed: 8689 additions & 991 deletions

File tree

.github/workflows/stubs.yml

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,17 @@ jobs:
3636
with:
3737
python-version: ${{ env.PYTHON_VERSION }}
3838

39-
- name: Install mypy
40-
run: |
41-
python -m pip install mypy
39+
- name: Update pip
40+
run: python -m pip install --upgrade pip
4241

4342
- name: Install PySCIPOpt
4443
run: |
4544
export CFLAGS="-O0 -ggdb -Wall -Wextra -Werror -Wno-error=deprecated-declarations" # Debug mode. More warnings. Warnings as errors, but allow deprecated declarations.
4645
python -m pip install . -v 2>&1 | tee build.log
46+
47+
- name: Install typing dependencies
48+
run: |
49+
python -m pip install -r requirements/pylock.types.toml
4750
4851
- name: Run MyPy
4952
run: python -m mypy --package pyscipopt
@@ -52,6 +55,15 @@ jobs:
5255
id: stubtest
5356
run: stubs/test.sh
5457

58+
- name: Check baseline test files are up to date
59+
run: |
60+
./stubs/baseline.sh
61+
# we need to ignore the .deb file we download above
62+
if [[ -n $(git status --porcelain --untracked-files=no) ]]; then
63+
echo "Baseline test files are out of date, run: ./stubs/baseline.sh on Python ${{ env.PYTHON_VERSION }} to update"
64+
exit 1
65+
fi
66+
5567
- name: Stub regeneration hint
5668
if: failure() && steps.stubtest.outcome == 'failure'
5769
run: |

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,3 +132,6 @@ model.lp
132132
# VSCode
133133
.vscode/
134134
.devcontainer/
135+
136+
# generated test files
137+
tests/@types/expr.py

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33
## Unreleased
44
### Added
55
- Added `addConsCumulative()` for SCIP cumulative constraints (#1222)
6+
- `Expr` and `GenExpr` support `__pos__` magic method like `+Expr` or `+GenExpr`
7+
- Added type annotations to most methods on the `Model` class
68
### Fixed
79
- Made `test_markDoNotAggrVar_and_getStatus` robust to SCIP presolve changes by discovering the aggregated/multi-aggregated variables instead of hardcoding them
810
### Changed
11+
- Move magic methods (`__radd__`, `__sub__`, `__rsub__`, `__rmul__`, `__richcmp__`, `__neg__`, and `__rtruediv__`) to `ExprLike` base class (#1204)
912
- Speed up `Expr.__add__` and `Expr.__iadd__` via the C-level API
1013
### Removed
1114

requirements/pylock.types.toml

Lines changed: 596 additions & 0 deletions
Large diffs are not rendered by default.

requirements/types.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
.
2+
mypy
Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
"""
2+
This script generates test cases for expression arithmetic type annotations.
3+
4+
It evaluates the runtime output of all combinations of arithmetic operations between different types and generates test cases
5+
that check whether the static type annotations match the actual runtime types of the results.
6+
"""
7+
8+
import argparse
9+
import logging
10+
11+
import itertools
12+
import operator
13+
from pathlib import Path
14+
15+
import pyscipopt
16+
17+
18+
logger = logging.getLogger(__name__)
19+
INDENT = " " * 4
20+
21+
22+
# Initial lines at the start of the generated test file.
23+
GLOBAL_STATEMENTS = [
24+
"# @generated by scripts/generate_expr_type_tests.py - do not edit manually",
25+
"",
26+
"import decimal",
27+
"import random",
28+
"",
29+
"import numpy",
30+
"from typing_extensions import assert_type",
31+
"",
32+
"import pyscipopt.scip",
33+
"",
34+
"",
35+
"model = pyscipopt.scip.Model()",
36+
]
37+
38+
# Expressions to test, mapped from a name to the expression that will be evaluated at runtime to get the value for that name.
39+
# These should cover all interesting types for arithmetic operations.
40+
# Order matters since later expressions can refer to previously defined names.
41+
EXPRESSIONS = {
42+
# Variables
43+
"var": "model.addVar()",
44+
"mvar1d": "model.addMatrixVar(3)",
45+
"mvar2d": "model.addMatrixVar((3, 3))",
46+
"term": "pyscipopt.scip.Term(var)",
47+
# Expressions
48+
"constant": "pyscipopt.scip.Constant(-2.0)",
49+
"expr": "var + 1",
50+
"matrix_expr": "mvar2d * 2",
51+
"sum_expr": "var + constant",
52+
"prod_expr": "var * constant",
53+
"pow_expr": "prod_expr**2",
54+
"unary_expr": "abs(var)",
55+
"var_expr": "pyscipopt.scip.VarExpr(var)",
56+
# Constraints
57+
"exprcons": "var <= 3",
58+
"matrixexprcons": "mvar1d <= 3",
59+
# Builtin numbers
60+
"integer": "random.randint(1, 10)",
61+
"floating_point": "random.random()",
62+
"dec": 'decimal.Decimal("1.0")',
63+
# NumPy arrays
64+
"np_float": "numpy.float64(3.0)",
65+
"array0d": "numpy.array(1)",
66+
"array1d": "numpy.array([1, 2, 3])",
67+
"array2d": "numpy.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])",
68+
}
69+
70+
# Mappings from operator symbols to their corresponding operator functions.
71+
# No spaces are added, so spacing must be added to the operator symbols.
72+
BINARY_OPERATORS = {
73+
" + ": operator.add,
74+
" - ": operator.sub,
75+
" * ": operator.mul,
76+
" / ": operator.truediv,
77+
"**": operator.pow,
78+
" < ": operator.lt,
79+
" <= ": operator.le,
80+
" > ": operator.gt,
81+
" >= ": operator.ge,
82+
" == ": operator.eq,
83+
" != ": operator.ne,
84+
" @ ": operator.matmul,
85+
}
86+
87+
INPLACE_BINARY_OPERATORS = {
88+
"+=": operator.iadd,
89+
"-=": operator.isub,
90+
"*=": operator.imul,
91+
"/=": operator.itruediv,
92+
"**=": operator.ipow,
93+
"@=": operator.imatmul,
94+
}
95+
96+
# Operator function and string with a formatting placeholder for the operation.
97+
UNARY_OPERATORS = [
98+
("+{}", operator.pos),
99+
("-{}", operator.neg),
100+
("abs({})", abs),
101+
("pyscipopt.exp({})", pyscipopt.exp),
102+
("pyscipopt.log({})", pyscipopt.log),
103+
("pyscipopt.sqrt({})", pyscipopt.sqrt),
104+
("pyscipopt.sin({})", pyscipopt.sin),
105+
("pyscipopt.cos({})", pyscipopt.cos),
106+
("{}.sum()", lambda x: x.sum()),
107+
("{}.sum(axis=-1)", lambda x: x.sum(axis=-1)),
108+
]
109+
110+
111+
def build_runtime_values(expressions: dict[str, str]) -> dict[str, object]:
112+
"""Evaluate the expressions and return a mapping from expression names to their runtime values.
113+
114+
Expressions are evaluated in order, so that later expressions can refer to previously defined members.
115+
"""
116+
eval_scope = {}
117+
118+
for statement in GLOBAL_STATEMENTS:
119+
logger.debug(f"Executing statement: {statement}")
120+
exec(statement, {}, eval_scope)
121+
122+
for name, expr in expressions.items():
123+
logger.debug(f"Evaluating expression for {name}: {expr}")
124+
eval_scope[name] = eval(expr, {}, eval_scope)
125+
126+
return eval_scope
127+
128+
129+
def generate_erroring_line(
130+
expr: str,
131+
error: Exception,
132+
indent: str = "",
133+
inplace: bool = False,
134+
) -> str:
135+
"""Generate a line in the generated test file for an expression that produces a runtime error.
136+
137+
Expressions that error at runtime have a type ignore comment to indicate that they are expected to produce a type error.
138+
"""
139+
# Add a fake assignment to prevent "unused expression" errors
140+
expr = f"{indent}{expr}" if inplace else f"{indent}_ = {expr}"
141+
error_message = str(error).replace("\n", "").strip()
142+
return f"{expr} # type: ignore # {error.__class__.__name__}: {error_message}"
143+
144+
145+
def type_name(obj: object) -> str:
146+
"""Get the fully qualified type name of an object."""
147+
if obj.__class__.__module__ == "builtins":
148+
return obj.__class__.__name__
149+
return f"{obj.__class__.__module__}.{obj.__class__.__name__}"
150+
151+
152+
def generate_result_expectation(expr: str, result: object, indent: str = "") -> str:
153+
"""Generate a line in the generated test file for an expression that produces a result without error.
154+
155+
The result type at runtime is used in an `assert_type` call to check that the static type annotations match the actual runtime type of the result.
156+
"""
157+
runtime_type_name = type_name(result)
158+
return f"{indent}assert_type({expr}, {runtime_type_name})"
159+
160+
161+
def no_pyscipopt_objs(*objs: object) -> bool:
162+
"""Check is there are no objects from the `pyscipopt` module in the given objects.
163+
If so, we can skip generating test cases for the expression since it won't involve any `pyscipopt` types that we care about testing.
164+
"""
165+
return not any(obj.__class__.__module__.startswith("pyscipopt") for obj in objs)
166+
167+
168+
def generate_test_cases():
169+
"""Build the test file content by evaluating the expressions and generating test cases for their results.
170+
171+
There are 4 phases:
172+
1. Evaluate all the expressions in `EXPRESSIONS` and store their runtime values.
173+
2. Generate test cases for unary operators applied to each expression in `EXPRESSIONS`.
174+
3. Generate test cases for binary operators applied to all pairs of expressions in `EXPRESSIONS`.
175+
4. Generate test cases for inplace binary operators applied to all pairs of expressions in `EXPRESSIONS`.
176+
"""
177+
runtime_values = build_runtime_values(EXPRESSIONS)
178+
179+
lines = [*GLOBAL_STATEMENTS, "", ""]
180+
181+
for name, expr in EXPRESSIONS.items():
182+
# Define the value from the expression
183+
lines.append(f"{name} = {expr}")
184+
# Check it has the expected type
185+
lines.append(generate_result_expectation(name, runtime_values[name]))
186+
187+
lines.extend(
188+
[
189+
"",
190+
"###################",
191+
"# Unary operators #",
192+
"###################",
193+
"",
194+
]
195+
)
196+
197+
for name in EXPRESSIONS:
198+
if no_pyscipopt_objs(runtime_values[name]):
199+
continue
200+
lines.extend([f"# Unary operators for {name}", ""])
201+
success_lines = []
202+
failure_lines = []
203+
for op_repr, op_func in UNARY_OPERATORS:
204+
expr = op_repr.format(name)
205+
logger.debug(f"Evaluating unary operator {expr}")
206+
try:
207+
result = op_func(runtime_values[name])
208+
except Exception as e:
209+
failure_lines.append(generate_erroring_line(expr, e))
210+
else:
211+
success_lines.append(generate_result_expectation(expr, result))
212+
if success_lines:
213+
lines.extend([*success_lines, ""])
214+
if failure_lines:
215+
lines.extend([*failure_lines, ""])
216+
217+
lines.extend(
218+
[
219+
"####################",
220+
"# Binary operators #",
221+
"####################",
222+
"",
223+
]
224+
)
225+
226+
for left, right in itertools.product(EXPRESSIONS, repeat=2):
227+
if no_pyscipopt_objs(runtime_values[left], runtime_values[right]):
228+
continue
229+
lines.extend([f"# Binary operators for {left} and {right}", ""])
230+
success_lines = []
231+
failure_lines = []
232+
for op_symbol, op_func in BINARY_OPERATORS.items():
233+
logger.debug(
234+
f"Evaluating binary operator {op_symbol} for {left} and {right}"
235+
)
236+
expr = f"{left}{op_symbol}{right}"
237+
try:
238+
result = op_func(runtime_values[left], runtime_values[right])
239+
except Exception as e:
240+
failure_lines.append(generate_erroring_line(expr, e))
241+
else:
242+
success_lines.append(generate_result_expectation(expr, result))
243+
if success_lines:
244+
lines.extend([*success_lines, ""])
245+
if failure_lines:
246+
lines.extend([*failure_lines, ""])
247+
248+
lines.extend(
249+
[
250+
"#####################",
251+
"# Inplace operators #",
252+
"#####################",
253+
]
254+
)
255+
256+
for left, right in itertools.product(EXPRESSIONS, repeat=2):
257+
if no_pyscipopt_objs(runtime_values[left], runtime_values[right]):
258+
continue
259+
lines.extend(["", f"# Inplace operators for {left} and {right}", ""])
260+
for op_symbol, op_func in INPLACE_BINARY_OPERATORS.items():
261+
logger.debug(
262+
f"Evaluating inplace binary operator {op_symbol} for {left} and {right}"
263+
)
264+
265+
# For inplace tests, the target gets modified and can change type.
266+
# To avoid influencing other tests, we wrap each case in a function
267+
# and create a fresh target for the test.
268+
# The function simply calls the inplace operator on the target (left)
269+
# and then checks what type it has after the operation.
270+
target_name = f"{left}_{op_func.__name__}_{right}"
271+
stmt = f"{target_name} {op_symbol} {right}"
272+
lines.extend(
273+
[
274+
"",
275+
f"def test_inplace_{target_name}() -> None:",
276+
f"{INDENT}{target_name} = {EXPRESSIONS[left]}",
277+
]
278+
)
279+
280+
function_scope_locals = runtime_values.copy()
281+
# 1. create the temporary target
282+
exec(f"{target_name} = {EXPRESSIONS[left]}", {}, function_scope_locals)
283+
try:
284+
# 2. apply the inplace operator
285+
exec(stmt, {}, function_scope_locals)
286+
except Exception as e:
287+
lines.append(
288+
generate_erroring_line(stmt, e, indent=INDENT, inplace=True)
289+
)
290+
else:
291+
# 3. fetch the resulting type
292+
new_type = function_scope_locals[target_name]
293+
lines.append(f"{INDENT}{stmt}")
294+
lines.append(
295+
generate_result_expectation(target_name, new_type, indent=INDENT)
296+
)
297+
lines.append("")
298+
299+
return "\n".join(lines)
300+
301+
302+
if __name__ == "__main__":
303+
parser = argparse.ArgumentParser()
304+
parser.add_argument(
305+
"--output",
306+
"-o",
307+
type=Path,
308+
default=Path(__file__).parent.parent / "tests" / "@types" / "expr.py",
309+
)
310+
parser.add_argument("-v", "--verbose", action="store_true", default=0)
311+
args = parser.parse_args()
312+
313+
logging.basicConfig()
314+
logger.setLevel(logging.DEBUG if args.verbose else logging.WARNING)
315+
316+
test_cases = generate_test_cases()
317+
target = Path(args.output)
318+
target.parent.mkdir(parents=True, exist_ok=True)
319+
with target.open("w") as f:
320+
f.write(test_cases)

0 commit comments

Comments
 (0)