Source code for minizinc.driver

#  This Source Code Form is subject to the terms of the Mozilla Public
#  License, v. 2.0. If a copy of the MPL was not distributed with this
#  file, You can obtain one at http://mozilla.org/MPL/2.0/.

import os
import platform
import re
import shutil
import subprocess
import sys
from asyncio import create_subprocess_exec
from asyncio.subprocess import PIPE, Process
from dataclasses import fields
from json import loads
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

import minizinc

from .error import ConfigurationError, parse_error
from .json import decode_json_stream
from .solver import Solver

#: MiniZinc version required by the python package
CLI_REQUIRED_VERSION = (2, 5, 4)
#: Default locations on MacOS where the MiniZinc packaged release would be installed
MAC_LOCATIONS = [
    str(Path("/Applications/MiniZincIDE.app/Contents/Resources")),
    str(Path("~/Applications/MiniZincIDE.app/Contents/Resources").expanduser()),
]
#: Default locations on Windows where the MiniZinc packaged release would be installed
WIN_LOCATIONS = [
    str(Path("c:/Program Files/MiniZinc")),
    str(Path("c:/Program Files/MiniZinc IDE (bundled)")),
    str(Path("c:/Program Files (x86)/MiniZinc")),
    str(Path("c:/Program Files (x86)/MiniZinc IDE (bundled)")),
]


