# 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