diff --git a/pyomo/common/envvar.py b/pyomo/common/envvar.py index 36ed5910781..d94e683ff02 100644 --- a/pyomo/common/envvar.py +++ b/pyomo/common/envvar.py @@ -9,12 +9,39 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ +"""Attributes describing the current platform and user configuration. + +This module provides standardized attributes that other parts of Pyomo +can use to interrogate aspects of the current platform, and to find +information about the current user configuration (including where to +locate the main Pyomo configuration directory). + +""" + import os import platform +_platform = platform.system().lower() +#: bool : True if running in a "native" Windows environment +is_native_windows = _platform.startswith('windows') +#: bool : True if running on Windows (native or cygwin) +is_windows = is_native_windows or _platform.startswith('cygwin') +#: bool : True if running on Mac/OSX +is_osx = _platform.startswith('darwin') +#: bool: True if running under the PyPy interpreter +is_pypy = platform.python_implementation().lower().startswith('pypy') + +#: str : Absolute path to the user's Pyomo Configuration Directory. +#: +#: By default, this is ``~/.pyomo`` on Linux and OSX and +#: ``%LOCALAPPDATA%/Pyomo`` on Windows. It can be overridden by +#: setting the ``PYOMO_CONFIG_DIR`` environment variable before +#: importing Pyomo. +PYOMO_CONFIG_DIR = None + if 'PYOMO_CONFIG_DIR' in os.environ: PYOMO_CONFIG_DIR = os.path.abspath(os.environ['PYOMO_CONFIG_DIR']) -elif platform.system().lower().startswith(('windows', 'cygwin')): +elif is_windows: PYOMO_CONFIG_DIR = os.path.abspath( os.path.join(os.environ.get('LOCALAPPDATA', ''), 'Pyomo') ) diff --git a/pyomo/common/fileutils.py b/pyomo/common/fileutils.py index 421bf7797b1..69ab7cdfcc7 100644 --- a/pyomo/common/fileutils.py +++ b/pyomo/common/fileutils.py @@ -505,6 +505,50 @@ def import_file(path, clear_cache=False, infer_package=True, module_name=None): return module +def to_legal_filename(name, universal=False) -> str: + """Convert a string to a legal filename on the current platform. + + This converts a candidate file name (not a path) and converts it to + a legal file name on the current platform. This includes replacing + any unallowable characters (including the path separator) with + underscores (``_``), and on some platforms, enforcing restrictions + on the allowable final character. + + Parameters + ---------- + name : str + + The original (desired) file name + + universal : bool + + If True, this will attempt a form of "universal" standardization + that uses the most restrictive set of character translations and + rules. Currently, ``universal=True`` is equivalent to running + the Windows translations. + + """ + if envvar.is_windows or universal: + tr = getattr(to_legal_filename, 'tr', None) + if tr is None: + # Windows illegal characters: 0-31, plus < > : " / \ | ? * + _illegal = r'<>:"/\|?*' + ''.join(map(chr, range(32))) + tr = to_legal_filename.tr = str.maketrans(_illegal, '_' * len(_illegal)) + # Remove illegal characters + name = name.translate(tr) + if name: + # Windows allows filenames to end with space or dot, but the + # file explorer can't interact with them + if name[-1] in ' .': + name = name[:-1] + '_' + # Similarly, starting with a space is generally a bad idea + if name[0] == ' ': + name = '_' + name[1:] + else: + name = name.replace('/', '_').replace(chr(0), '_') + return name + + class PathData(object): """An object for storing and managing a :py:class:`PathManager` path""" diff --git a/pyomo/common/tests/test_fileutils.py b/pyomo/common/tests/test_fileutils.py index ddbbda30b7d..e5e781c29e7 100644 --- a/pyomo/common/tests/test_fileutils.py +++ b/pyomo/common/tests/test_fileutils.py @@ -38,6 +38,7 @@ _libExt, ExecutableData, import_file, + to_legal_filename, ) from pyomo.common.download import FileDownloader @@ -497,3 +498,35 @@ def test_PathManager(self): Executable(f_in_path2).rehash() self.assertTrue(Executable(f_in_path2).available()) self.assertEqual(Executable(f_in_path2).path(), f_loc) + + def test_to_legal_filename(self): + self.assertEqual('abc', to_legal_filename('abc')) + self.assertEqual('', to_legal_filename('')) + if envvar.is_windows: + self.assertEqual('_abc', to_legal_filename(' abc')) + self.assertEqual('abc_', to_legal_filename('abc.')) + self.assertEqual('abc_', to_legal_filename('abc ')) + self.assertEqual('abc_def', to_legal_filename('abc/def')) + self.assertEqual('abc_def', to_legal_filename('abc\\def')) + self.assertEqual( + 'a_b_c', to_legal_filename(''.join(['a', chr(0), 'b', chr(7), 'c'])) + ) + else: + self.assertEqual(' abc', to_legal_filename(' abc')) + self.assertEqual('abc.', to_legal_filename('abc.')) + self.assertEqual('abc ', to_legal_filename('abc ')) + self.assertEqual('abc_def', to_legal_filename('abc/def')) + self.assertEqual('abc\\def', to_legal_filename('abc\\def')) + self.assertEqual( + 'a_b' + chr(7) + 'c', + to_legal_filename(''.join(['a', chr(0), 'b', chr(7), 'c'])), + ) + + self.assertEqual('_abc', to_legal_filename(' abc', True)) + self.assertEqual('abc_', to_legal_filename('abc.', True)) + self.assertEqual('abc_', to_legal_filename('abc ', True)) + self.assertEqual('abc_def', to_legal_filename('abc/def', True)) + self.assertEqual('abc_def', to_legal_filename('abc\\def', True)) + self.assertEqual( + 'a_b_c', to_legal_filename(''.join(['a', chr(0), 'b', chr(7), 'c']), True) + ) diff --git a/pyomo/contrib/solver/common/factory.py b/pyomo/contrib/solver/common/factory.py index 29aca32b7cb..8af5de6ab9c 100644 --- a/pyomo/contrib/solver/common/factory.py +++ b/pyomo/contrib/solver/common/factory.py @@ -16,24 +16,90 @@ class SolverFactoryClass(Factory): - """ - Registers new interfaces in the legacy SolverFactory - """ + """Factory class for generating instances of solver interfaces (API v2)""" def register(self, name, legacy_name=None, doc=None): + """Register a new solver with this solver factory + + This will register the solver both with this + :attr:`SolverFactory` and with the original (legacy) + :attr:`~pyomo.opt.base.solvers.LegacySolverFactory` + + Examples + -------- + + .. testcode:: + :hide: + + SolverFactory = SolverFactoryClass() + + This method can either be called as a decorator on a solver + interface class definition, e.g.: + + .. testcode:: + + from pyomo.contrib.solver.common.base import SolverBase + + @SolverFactory.register("test_solver_1") + class TestSolver1(SolverBase): + pass + + Or explicitly: + + .. testcode:: + + class TestSolver2(SolverBase): + pass + + SolverFactory.register("test_solver_2")(TestSolver2) + + When called explicitly, you can pass a custom class to register + with the :attr:`LegacySolverFactory`: + + .. testcode:: + + from pyomo.contrib.solver.common.base import LegacySolverWrapper + + class LegacyTestSolver2(LegacySolverWrapper, TestSolver2): + pass + + SolverFactory.register("test_solver_2a")(TestSolver2, LegacyTestSolver2) + + + Parameters + ---------- + name : str + + The name used to register this solver interface class + + legacy_name : str + + The name to use to register the legacy interface wrapper to + this solver interface in the LegacySolverInterface. If + ``None``, then ``name`` will be used. + + doc : str + + Extended description of this solver interface. + + """ if legacy_name is None: legacy_name = name - def decorator(cls): + def decorator(cls, legacy_cls=None): self._cls[name] = cls self._doc[name] = doc - class LegacySolver(LegacySolverWrapper, cls): - pass + if legacy_cls is None: + + class LegacySolver(LegacySolverWrapper, cls): + pass + + legacy_cls = LegacySolver - LegacySolverFactory.register(legacy_name, doc + " (new interface)")( - LegacySolver - ) + LegacySolverFactory.register( + legacy_name, doc + " (new interface)" if doc else doc + )(legacy_cls) # Preserve the preferred name, as registered in the Factory cls.name = name @@ -42,4 +108,5 @@ class LegacySolver(LegacySolverWrapper, cls): return decorator -SolverFactory = SolverFactoryClass() +#: Global registry/factory for "v2" solver interfaces. +SolverFactory: SolverFactoryClass = SolverFactoryClass() diff --git a/pyomo/contrib/solver/common/util.py b/pyomo/contrib/solver/common/util.py index 92c5b3818e9..8c62eea3f73 100644 --- a/pyomo/contrib/solver/common/util.py +++ b/pyomo/contrib/solver/common/util.py @@ -27,7 +27,7 @@ class NoFeasibleSolutionError(PyomoException): class NoOptimalSolutionError(PyomoException): default_message = ( 'Solver did not find the optimal solution. Set ' - 'opt.config.raise_exception_on_nonoptimal_result = False to bypass this error.' + 'opt.config.raise_exception_on_nonoptimal_result=False to bypass this error.' ) diff --git a/pyomo/contrib/solver/plugins.py b/pyomo/contrib/solver/plugins.py index 86c05f2bd70..19fc9b2b2a1 100644 --- a/pyomo/contrib/solver/plugins.py +++ b/pyomo/contrib/solver/plugins.py @@ -11,7 +11,7 @@ from .common.factory import SolverFactory -from .solvers.ipopt import Ipopt +from .solvers.ipopt import Ipopt, LegacyIpoptSolver from .solvers.gurobi_persistent import GurobiPersistent from .solvers.gurobi_direct import GurobiDirect from .solvers.highs import Highs @@ -20,7 +20,7 @@ def load(): SolverFactory.register( name='ipopt', legacy_name='ipopt_v2', doc='The IPOPT NLP solver' - )(Ipopt) + )(Ipopt, LegacyIpoptSolver) SolverFactory.register( name='gurobi_persistent', legacy_name='gurobi_persistent_v2', diff --git a/pyomo/contrib/solver/solvers/ipopt.py b/pyomo/contrib/solver/solvers/ipopt.py index 075fc998ecc..a7ed5435aa7 100644 --- a/pyomo/contrib/solver/solvers/ipopt.py +++ b/pyomo/contrib/solver/solvers/ipopt.py @@ -16,6 +16,7 @@ import io import re import sys +import threading from typing import Optional, Tuple, Union, Mapping, List, Dict, Any, Sequence from pyomo.common import Executable @@ -30,6 +31,7 @@ DeveloperError, InfeasibleConstraintException, ) +from pyomo.common.fileutils import to_legal_filename from pyomo.common.tempfiles import TempfileManager from pyomo.common.timing import HierarchicalTimer from pyomo.core.base.var import VarData @@ -37,6 +39,7 @@ from pyomo.repn.plugins.nl_writer import NLWriter, NLWriterInfo from pyomo.contrib.solver.common.base import SolverBase, Availability from pyomo.contrib.solver.common.config import SolverConfig +from pyomo.contrib.solver.common.factory import LegacySolverWrapper from pyomo.contrib.solver.common.results import ( Results, TerminationCondition, @@ -210,6 +213,14 @@ def get_reduced_costs( 'watchdog_shortened_iter_trigger', } +unallowed_ipopt_options = { + 'wantsol': 'The solver interface requires the sol file to be created', + 'option_file_name': ( + 'Pyomo generates the ipopt options file as part of the `solve` ' + 'method. Add all options to ipopt.config.solver_options instead.' + ), +} + @document_class_CONFIG(methods=['solve']) class Ipopt(SolverBase): @@ -280,43 +291,50 @@ def has_linear_solver(self, linear_solver: str) -> bool: ) return 'running with linear solver' in results.solver_log + def _verify_ipopt_options(self, config: IpoptConfig) -> None: + for key, msg in unallowed_ipopt_options.items(): + if key in config.solver_options: + raise ValueError(f"unallowed ipopt option '{key}': {msg}") + # Map standard Pyomo solver options to Ipopt options: standard + # options override ipopt-specific options. + if config.time_limit is not None: + config.solver_options['max_cpu_time'] = config.time_limit + def _write_options_file( self, filename: str, options: Mapping[str, Union[str, int, float]] - ) -> bool: - # First we need to determine if we even need to create a file. - # If options is empty, then we return False - opt_file_exists = False - if not options: - return False - # If it has options in it, parse them and write them to a file. + ) -> None: + # Look through the solver options and write them to a file. # If they are command line options, ignore them; they will be - # parsed during _create_command_line - for k, val in options.items(): - if k not in ipopt_command_line_options: - opt_file_exists = True - with open(filename + '.opt', 'a+', encoding='utf-8') as opt_file: - opt_file.write(str(k) + ' ' + str(val) + '\n') - return opt_file_exists - - def _create_command_line( - self, basename: str, config: IpoptConfig, opt_file: bool - ) -> List[str]: - cmd = [str(config.executable), basename + '.nl', '-AMPL'] - if opt_file: - cmd.append('option_file_name=' + basename + '.opt') - if 'option_file_name' in config.solver_options: - raise ValueError( - 'Pyomo generates the ipopt options file as part of the `solve` method. ' - 'Add all options to ipopt.config.solver_options instead.' + # added to the command line. + options_file_options = [ + opt for opt in options if opt not in ipopt_command_line_options + ] + if not options_file_options: + return + with open(filename, 'w', encoding='utf-8') as OPT_FILE: + OPT_FILE.writelines( + f"{opt} {options[opt]}\n" for opt in options_file_options ) - if ( - config.time_limit is not None - and 'max_cpu_time' not in config.solver_options - ): - config.solver_options['max_cpu_time'] = config.time_limit - for k, val in config.solver_options.items(): - if k in ipopt_command_line_options: - cmd.append(str(k) + '=' + str(val)) + options['option_file_name'] = filename + + def _create_command_line(self, basename: str, config: IpoptConfig) -> List[str]: + cmd = [str(config.executable), basename + '.nl', '-AMPL'] + for opt, val in config.solver_options.items(): + if opt not in ipopt_command_line_options: + continue + if isinstance(val, str): + if '"' not in val: + cmd.append(f'{opt}="{val}"') + elif "'" not in val: + cmd.append(f"{opt}='{val}'") + else: + raise ValueError( + f"solver_option '{opt}' contained value {val!r} with " + "both single and double quotes. Ipopt cannot parse " + "command line options with escaped quote characters." + ) + else: + cmd.append(f'{opt}={val}') return cmd def solve(self, model, **kwds) -> Results: @@ -349,11 +367,31 @@ def solve(self, model, **kwds) -> Results: dname = config.working_dir if not os.path.exists(dname): os.mkdir(dname) - basename = os.path.join(dname, model.name) - if os.path.exists(basename + '.nl'): - raise RuntimeError( - f"NL file with the same name {basename + '.nl'} already exists!" - ) + # Because we are just "making up" a file name, it is better + # to always generate a consistent and legal name, rather + # than blindly follow what the user gave us. We will use + # `universal=True` here to make sure that double quotes are + # translated, thereby guaranteeing that we should always + # generate a legal base name (unless, of course, the user + # put double quotes somewhere else in the path) + basename = to_legal_filename(model.name, universal=True) + # Strip off quotes - the command line parser will re-add them + if basename[0] in "'\"" and basename[0] == basename[-1]: + basename = basename[1:-1] + # The base file name for this interface is "model_name + PID + # + thread id", so that this is reasonably unique in both + # parallel and threaded environments (even when working_dir + # is set to a persistent directory). Note that the Pyomo + # solver interfaces are not formally thread-safe (yet), so + # this is a bit of future-proofing. + basename = os.path.join( + dname, f"{basename}.{os.getpid()}.{threading.get_ident()}" + ) + for ext in ('.nl', '.row', '.col', '.sol', '.opt'): + if os.path.exists(basename + ext): + raise RuntimeError( + f"Solver interface file {basename + ext} already exists!" + ) # Note: the ASL has an issue where string constants written # to the NL file (e.g. arguments in external functions) MUST # be terminated with '\n' regardless of platform. We will @@ -385,15 +423,16 @@ def solve(self, model, **kwds) -> Results: env['AMPLFUNC'] = amplfunc_merge( env, *nl_info.external_function_libraries ) - # Write the opt_file, if there should be one; return a bool to say - # whether or not we have one (so we can correctly build the command line) - opt_file = self._write_options_file( - filename=basename, options=config.solver_options + self._verify_ipopt_options(config) + # Write the options file, if there should be one. If + # the file was written, then 'options_file_name' was + # added to config.options (so we can correctly build the + # command line) + self._write_options_file( + filename=basename + '.opt', options=config.solver_options ) # Call ipopt - passing the files via the subprocess - cmd = self._create_command_line( - basename=basename, config=config, opt_file=opt_file - ) + cmd = self._create_command_line(basename=basename, config=config) # this seems silly, but we have to give the subprocess slightly # longer to finish than ipopt if config.time_limit is not None: @@ -680,3 +719,15 @@ def _parse_solution( ) return res + + +class LegacyIpoptSolver(LegacySolverWrapper, Ipopt): + def _verify_ipopt_options(self, config: IpoptConfig) -> None: + # The old Ipopt solver would map solver_options starting with + # "OF_" to the options file. That is no longer needed, so we + # will strip off any "OF_" that we find + for opt, val in list(config.solver_options.items()): + if opt.startswith('OF_'): + config.solver_options[opt[3:]] = val + del config.solver_options[opt] + return super()._verify_ipopt_options(config) diff --git a/pyomo/contrib/solver/tests/solvers/test_ipopt.py b/pyomo/contrib/solver/tests/solvers/test_ipopt.py index d788b66982a..12ad76f7904 100644 --- a/pyomo/contrib/solver/tests/solvers/test_ipopt.py +++ b/pyomo/contrib/solver/tests/solvers/test_ipopt.py @@ -13,12 +13,14 @@ import subprocess import pyomo.environ as pyo +from pyomo.common.envvar import is_windows from pyomo.common.fileutils import ExecutableData from pyomo.common.config import ConfigDict, ADVANCED_OPTION from pyomo.common.errors import DeveloperError from pyomo.common.tee import capture_output import pyomo.contrib.solver.solvers.ipopt as ipopt from pyomo.contrib.solver.common.util import NoSolutionError +from pyomo.contrib.solver.common.results import TerminationCondition, SolutionStatus from pyomo.contrib.solver.common.factory import SolverFactory from pyomo.common import unittest, Executable from pyomo.common.tempfiles import TempfileManager @@ -318,15 +320,38 @@ def test_empty_output_parsing(self): logs.output[0], ) + def test_verify_ipopt_options(self): + opt = ipopt.Ipopt(solver_options={'max_iter': 4}) + opt._verify_ipopt_options(opt.config) + self.assertEqual(opt.config.solver_options.value(), {'max_iter': 4}) + + opt = ipopt.Ipopt(solver_options={'max_iter': 4}, time_limit=10) + opt._verify_ipopt_options(opt.config) + self.assertEqual( + opt.config.solver_options.value(), {'max_iter': 4, 'max_cpu_time': 10} + ) + + # Finally, let's make sure it errors if someone tries to pass option_file_name + opt = ipopt.Ipopt( + solver_options={'max_iter': 4, 'option_file_name': 'myfile.opt'} + ) + with self.assertRaisesRegex( + ValueError, + r'Pyomo generates the ipopt options file as part of the `solve` ' + r'method. Add all options to ipopt.config.solver_options instead', + ): + opt._verify_ipopt_options(opt.config) + def test_write_options_file(self): - # If we have no options, we should get false back + # If we have no options, nothing should happen (and no options + # file should be added tot he set of options) opt = ipopt.Ipopt() - result = opt._write_options_file('fakename', None) - self.assertFalse(result) + opt._write_options_file('fakename', opt.config.solver_options) + self.assertEqual(opt.config.solver_options.value(), {}) # Pass it some options that ARE on the command line opt = ipopt.Ipopt(solver_options={'max_iter': 4}) - result = opt._write_options_file('myfile', opt.config.solver_options) - self.assertFalse(result) + opt._write_options_file('myfile', opt.config.solver_options) + self.assertNotIn('option_file_name', opt.config.solver_options) self.assertFalse(os.path.isfile('myfile.opt')) # Now we are going to actually pass it some options that are NOT on # the command line @@ -335,23 +360,25 @@ def test_write_options_file(self): dname = temp.mkdtemp() if not os.path.exists(dname): os.mkdir(dname) - filename = os.path.join(dname, 'myfile') - result = opt._write_options_file(filename, opt.config.solver_options) - self.assertTrue(result) - self.assertTrue(os.path.isfile(filename + '.opt')) + filename = os.path.join(dname, 'myfile.opt') + opt._write_options_file(filename, opt.config.solver_options) + self.assertIn('option_file_name', opt.config.solver_options) + self.assertTrue(os.path.isfile(filename)) # Make sure all options are writing to the file opt = ipopt.Ipopt(solver_options={'custom_option_1': 4, 'custom_option_2': 3}) with TempfileManager.new_context() as temp: dname = temp.mkdtemp() if not os.path.exists(dname): os.mkdir(dname) - filename = os.path.join(dname, 'myfile') - result = opt._write_options_file(filename, opt.config.solver_options) - self.assertTrue(result) - self.assertTrue(os.path.isfile(filename + '.opt')) - with open(filename + '.opt', 'r') as f: + filename = os.path.join(dname, 'myfile.opt') + opt._write_options_file(filename, opt.config.solver_options) + self.assertIn('option_file_name', opt.config.solver_options) + self.assertTrue(os.path.isfile(filename)) + with open(filename, 'r') as f: data = f.readlines() - self.assertEqual(len(data), len(list(opt.config.solver_options.keys()))) + self.assertEqual( + len(data) + 1, len(list(opt.config.solver_options.keys())) + ) def test_has_linear_solver(self): opt = ipopt.Ipopt() @@ -379,17 +406,18 @@ def test_has_linear_solver(self): def test_create_command_line(self): opt = ipopt.Ipopt() # No custom options, no file created. Plain and simple. - result = opt._create_command_line('myfile', opt.config, False) + result = opt._create_command_line('myfile', opt.config) self.assertEqual(result, [str(opt.config.executable), 'myfile.nl', '-AMPL']) # Custom command line options opt = ipopt.Ipopt(solver_options={'max_iter': 4}) - result = opt._create_command_line('myfile', opt.config, False) + result = opt._create_command_line('myfile', opt.config) self.assertEqual( result, [str(opt.config.executable), 'myfile.nl', '-AMPL', 'max_iter=4'] ) # Let's see if we correctly parse config.time_limit opt = ipopt.Ipopt(solver_options={'max_iter': 4}, time_limit=10) - result = opt._create_command_line('myfile', opt.config, False) + opt._verify_ipopt_options(opt.config) + result = opt._create_command_line('myfile', opt.config) self.assertEqual( result, [ @@ -402,36 +430,18 @@ def test_create_command_line(self): ) # Now let's do multiple command line options opt = ipopt.Ipopt(solver_options={'max_iter': 4, 'max_cpu_time': 10}) - result = opt._create_command_line('myfile', opt.config, False) - self.assertEqual( - result, - [ - str(opt.config.executable), - 'myfile.nl', - '-AMPL', - 'max_cpu_time=10', - 'max_iter=4', - ], - ) - # Let's now include if we "have" an options file - result = opt._create_command_line('myfile', opt.config, True) + opt._verify_ipopt_options(opt.config) + result = opt._create_command_line('myfile', opt.config) self.assertEqual( result, [ str(opt.config.executable), 'myfile.nl', '-AMPL', - 'option_file_name=myfile.opt', 'max_cpu_time=10', 'max_iter=4', ], ) - # Finally, let's make sure it errors if someone tries to pass option_file_name - opt = ipopt.Ipopt( - solver_options={'max_iter': 4, 'option_file_name': 'myfile.opt'} - ) - with self.assertRaises(ValueError): - result = opt._create_command_line('myfile', opt.config, False) @unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") @@ -464,6 +474,8 @@ def test_ipopt_solve(self): # Gut check - does it solve? model = self.create_model() ipopt.Ipopt().solve(model) + self.assertAlmostEqual(model.x.value, 1) + self.assertAlmostEqual(model.y.value, 1) def test_ipopt_results(self): model = self.create_model() @@ -505,3 +517,111 @@ def test_ipopt_timer_object(self): else: # Newer version of IPOPT self.assertIn('IPOPT', timing_info.keys()) + + def test_ipopt_options_file(self): + # Check that the options file is getting to Ipopt: if we give it + # an invalid option in the options file, ipopt will fail. This + # is important, as ipopt will NOT fail if you pass if an + # option_file_name that does not exist. + model = self.create_model() + results = ipopt.Ipopt().solve( + model, + solver_options={'bogus_option': 5}, + raise_exception_on_nonoptimal_result=False, + load_solutions=False, + ) + self.assertEqual(results.termination_condition, TerminationCondition.error) + self.assertEqual(results.solution_status, SolutionStatus.noSolution) + self.assertIn('OPTION_INVALID', results.solver_log) + + # If the model name contains a quote, then the name needs + # to be quoted + model.name = "test'model'" + results = ipopt.Ipopt().solve( + model, + solver_options={'bogus_option': 5}, + raise_exception_on_nonoptimal_result=False, + load_solutions=False, + ) + self.assertEqual(results.termination_condition, TerminationCondition.error) + self.assertEqual(results.solution_status, SolutionStatus.noSolution) + self.assertIn('OPTION_INVALID', results.solver_log) + + model.name = 'test"model' + results = ipopt.Ipopt().solve( + model, + solver_options={'bogus_option': 5}, + raise_exception_on_nonoptimal_result=False, + load_solutions=False, + ) + self.assertEqual(results.termination_condition, TerminationCondition.error) + self.assertEqual(results.solution_status, SolutionStatus.noSolution) + self.assertIn('OPTION_INVALID', results.solver_log) + + # Because we are using universal=True for to_legal_filename, + # using both single and double quotes will be OK + model.name = 'test"\'model' + results = ipopt.Ipopt().solve( + model, + solver_options={'bogus_option': 5}, + raise_exception_on_nonoptimal_result=False, + load_solutions=False, + ) + self.assertEqual(results.termination_condition, TerminationCondition.error) + self.assertEqual(results.solution_status, SolutionStatus.noSolution) + self.assertIn('OPTION_INVALID', results.solver_log) + + if not is_windows: + # This test is not valid on Windows, as {"} is not a valid + # character in a directory name. + with TempfileManager.new_context() as temp: + dname = temp.mkdtemp() + working_dir = os.path.join(dname, '"foo"') + os.mkdir(working_dir) + with self.assertRaisesRegex(ValueError, 'single and double'): + results = ipopt.Ipopt().solve( + model, + working_dir=working_dir, + solver_options={'bogus_option': 5}, + raise_exception_on_nonoptimal_result=False, + load_solutions=False, + ) + + +@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") +class TestLegacyIpopt(unittest.TestCase): + def create_model(self): + model = pyo.ConcreteModel() + model.x = pyo.Var(initialize=1.5) + model.y = pyo.Var(initialize=1.5) + + @model.Objective(sense=pyo.minimize) + def rosenbrock(m): + return (1.0 - m.x) ** 2 + 100.0 * (m.y - m.x**2) ** 2 + + return model + + def test_map_OF_options(self): + model = self.create_model() + + with capture_output() as LOG: + results = ipopt.LegacyIpoptSolver().solve( + model, + tee=True, + solver_options={'OF_bogus_option': 5}, + load_solutions=False, + ) + print(LOG.getvalue()) + self.assertIn('OPTION_INVALID', LOG.getvalue()) + # Note: OF_ is stripped + self.assertIn( + 'Read Option: "bogus_option". It is not a valid option', LOG.getvalue() + ) + + with self.assertRaisesRegex(ValueError, "unallowed ipopt option 'wantsol'"): + results = ipopt.LegacyIpoptSolver().solve( + model, + tee=True, + solver_options={'OF_wantsol': False}, + load_solutions=False, + ) diff --git a/pyomo/opt/base/solvers.py b/pyomo/opt/base/solvers.py index 767f62d07c2..fb0a65d66b6 100644 --- a/pyomo/opt/base/solvers.py +++ b/pyomo/opt/base/solvers.py @@ -178,7 +178,8 @@ def __call__(self, _name=None, **kwds): return opt -LegacySolverFactory = SolverFactoryClass('solver type') +#: Global registry/factory for "v1" solver interfaces. +LegacySolverFactory: SolverFactoryClass = SolverFactoryClass('solver type') SolverFactory = SolverFactoryClass('solver type') SolverFactory._cls = LegacySolverFactory._cls