Source code for minizinc.CLI.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 re
import subprocess
import sys
import warnings
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, Set, Tuple, Type, Union

import minizinc

from ..driver import Driver
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, 0)


def to_python_type(mzn_type: dict) -> Type:
    """Converts MiniZinc JSON type to Type

    Converts a MiniZinc JSON type definition generated by the MiniZinc CLI to a
    Python Type object. This can be used on types that result from calling
    ``minizinc --model-interface-only``.

    Args:
        mzn_type (dict): MiniZinc type definition as resulting from JSON

    Returns:
        Type: Type definition in Python

    """
    basetype = mzn_type["type"]
    pytype: Type
    # TODO: MiniZinc does not report enumerated types correctly
    if basetype == "bool":
        pytype = bool
    elif basetype == "float":
        pytype = float
    elif basetype == "int":
        pytype = int
    elif basetype == "string":
        pytype = str
    elif basetype == "ann":
        pytype = str
    else:
        warnings.warn(
            f"Unable to determine minizinc type `{basetype}` assuming integer type",
            FutureWarning,
        )
        pytype = int

    if mzn_type.get("set", False):
        if pytype is int:
            pytype = Union[Set[int], range]  # type: ignore
        else:
            pytype = Set[pytype]  # type: ignore

    dim = mzn_type.get("dim", 0)
    while dim >= 1:
        # No typing support for n-dimensional typing
        pytype = List[pytype]  # type: ignore
        dim -= 1
    return pytype


[docs]class CLIDriver(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. Attributes: executable (Path): The path to the executable used to access the MiniZinc Driver """ _executable: Path _solver_cache: Optional[Dict[str, Solver]] = None _version: Optional[Tuple[int, ...]] = None def __init__(self, executable: Path): self._executable = executable assert self._executable.exists() super(CLIDriver, self).__init__() 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: from . import CLIInstance minizinc.default_driver = self minizinc.Instance = CLIInstance
def run( self, args: List[Any], solver: Optional[Solver] = None, ): # 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
[docs] 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
@property def minizinc_version(self) -> str: return self.run(["--version"]).stdout.decode() @property def parsed_version(self) -> Tuple[int, ...]: if self._version is None: output = subprocess.run( [str(self._executable), "--version"], stdin=None, stdout=PIPE, stderr=PIPE, ) match = re.search(rb"version (\d+)\.(\d+)\.(\d+)", output.stdout) assert match self._version = tuple([int(i) for i in match.groups()]) return self._version
[docs] def available_solvers(self, refresh=False): 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 = set([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