Skip to content

Commit 35424f2

Browse files
authored
Merge pull request #3854 from jsiirola/pytest-solver-test-filter
pytest: rework `conftest.py` to support solver/writer marker expressions
2 parents 5852a20 + c2ce299 commit 35424f2

31 files changed

+219
-191
lines changed

conftest.py

Lines changed: 81 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -10,76 +10,103 @@
1010
import pytest
1111

1212
_implicit_markers = {'default'}
13-
_extended_implicit_markers = _implicit_markers.union({'solver'})
13+
_category_markers = {'solver', 'writer'}
14+
_extended_implicit_markers = _implicit_markers.union(_category_markers)
1415

1516

16-
def pytest_collection_modifyitems(items):
17-
"""
18-
This method will mark any unmarked tests with the implicit marker ('default')
17+
def pytest_configure(config):
18+
# If the user specified "--solver" or "--writer", then add that
19+
# logic to the marker expression
20+
markexpr = config.option.markexpr
21+
for cat in _category_markers:
22+
opt = config.getoption('--' + cat)
23+
if opt:
24+
if markexpr:
25+
markexpr = f"({markexpr}) and "
26+
markexpr += f"{cat}(id='{opt}')"
27+
# If the user didn't specify a marker expression, then we will
28+
# select all "default" tests.
29+
if not markexpr:
30+
markexpr = 'default'
31+
config.option.markexpr = markexpr
1932

20-
"""
21-
for item in items:
22-
try:
23-
next(item.iter_markers())
24-
except StopIteration:
25-
for marker in _implicit_markers:
26-
item.add_marker(getattr(pytest.mark, marker))
2733

