Source code for spack.solver.compat

# Copyright Spack Project Developers. See COPYRIGHT file for details.
#
# SPDX-License-Identifier: (Apache-2.0 OR MIT)
"""Loads clingo and abstracts over the differences between its Python API variants.

Spack supports three clingo Python APIs: the pre-CFFI API, the CFFI-based API, and the clingo 6
rewrite, which moved everything into ``clingo.*`` submodules, replaced the top-level ``Control``
constructor with one that takes a shared ``Library`` plus CLI options, renamed backend methods
(``add_atom``/``add_rule`` to ``atom``/``rule``), and made statistics a lazy view that must be
``nestify()``-ed.

This module bootstraps/imports the right clingo and exposes a single uniform interface (a
legacy-shaped ``Control`` plus ``symbol_name`` / ``symbol_string`` helpers) so the rest of Spack
does not have to branch on the flavor in use.
"""

import enum
import functools
import importlib
import pathlib
from types import ModuleType
from typing import Any, Optional, Tuple

#: Process-global cache of the lazily-imported clingo module.
_CLINGO_MODULE: Optional[ModuleType] = None


[docs] def clingo() -> ModuleType: """Lazy imports the Python module for clingo, and returns it.""" if _CLINGO_MODULE is not None: return _CLINGO_MODULE try: clingo_mod = importlib.import_module("clingo") # Make sure we didn't import an empty module _ensure_clingo_or_raise(clingo_mod) except ImportError: clingo_mod = None if clingo_mod is not None: return _set_clingo_module_cache(clingo_mod) clingo_mod = _bootstrap_clingo() return _set_clingo_module_cache(clingo_mod)
def _set_clingo_module_cache(clingo_mod: ModuleType) -> ModuleType: """Cache the lazily-imported clingo module.""" global _CLINGO_MODULE importlib.import_module("clingo.ast") _CLINGO_MODULE = clingo_mod return clingo_mod def _ensure_clingo_or_raise(clingo_mod: ModuleType) -> None: """Ensures the clingo module can access expected attributes, otherwise raises an error.""" # These are imports that may be problematic at top level (circular imports). They are used # only to provide exhaustive details when erroring due to a broken clingo module. import spack.config import spack.paths as sp import spack.util.path as sup try: clingo_mod.Symbol except AttributeError: # clingo 6 moved Symbol into the clingo.symbol submodule try: if importlib.import_module("clingo.symbol").Symbol is not None: return except (ImportError, AttributeError): pass assert clingo_mod.__file__ is not None, "clingo installation is incomplete or invalid" # Reaching this point indicates a broken clingo installation # If Spack derived clingo, suggest user re-run bootstrap # if non-spack, suggest user investigate installation # assume Spack is not responsible for broken clingo msg = ( f"Clingo installation at {clingo_mod.__file__} is incomplete or invalid." "Please repair installation or re-install. " "Alternatively, consider installing clingo via Spack." ) # check whether Spack is responsible if ( pathlib.Path( sup.canonicalize_path( spack.config.CONFIG.get("bootstrap:root", sp.default_user_bootstrap_path) ) ) in pathlib.Path(clingo_mod.__file__).parents ): # Spack is responsible for the broken clingo msg = ( "Spack bootstrapped copy of Clingo is broken, " "please re-run the bootstrapping process via command `spack bootstrap now`." " If this issue persists, please file a bug at: github.com/spack/spack" ) raise RuntimeError( "Clingo installation may be broken or incomplete, " "please verify clingo has been installed correctly" "\n\nClingo does not provide symbol clingo.Symbol" f"{msg}" ) def _bootstrap_clingo() -> ModuleType: """Bootstraps the clingo module and returns it""" import spack.bootstrap with spack.bootstrap.ensure_bootstrap_configuration(): spack.bootstrap.ensure_clingo_importable_or_raise() clingo_mod = importlib.import_module("clingo") return clingo_mod
[docs] class ClingoFlavor(enum.Enum): """The clingo Python API variant in use. Spack supports three: the legacy pre-CFFI API, the CFFI-based API (clingo ``@5.5:5``), and the clingo 6 rewrite, which restructured everything into submodules ``clingo.*``.""" LEGACY = enum.auto() CFFI = enum.auto() V6 = enum.auto()
def _detect_clingo_flavor(clingo_mod: ModuleType) -> ClingoFlavor: """Determine which of the three supported clingo Python APIs is in use.""" if not hasattr(clingo_mod, "Control"): # clingo 6 dropped the top-level Control/Symbol. return ClingoFlavor.V6 if hasattr(getattr(clingo_mod, "Symbol", None), "_rep"): return ClingoFlavor.CFFI return ClingoFlavor.LEGACY
[docs] @functools.lru_cache(maxsize=None) def clingo_flavor() -> ClingoFlavor: """Return the :class:`ClingoFlavor` of the loaded clingo module (detected once).""" return _detect_clingo_flavor(clingo())
[docs] @functools.lru_cache(maxsize=None) def clingo_library() -> Any: """Return a process-global ``clingo.core.Library`` (clingo 6 only). A single shared library lets symbols produced by one control object be reused by another (e.g. when ``raise_if_errors`` feeds a model from the main solve into a second control). """ clingo() # ensure the clingo module is importable / bootstrapped return importlib.import_module("clingo.core").Library()
[docs] def symbol_name(sym: Any) -> Optional[str]: """Return ``sym.name`` if ``sym`` is a function symbol, otherwise ``None``. Non-function symbols raise ``RuntimeError`` on clingo+CFFI and ``ValueError`` on clingo 6; legacy clingo returns an empty string. """ try: return sym.name or None except (RuntimeError, ValueError): return None
[docs] def symbol_string(sym: Any) -> str: """Return ``sym.string`` for a string symbol, otherwise ``str(sym)``.""" if clingo_flavor() is ClingoFlavor.CFFI: # CFFI throws RuntimeError on ".string" for non-string symbols. try: return sym.string except RuntimeError: return str(sym) # Legacy returns "" for non-string symbols; clingo 6 raises ValueError. try: return sym.string or str(sym) except (RuntimeError, ValueError): return str(sym)
class _ClingoBackend: """Context manager adapting the clingo 6 backend to the legacy interface.""" __slots__ = ("_manager", "_backend") def __init__(self, manager: Any) -> None: self._manager = manager self._backend: Any = None def __enter__(self) -> "_ClingoBackend": self._backend = self._manager.__enter__() return self def __exit__(self, *exc_info) -> Any: return self._manager.__exit__(*exc_info) def add_atom(self, symbol: Any = None) -> int: return self._backend.atom(symbol) def add_rule(self, head: Any, body: Any = (), choice: bool = False) -> None: self._backend.rule(head, body, choice) class _ClingoV6Control: """Adapter exposing the legacy clingo ``Control`` interface on top of the restructured clingo 6 Python API. Only the subset of the API used by Spack's solver is implemented; instantiate via :func:`default_clingo_control` / :func:`make_error_control`.""" __slots__ = ("_control",) def __init__(self, options: Tuple[str, ...] = ()) -> None: control_mod = importlib.import_module("clingo.control") self._control = control_mod.Control(clingo_library(), list(options)) def add(self, name: str, parameters: Tuple[str, ...], program: str) -> None: # Spack only ever adds the implicit "base" part without parameters. self._control.parse_string(program) def load(self, path: str) -> None: self._control.parse_files([path]) def ground(self, parts: Any) -> None: self._control.ground([(name, list(args)) for name, args in parts]) def solve(self, on_model: Any = None, async_: bool = False) -> Any: if async_: return self._control.start_solve(on_model=on_model, async_=True) return self._control.solve(on_model=on_model) def backend(self) -> _ClingoBackend: return _ClingoBackend(self._control.backend) @property def statistics(self) -> Any: # clingo 6 returns a lazy StatsView; nestify() yields the plain dict older versions did. return self._control.stats.nestify()
[docs] def default_clingo_control() -> Any: """Return a control object configured with Spack's default solver settings.""" if clingo_flavor() is ClingoFlavor.V6: # clingo 6 has no `.configuration` API; pass equivalents as CLI options. return _ClingoV6Control( ("--configuration=tweety", "--heuristic=Domain", "--opt-strategy=usc") ) control = clingo().Control() control.configuration.configuration = "tweety" control.configuration.solver.heuristic = "Domain" control.configuration.solver.opt_strategy = "usc" return control
[docs] def make_error_control() -> Any: """Return a plain control object, used to derive error causation on unsat.""" if clingo_flavor() is ClingoFlavor.V6: return _ClingoV6Control() return clingo().Control()