[docs]class Driver: """Driver that interfaces with MiniZinc through the command line interface. The command line driver will interact with MiniZinc and its solvers through the use of a ``minizinc`` executable. Driving MiniZinc using its executable is non-incremental and can often trigger full recompilation and might restart the solver from the beginning when changes are made to the instance. Raises: ConfigurationError: If an the driver version is found to be incompatible with MiniZinc Python Attributes: _executable (Path): The path to the executable used to access the MiniZinc """ _executable: Path _solver_cache: Optional[Dict[str, List[Solver]]] = None _version: Optional[Tuple[int, ...]] = None def __init__(self, executable: Path): self._executable = executable if not self._executable.exists(): raise ConfigurationError( f"No MiniZinc executable was found at '{self._executable}'." ) if self.parsed_version < CLI_REQUIRED_VERSION: raise ConfigurationError( f"The MiniZinc driver found at '{self._executable}' has " f"version {self.parsed_version}. The minimal required version is " f"{CLI_REQUIRED_VERSION}." )
[docs] def make_default(self) -> None: """Method to override the current default MiniZinc Python driver with the current driver. """ minizinc.default_driver = self
@property def executable(self) -> Path: """Reports the Path of the MiniZinc executable used by the Driver object Returns: Path: location of the MiniZinc executable """ return self._executable @property def minizinc_version(self) -> str: """Reports the version text of the MiniZinc Driver Report the full version text of MiniZinc as reported by the driver, including the driver name, the semantic version, the build reference, and its authors. Returns: str: the version of as reported by the MiniZinc driver """ # Note: cannot use "_run" as it already required the parsed version return subprocess.run( [str(self._executable), "--version"], stdin=None, stdout=PIPE, stderr=PIPE, ).stdout.decode() @property def parsed_version(self) -> Tuple[int, ...]: """Reports the version of the MiniZinc Driver Report the parsed version of the MiniZinc Driver as a tuple of integers. The tuple is ordered: major, minor, patch. Returns: Tuple[int, ...]: the parsd version reported by the MiniZinc driver """ if self._version is None: match = re.search(r"version (\d+)\.(\d+)\.(\d+)", self.minizinc_version) assert match self._version = tuple([int(i) for i in match.groups()]) return self._version
[docs] def available_solvers(self, refresh=False): """Returns a list of available solvers This method returns the list of solvers available to the Driver object according to the current environment. Note that the list of solvers might be cached for future usage. The refresh argument can be used to ignore the current cache. Args: refresh (bool): When set to true, the Driver will rediscover the available solvers from the current environment. Returns: Dict[str, List[Solver]]: A dictionary that maps solver tags to MiniZinc solver configurations that can be used with the Driver object. """ if not refresh and self._solver_cache is not None: return self._solver_cache # Find all available solvers output = self._run(["--solvers-json"]) solvers = loads(output.stdout) # Construct Solver objects self._solver_cache = {} allowed_fields = {f.name for f in fields(Solver)} for s in solvers: obj = Solver( **{key: value for (key, value) in s.items() if key in allowed_fields} ) if obj.version == "<unknown version>": obj._identifier = obj.id else: obj._identifier = obj.id + "@" + obj.version names = s.get("tags", []) names.extend([s["id"], s["id"].split(".")[-1]]) for name in names: self._solver_cache.setdefault(name, []).append(obj) return self._solver_cache
def _run( self, args: List[Any], solver: Optional[Solver] = None, ): """Start a driver process with given arguments Args: args (List[str]): direct arguments to the driver solver (Union[str, Path, None]): Solver configuration string guaranteed by the user to be valid until the process has ended. """ # TODO: Add documentation windows_spawn_options: Dict[str, Any] = {} if sys.platform == "win32": # On Windows, MiniZinc terminates its subprocesses by generating a # Ctrl+C event for its own console using GenerateConsoleCtrlEvent. # Therefore, we must spawn it in its own console to avoid receiving # that Ctrl+C ourselves. # # On POSIX systems, MiniZinc terminates its subprocesses by sending # SIGTERM to the solver's process group, so this workaround is not # necessary as we won't receive that signal. windows_spawn_options = { "startupinfo": subprocess.STARTUPINFO( dwFlags=subprocess.STARTF_USESHOWWINDOW, wShowWindow=subprocess.SW_HIDE, ), "creationflags": subprocess.CREATE_NEW_CONSOLE, } # TODO: Always add --json-stream once 2.6.0 is minimum requirement if self.parsed_version >= (2, 6, 0): args.append("--json-stream") if solver is None: cmd = [str(self._executable), "--allow-multiple-assignments"] + [ str(arg) for arg in args ] minizinc.logger.debug(f"CLIDriver:run -> command: \"{' '.join(cmd)}\"") output = subprocess.run( cmd, stdin=None, stdout=PIPE, stderr=PIPE, **windows_spawn_options, ) else: with solver.configuration() as conf: cmd = [ str(self._executable), "--solver", conf, "--allow-multiple-assignments", ] + [str(arg) for arg in args] minizinc.logger.debug(f"CLIDriver:run -> command: \"{' '.join(cmd)}\"") output = subprocess.run( cmd, stdin=None, stdout=PIPE, stderr=PIPE, **windows_spawn_options, ) if output.returncode != 0: if self.parsed_version >= (2, 6, 0): # Error will (usually) be raised in json stream for _ in decode_json_stream(output.stdout): pass raise parse_error(output.stderr) return output async def _create_process( self, args: List[str], solver: Optional[str] = None ) -> Process: """Start an asynchronous driver process with given arguments Args: args (List[str]): direct arguments to the driver solver (Union[str, Path, None]): Solver configuration string guaranteed by the user to be valid until the process has ended. """ windows_spawn_options: Dict[str, Any] = {} if sys.platform == "win32": # See corresponding comment in run() windows_spawn_options = { "startupinfo": subprocess.STARTUPINFO( dwFlags=subprocess.STARTF_USESHOWWINDOW, wShowWindow=subprocess.SW_HIDE, ), "creationflags": subprocess.CREATE_NEW_CONSOLE, } # TODO: Always add --json-stream once 2.6.0 is minimum requirement if self.parsed_version >= (2, 6, 0): args.append("--json-stream") if solver is None: minizinc.logger.debug( f"CLIDriver:create_process -> program: {str(self._executable)} " f'args: "--allow-multiple-assignments ' f"{' '.join(str(arg) for arg in args)}\"" ) proc = await create_subprocess_exec( str(self._executable), "--allow-multiple-assignments", *[str(arg) for arg in args], stdin=None, stdout=PIPE, stderr=PIPE, **windows_spawn_options, ) else: minizinc.logger.debug( f"CLIDriver:create_process -> program: {str(self._executable)} " f'args: "--solver {solver} --allow-multiple-assignments ' f"{' '.join(str(arg) for arg in args)}\"" ) proc = await create_subprocess_exec( str(self._executable), "--solver", solver, "--allow-multiple-assignments", *[str(arg) for arg in args], stdin=None, stdout=PIPE, stderr=PIPE, **windows_spawn_options, ) return proc
[docs] @classmethod def find( cls, path: Optional[List[str]] = None, name: str = "minizinc" ) -> Optional["Driver"]: """Finds MiniZinc Driver on default or specified path. Find driver will look for the MiniZinc executable to create a Driver for MiniZinc Python. If no path is specified, then the paths given by the environment variables appended by MiniZinc's default locations will be tried. Args: path: List of locations to search. name: Name of the executable. Returns: Optional[Driver]: Returns a Driver object when found or None. """ if path is None: path = os.environ.get("PATH", "").split(os.pathsep) # Add default MiniZinc locations to the path if platform.system() == "Darwin": path.extend(MAC_LOCATIONS) elif platform.system() == "Windows": path.extend(WIN_LOCATIONS) # Try to locate the MiniZinc executable executable = shutil.which(name, path=os.pathsep.join(path)) if executable is not None: return cls(Path(executable)) return None