34+
def pytest_itemcollected(item):
35+
"""Standardize all Pyomo test markers.
36+
37+
This callback ensures that all unmarked tests, along with all tests
38+
that are only marked by category markers (e.g., "solver" or
39+
"writer"), are also marked with the default (implicit) markers
40+
(currently just "default").
41+
42+
About category markers
43+
----------------------
44+
45+
We have historically supported "category markers"::
46+
47+
@pytest.mark.solver("highs")
48+
49+
Unfortunately, pytest doesn't allow for building marker
50+
expressions (e.g., for "-m") based on the marker.args. We will
51+
map the positional argument (for pytest.mark.solver and
52+
pytest.mark.writer) to the keyword argument "id". This will allow
53+
querying against specific solver interfaces in marker expressions
54+
with::
55+
56+
solver(id='highs')
57+
58+
We will take this opportunity to also set a keyword argument for
59+
the solver/writer "vendor" (defined as the id up to the first
60+
underscore). This will allow running "all Gurobi tests"
61+
(including, e.g., lp, direct, and persistent) with::
62+
63+
-m solver(vendor='gurobi')
64+
65+
As with all pytest markers, these can be combined into more complex
66+
"marker expressions" using ``and``, ``or``, ``not``, and ``()``.
2867
29-
def pytest_runtest_setup(item):
30-
"""
31-
This method overrides pytest's default behavior for marked tests.
32-
33-
The logic below follows this flow:
34-
1) Did the user ask for a specific solver using the '--solver' flag?
35-
If so: Add skip statements to any test NOT labeled with the
36-
requested solver category.
37-
2) Did the user ask for a specific marker using the '-m' flag?
38-
If so: Return to pytest's default behavior.
39-
3) If the user requested no specific solver or marker, look at each
40-
test for the following:
41-
a) If unmarked, run the test
42-
b) If marked with implicit_markers, run the test
43-
c) If marked "solver" and NOT any explicit marker, run the test
44-
OTHERWISE: Skip the test.
45-
In other words - we want to run unmarked, implicit, and solver tests as
46-
the default mode; but if solver tests are also marked with an explicit
47-
category (e.g., "expensive"), we will skip them.
4868
"""
49-
solvernames = [mark.args[0] for mark in item.iter_markers(name="solver")]
50-
solveroption = item.config.getoption("--solver")
51-
markeroption = item.config.getoption("-m")
52-
item_markers = set(mark.name for mark in item.iter_markers())
53-
if solveroption:
54-
if solveroption not in solvernames:
55-
pytest.skip("SKIPPED: Test not marked {!r}".format(solveroption))
56-
return
57-
elif markeroption:
69+
markers = list(item.iter_markers())
70+
if not markers:
71+
# No markers; add the implicit (default) markers
72+
for marker in _implicit_markers:
73+
item.add_marker(getattr(pytest.mark, marker))
5874
return
59-
elif item_markers:
60-
if not _implicit_markers.issubset(item_markers) and not item_markers.issubset(
61-
_extended_implicit_markers
62-
):
63-
pytest.skip('SKIPPED: Only running default, solver, and unmarked tests.')
75+
76+
marker_set = {mark.name for mark in markers}
77+
# If the item is only marked by extended implicit markers (e.g.,
78+
# solver and/or writer), then make sure it is also marked by all
79+
# implicit markers (i.e., "default")
80+
if marker_set.issubset(_extended_implicit_markers):
81+
for marker in _implicit_markers - marker_set:
82+
item.add_marker(getattr(pytest.mark, marker))
83+
84+
# Map any "category" markers (solver or writer) positional arguments
85+
# to the id keyword, and ensure the 'vendor' keyword is populated
86+
for mark in markers:
87+
if mark.name not in _category_markers:
88+
continue
89+
if mark.args:
90+
(_id,) = mark.args
91+
mark.kwargs['id'] = _id
92+
if 'vendor' not in mark.kwargs:
93+
mark.kwargs['vendor'] = mark.kwargs['id'].split("_")[0]
6494

6595

6696
def pytest_addoption(parser):
6797
"""
68-
Add another parser option to specify suite of solver tests to run
98+
Add parser options as shorthand for running tests marked by specific
99+
solvers or writers.
69100
"""
70101
parser.addoption(
71102
"--solver",
72103
action="store",
73104
metavar="SOLVER",
74105
help="Run tests matching the requested SOLVER.",
75106
)
76-
77-
78-
def pytest_configure(config):
79-
"""
80-
Register additional solver marker, as applicable.
81-
This stops pytest from printing a warning about unregistered solver options.
82-
"""
83-
config.addinivalue_line(
84-
"markers", "solver(name): mark test to run the named solver"
107+
parser.addoption(
108+
"--writer",
109+
action="store",
110+
metavar="WRITER",
111+
help="Run tests matching the requested WRITER.",
85112
)

doc/OnlineDocs/contribution_guide.rst

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,12 @@ Markers are declared in ``pyproject.toml``. Some commonly used markers are:
9898

9999
- ``expensive``: tests that take a long time to run
100100
- ``mpi``: tests that require MPI
101-
- ``solver(name)``: dynamic marker to label a test for a specific solver,
102-
e.g., ``@pytest.mark.solver("gurobi")``
101+
- ``solver(id='name')``: tests for a specific solver,
102+
e.g., ``@pytest.mark.solver("name")``
103+
- ``solver(vendor='name')``: tests for a set of solvers (matching up to the
104+
first underscore), e.g., ``solver(vendor="gurobi")`` will run tests marked
105+
with ``solver("gurobi")``, ``solver("gurobi_direct")``, and
106+
``solver("gurobi_persistent")``
103107

104108
More details about Pyomo-defined default test behavior can be found in
105109
the `conftest.py file <https://github.com/Pyomo/pyomo/blob/main/conftest.py>`_.

pyomo/contrib/solver/tests/solvers/test_gurobi_minlp.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939

4040

4141
@unittest.skipUnless(gurobi_direct.available(), "needs Gurobi Direct MINLP interface")
42+
@unittest.pytest.mark.solver("gurobi_direct_minlp")
4243
class TestGurobiMINLP(unittest.TestCase):
4344
def test_gurobi_minlp_sincosexp(self):
4445
m = ConcreteModel(name="test")

pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ def get_visitor(self):
6464

6565

6666
@unittest.skipUnless(gurobipy_available, "gurobipy is not available")
67+
@unittest.pytest.mark.solver("gurobi_direct_minlp")
6768
class TestGurobiMINLPWalker(CommonTest):
6869
def _get_nl_expr_tree(self, visitor, expr):
6970
# This is a bit hacky, but the only way that I know to get the expression tree

pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ def make_model():
7474

7575

7676
@unittest.skipUnless(gurobipy_available, "Gurobipy 12 is not available")
77+
@unittest.pytest.mark.solver("gurobi_direct_minlp")
7778
class TestGurobiMINLPWriter(CommonTest):
7879
def test_small_model(self):
7980
grb_model = gurobipy.Model()

pyomo/contrib/solver/tests/solvers/test_gurobi_persistent.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ def rule3(model):
122122
return model
123123

124124

125+
@unittest.pytest.mark.solver("gurobi_persistent")
125126
class TestGurobiPersistentSimpleLPUpdates(unittest.TestCase):
126127
def setUp(self):
127128
self.m = pyo.ConcreteModel()
@@ -183,6 +184,7 @@ def test_lp(self):
183184
self.assertAlmostEqual(y, self.m.y.value)
184185

185186

187+
@unittest.pytest.mark.solver("gurobi_persistent")
186188
class TestGurobiPersistent(unittest.TestCase):
187189
def test_nonconvex_qcp_objective_bound_1(self):
188190
# the goal of this test is to ensure we can get an objective bound
@@ -493,6 +495,7 @@ def test_zero_time_limit(self):
493495
self.assertIsNone(res.incumbent_objective)
494496

495497

498+
@unittest.pytest.mark.solver("gurobi_persistent")
496499
class TestManualMode(unittest.TestCase):
497500
def setUp(self):
498501
opt = GurobiPersistent()

pyomo/contrib/solver/tests/solvers/test_gurobi_warm_start.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ def check_optimal_soln(self, m):
6767
self.assertEqual(value(m.x[i]), x[i])
6868

6969
@unittest.skipUnless(gurobi_direct.available(), "needs Gurobi Direct interface")
70+
@unittest.pytest.mark.solver("gurobi_direct")
7071
def test_gurobi_direct_warm_start(self):
7172
m = self.make_model()
7273

@@ -82,6 +83,7 @@ def test_gurobi_direct_warm_start(self):
8283
@unittest.skipUnless(
8384
gurobi_direct_minlp.available(), "needs Gurobi Direct MINLP interface"
8485
)
86+
@unittest.pytest.mark.solver("gurobi_direct_minlp")
8587
def test_gurobi_minlp_warmstart(self):
8688
m = self.make_model()
8789

@@ -97,6 +99,7 @@ def test_gurobi_minlp_warmstart(self):
9799
@unittest.skipUnless(
98100
gurobi_persistent.available(), "needs Gurobi persistent interface"
99101
)
102+
@unittest.pytest.mark.solver("gurobi_persistent")
100103
def test_gurobi_persistent_warmstart(self):
101104
m = self.make_model()
102105

pyomo/contrib/solver/tests/solvers/test_highs.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
raise unittest.SkipTest
1818

1919

20+
@unittest.pytest.mark.solver("highs")
2021
class TestBugs(unittest.TestCase):
2122
def test_mutable_params_with_remove_cons(self):
2223
m = pyo.ConcreteModel()

pyomo/contrib/solver/tests/solvers/test_ipopt.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def windows_tee_buffer(size=1 << 20):
5353
tee._pipe_buffersize = old
5454

5555

56+
@unittest.pytest.mark.solver("ipopt")
5657
class TestIpoptSolverConfig(unittest.TestCase):
5758
def test_default_instantiation(self):
5859
config = ipopt.IpoptConfig()
@@ -87,6 +88,7 @@ def test_custom_instantiation(self):
8788
self.assertFalse(config.executable.available())
8889

8990

91+
@unittest.pytest.mark.solver("ipopt")
9092
class TestIpoptSolutionLoader(unittest.TestCase):
9193
def test_get_reduced_costs_error(self):
9294
loader = ipopt.IpoptSolutionLoader(
@@ -105,6 +107,7 @@ def test_get_duals_error(self):
105107
loader.get_duals()
106108

107109

110+
@unittest.pytest.mark.solver("ipopt")
108111
class TestIpoptInterface(unittest.TestCase):
109112
@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available")
110113
def test_command_line_options(self):
@@ -1877,6 +1880,7 @@ def test_bad_executable(self):
18771880

18781881

18791882
@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available")
1883+
@unittest.pytest.mark.solver("ipopt")
18801884
class TestIpopt(unittest.TestCase):
18811885
def create_model(self):
18821886
model = pyo.ConcreteModel()
@@ -2091,6 +2095,7 @@ def test_load_suffixes_infeasible_model(self):
20912095

20922096

20932097
@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available")
2098+
@unittest.pytest.mark.solver("ipopt")
20942099
class TestLegacyIpopt(unittest.TestCase):
20952100
def create_model(self):
20962101
model = pyo.ConcreteModel()

pyomo/contrib/solver/tests/solvers/test_knitro_direct.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424

2525
@unittest.skipIf(not avail, "KNITRO solver is not available")
26+
@unittest.pytest.mark.solver("knitro_direct")
2627
class TestKnitroDirectSolverConfig(unittest.TestCase):
2728
def test_default_instantiation(self):
2829
config = KnitroConfig()
@@ -45,6 +46,7 @@ def test_custom_instantiation(self):
4546

4647

4748
@unittest.skipIf(not avail, "KNITRO solver is not available")
49+
@unittest.pytest.mark.solver("knitro_direct")
4850
class TestKnitroSolverResultsExtraInfo(unittest.TestCase):
4951
def test_results_extra_info_mip(self):
5052
"""Test that MIP-specific extra info is populated for MIP problems."""
@@ -98,6 +100,7 @@ def test_results_extra_info_no_mip(self):
98100

99101

100102
@unittest.skipIf(not avail, "KNITRO solver is not available")
103+
@unittest.pytest.mark.solver("knitro_direct")
101104
class TestKnitroSolverObjectiveBound(unittest.TestCase):
102105
def test_objective_bound_mip(self):
103106
"""Test that objective bound is retrieved for MIP problems."""
@@ -135,6 +138,7 @@ def test_objective_bound_no_mip(self):
135138

136139

137140
@unittest.skipIf(not avail, "KNITRO solver is not available")
141+
@unittest.pytest.mark.solver("knitro_direct")
138142
class TestKnitroSolverIncumbentObjective(unittest.TestCase):
139143
def test_none_without_objective(self):
140144
"""Test that incumbent objective is None when no objective is present."""
@@ -192,6 +196,7 @@ def test_value_when_optimal(self):
192196

193197

194198
@unittest.skipIf(not avail, "KNITRO solver is not available")
199+
@unittest.pytest.mark.solver("knitro_direct")
195200
class TestKnitroSolverSolutionStatus(unittest.TestCase):
196201
def test_solution_status_mapping(self):
197202
"""Test that solution status is correctly mapped from KNITRO status."""
@@ -246,6 +251,7 @@ def test_solution_status_mapping(self):
246251

247252

248253
@unittest.skipIf(not avail, "KNITRO solver is not available")
254+
@unittest.pytest.mark.solver("knitro_direct")
249255
class TestKnitroSolverTerminationCondition(unittest.TestCase):
250256
def test_termination_condition_mapping(self):
251257
"""Test that termination condition is correctly mapped from KNITRO status."""
@@ -337,6 +343,7 @@ def test_termination_condition_mapping(self):
337343

338344

339345
@unittest.skipIf(not avail, "KNITRO solver is not available")
346+
@unittest.pytest.mark.solver("knitro_direct")
340347
class TestKnitroDirectSolverInterface(unittest.TestCase):
341348
def test_class_member_list(self):
342349
opt = KnitroDirectSolver()
@@ -379,6 +386,7 @@ def test_available_cache(self):
379386

380387

381388
@unittest.skipIf(not avail, "KNITRO solver is not available")
389+
@unittest.pytest.mark.solver("knitro_direct")
382390
class TestKnitroDirectSolver(unittest.TestCase):
383391
def setUp(self):
384392
self.opt = KnitroDirectSolver()

0 commit comments

Comments
 (0)