flamapy-fw 2.6.0.dev11__tar.gz → 2.9.0.dev0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {flamapy_fw-2.6.0.dev11/flamapy_fw.egg-info → flamapy_fw-2.9.0.dev0}/PKG-INFO +1 -1
- flamapy_fw-2.9.0.dev0/flamapy/core/cache.py +141 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/discover.py +84 -21
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/exceptions.py +8 -0
- flamapy_fw-2.9.0.dev0/flamapy/core/execution.py +206 -0
- flamapy_fw-2.9.0.dev0/flamapy/core/manifest.py +110 -0
- flamapy_fw-2.9.0.dev0/flamapy/core/models/__init__.py +11 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/models/ast.py +39 -9
- flamapy_fw-2.9.0.dev0/flamapy/core/models/language_level.py +36 -0
- flamapy_fw-2.9.0.dev0/flamapy/core/models/variability_model.py +22 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/operations/__init__.py +4 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/operations/abstract_operation.py +5 -0
- flamapy_fw-2.9.0.dev0/flamapy/core/operations/atomic_sets.py +47 -0
- flamapy_fw-2.9.0.dev0/flamapy/core/operations/commonality.py +51 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/operations/configurations.py +3 -2
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/operations/configurations_number.py +2 -1
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/operations/core_features.py +2 -1
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/operations/dead_features.py +2 -1
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/operations/descriptor.py +1 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/operations/false_optional_features.py +2 -1
- flamapy_fw-2.9.0.dev0/flamapy/core/operations/metrics_operation.py +180 -0
- flamapy_fw-2.9.0.dev0/flamapy/core/operations/result.py +42 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/operations/satisfiable.py +2 -1
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/operations/satisfiable_configuration.py +2 -1
- flamapy_fw-2.9.0.dev0/flamapy/core/operations/streaming.py +21 -0
- flamapy_fw-2.9.0.dev0/flamapy/core/planner.py +257 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/plugins.py +66 -6
- flamapy_fw-2.9.0.dev0/flamapy/core/reasoning/__init__.py +12 -0
- flamapy_fw-2.9.0.dev0/flamapy/core/reasoning/fastdiag.py +95 -0
- flamapy_fw-2.9.0.dev0/flamapy/core/reasoning/hsdag/__init__.py +4 -0
- flamapy_fw-2.9.0.dev0/flamapy/core/reasoning/hsdag/hsdag.py +315 -0
- flamapy_fw-2.9.0.dev0/flamapy/core/reasoning/hsdag/labeler/__init__.py +6 -0
- flamapy_fw-2.9.0.dev0/flamapy/core/reasoning/hsdag/labeler/fastdiag_labeler.py +71 -0
- flamapy_fw-2.9.0.dev0/flamapy/core/reasoning/hsdag/labeler/labeler.py +49 -0
- flamapy_fw-2.9.0.dev0/flamapy/core/reasoning/hsdag/labeler/quickxplain_labeler.py +69 -0
- flamapy_fw-2.9.0.dev0/flamapy/core/reasoning/hsdag/node.py +78 -0
- flamapy_fw-2.9.0.dev0/flamapy/core/reasoning/protocols.py +18 -0
- flamapy_fw-2.9.0.dev0/flamapy/core/reasoning/quickxplain.py +88 -0
- flamapy_fw-2.9.0.dev0/flamapy/core/reasoning/utils.py +46 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/transformations/__init__.py +3 -1
- flamapy_fw-2.9.0.dev0/flamapy/core/transformations/cacheable.py +31 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0/flamapy_fw.egg-info}/PKG-INFO +1 -1
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy_fw.egg-info/SOURCES.txt +20 -0
- flamapy_fw-2.9.0.dev0/flamapy_fw.egg-info/entry_points.txt +5 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/pyproject.toml +4 -1
- flamapy_fw-2.6.0.dev11/flamapy/core/models/__init__.py +0 -4
- flamapy_fw-2.6.0.dev11/flamapy/core/models/variability_model.py +0 -13
- flamapy_fw-2.6.0.dev11/flamapy/core/operations/atomic_sets.py +0 -18
- flamapy_fw-2.6.0.dev11/flamapy/core/operations/commonality.py +0 -18
- flamapy_fw-2.6.0.dev11/flamapy/core/operations/metrics_operation.py +0 -125
- flamapy_fw-2.6.0.dev11/flamapy_fw.egg-info/entry_points.txt +0 -2
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/LICENSE +0 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/README.md +0 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/__init__.py +0 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/config.py +0 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/operations/attribute_optimization.py +0 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/operations/average_branching_factor.py +0 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/operations/count_leafs.py +0 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/operations/error_detection.py +0 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/operations/error_diagnosis.py +0 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/operations/estimated_configurations_number.py +0 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/operations/filter.py +0 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/operations/sampling.py +0 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/operations/variability.py +0 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/transformations/abstract_transformation.py +0 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/transformations/model_to_model.py +0 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/transformations/model_to_text.py +0 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/transformations/text_to_model.py +0 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/utils.py +0 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/metamodels/configuration_metamodel/__init__.py +0 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/metamodels/configuration_metamodel/models/__init__.py +0 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/metamodels/configuration_metamodel/models/configuration.py +0 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/metamodels/configuration_metamodel/transformations/__init__.py +0 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/metamodels/configuration_metamodel/transformations/configuration_basic_reader.py +0 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/metamodels/configuration_metamodel/transformations/configuration_basic_writer.py +0 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/metamodels/configuration_metamodel/transformations/configuration_json_reader.py +0 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/metamodels/configuration_metamodel/transformations/configuration_json_writer.py +0 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/metamodels/configuration_metamodel/transformations/uvls_json_reader.py +0 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/metamodels/configuration_metamodel/transformations/uvls_json_writer.py +0 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy_fw.egg-info/dependency_links.txt +0 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy_fw.egg-info/requires.txt +0 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy_fw.egg-info/top_level.txt +0 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/setup.cfg +0 -0
- {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/setup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: flamapy-fw
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.9.0.dev0
|
|
4
4
|
Summary: Flamapy is a Python-based AAFM framework that takes into consideration previous AAFM tool designs and enables multi-solver and multi-metamodel support for the integration of AAFM tooling on the Python ecosystem.
|
|
5
5
|
Author-email: Flamapy <flamapy@us.es>
|
|
6
6
|
License-Expression: GPL-3.0-or-later
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Compilation-artifact cache: compile once, reuse across operations and sessions.
|
|
2
|
+
|
|
3
|
+
Opt-in in 2.9: nothing is written unless ``FLAMAPY_CACHE=1`` (3.0 flips this to an opt-out
|
|
4
|
+
``FLAMAPY_NO_CACHE``). Cache directory precedence: ``FLAMAPY_CACHE_DIR`` >
|
|
5
|
+
``$XDG_CACHE_HOME/flamapy`` > ``~/.flamapy/cache``. Artifacts are keyed by the model's
|
|
6
|
+
canonical hash, the target extension, the transformation's options and format version, and
|
|
7
|
+
the plugin version — and stored under ``<hash16>-<extension>-<digest>.<suffix>`` so
|
|
8
|
+
availability by (model, extension) is a glob away (the planner's ``artifact_available``
|
|
9
|
+
hook). Writes are atomic (``os.replace``); the cache is pruned LRU to
|
|
10
|
+
``FLAMAPY_CACHE_MAX_MB`` (default 500). A corrupt artifact is never fatal: loads fall back
|
|
11
|
+
to recomputation.
|
|
12
|
+
"""
|
|
13
|
+
import hashlib
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import tempfile
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Optional
|
|
19
|
+
|
|
20
|
+
LOGGER = logging.getLogger('flamapy.cache')
|
|
21
|
+
|
|
22
|
+
DEFAULT_MAX_MB = 500
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def enabled() -> bool:
|
|
26
|
+
return os.environ.get('FLAMAPY_CACHE') == '1'
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def directory() -> Path:
|
|
30
|
+
explicit = os.environ.get('FLAMAPY_CACHE_DIR')
|
|
31
|
+
if explicit:
|
|
32
|
+
return Path(explicit)
|
|
33
|
+
xdg = os.environ.get('XDG_CACHE_HOME')
|
|
34
|
+
if xdg:
|
|
35
|
+
return Path(xdg) / 'flamapy'
|
|
36
|
+
return Path.home() / '.flamapy' / 'cache'
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def artifact_key(
|
|
40
|
+
model_hash: str,
|
|
41
|
+
target_extension: str,
|
|
42
|
+
options: dict[str, Any],
|
|
43
|
+
format_version: int,
|
|
44
|
+
plugin_version: str,
|
|
45
|
+
) -> str:
|
|
46
|
+
canonical = '\x1f'.join([
|
|
47
|
+
model_hash, target_extension,
|
|
48
|
+
';'.join(f'{key}={options[key]}' for key in sorted(options)),
|
|
49
|
+
str(format_version), plugin_version,
|
|
50
|
+
])
|
|
51
|
+
return hashlib.sha256(canonical.encode('utf-8')).hexdigest()[:32]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def artifact_path(
|
|
55
|
+
model_hash: str, target_extension: str, key: str, suffix: str
|
|
56
|
+
) -> Path:
|
|
57
|
+
return directory() / f'{model_hash[:16]}-{target_extension}-{key}.{suffix}'
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def lookup(path: Path) -> Optional[Path]:
|
|
61
|
+
"""The artifact path if it exists (touching it for LRU), else None."""
|
|
62
|
+
if not path.is_file():
|
|
63
|
+
return None
|
|
64
|
+
try:
|
|
65
|
+
path.touch()
|
|
66
|
+
except OSError:
|
|
67
|
+
pass
|
|
68
|
+
return path
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def store(path: Path, write: Any) -> None:
|
|
72
|
+
"""Atomically create ``path`` by calling ``write(temporary_path)`` and renaming."""
|
|
73
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
handle, temporary = tempfile.mkstemp(dir=path.parent, suffix='.tmp')
|
|
75
|
+
os.close(handle)
|
|
76
|
+
try:
|
|
77
|
+
write(temporary)
|
|
78
|
+
os.replace(temporary, path)
|
|
79
|
+
except Exception: # pylint: disable=broad-except
|
|
80
|
+
LOGGER.exception('Could not store cache artifact %s', path)
|
|
81
|
+
Path(temporary).unlink(missing_ok=True)
|
|
82
|
+
return
|
|
83
|
+
prune()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def available_for(model_hash: Optional[str], target_extension: str) -> bool:
|
|
87
|
+
"""Whether any artifact exists for (model, extension) — the planner's hook."""
|
|
88
|
+
if not enabled() or not model_hash or not directory().is_dir():
|
|
89
|
+
return False
|
|
90
|
+
pattern = f'{model_hash[:16]}-{target_extension}-*'
|
|
91
|
+
return any(directory().glob(pattern))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def prune(max_mb: Optional[int] = None) -> None:
|
|
95
|
+
"""Drop least-recently-used artifacts until the cache fits the size budget."""
|
|
96
|
+
if max_mb is None:
|
|
97
|
+
try:
|
|
98
|
+
max_mb = int(os.environ.get('FLAMAPY_CACHE_MAX_MB', DEFAULT_MAX_MB))
|
|
99
|
+
except ValueError:
|
|
100
|
+
max_mb = DEFAULT_MAX_MB
|
|
101
|
+
base = directory()
|
|
102
|
+
if not base.is_dir():
|
|
103
|
+
return
|
|
104
|
+
files = [(path, path.stat()) for path in base.iterdir() if path.is_file()]
|
|
105
|
+
total = sum(stat.st_size for _, stat in files)
|
|
106
|
+
budget = max_mb * 1024 * 1024
|
|
107
|
+
if total <= budget:
|
|
108
|
+
return
|
|
109
|
+
for path, stat in sorted(files, key=lambda item: item[1].st_mtime):
|
|
110
|
+
try:
|
|
111
|
+
path.unlink()
|
|
112
|
+
except OSError:
|
|
113
|
+
continue
|
|
114
|
+
total -= stat.st_size
|
|
115
|
+
if total <= budget:
|
|
116
|
+
break
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def info() -> dict[str, Any]:
|
|
120
|
+
base = directory()
|
|
121
|
+
files = [path for path in base.iterdir() if path.is_file()] if base.is_dir() else []
|
|
122
|
+
return {
|
|
123
|
+
'enabled': enabled(),
|
|
124
|
+
'directory': str(base),
|
|
125
|
+
'artifacts': len(files),
|
|
126
|
+
'size_mb': round(sum(path.stat().st_size for path in files) / (1024 * 1024), 2),
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def clear() -> int:
|
|
131
|
+
base = directory()
|
|
132
|
+
removed = 0
|
|
133
|
+
if base.is_dir():
|
|
134
|
+
for path in base.iterdir():
|
|
135
|
+
if path.is_file():
|
|
136
|
+
try:
|
|
137
|
+
path.unlink()
|
|
138
|
+
removed += 1
|
|
139
|
+
except OSError:
|
|
140
|
+
continue
|
|
141
|
+
return removed
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import inspect
|
|
2
2
|
import logging
|
|
3
|
+
import warnings
|
|
3
4
|
from importlib import import_module
|
|
5
|
+
from importlib.metadata import EntryPoint, entry_points as metadata_entry_points
|
|
4
6
|
from pkgutil import iter_modules
|
|
5
7
|
from types import ModuleType
|
|
6
|
-
from typing import Any, Optional, Protocol, Type, runtime_checkable, cast
|
|
8
|
+
from typing import Any, Iterable, Optional, Protocol, Type, runtime_checkable, cast
|
|
7
9
|
|
|
8
10
|
from flamapy.core.config import PLUGIN_PATHS
|
|
9
11
|
from flamapy.core.exceptions import OperationNotFound
|
|
@@ -22,6 +24,12 @@ from flamapy.metamodels.configuration_metamodel.models.configuration import Conf
|
|
|
22
24
|
|
|
23
25
|
LOGGER = logging.getLogger("discover")
|
|
24
26
|
|
|
27
|
+
# Plugins declare themselves via an entry point in this group whose value is the metamodel
|
|
28
|
+
# package (e.g. ``pysat = "flamapy.metamodels.pysat_metamodel"``). The namespace scan over
|
|
29
|
+
# PLUGIN_PATHS is kept as a fallback for plugins that predate entry points; it will be removed
|
|
30
|
+
# in flamapy 3.0 (breaking removals belong in the major).
|
|
31
|
+
ENTRY_POINT_GROUP = "flamapy.plugins"
|
|
32
|
+
|
|
25
33
|
|
|
26
34
|
@runtime_checkable
|
|
27
35
|
class OperationWithConfiguration(Protocol):
|
|
@@ -41,10 +49,29 @@ def filter_modules_from_plugin_paths() -> list[ModuleType]:
|
|
|
41
49
|
|
|
42
50
|
|
|
43
51
|
class DiscoverMetamodels:
|
|
44
|
-
|
|
45
|
-
|
|
52
|
+
"""Discovers installed plugins, preferring ``flamapy.plugins`` entry points.
|
|
53
|
+
|
|
54
|
+
Plugins without an entry point are still found through the legacy namespace scan over
|
|
55
|
+
``PLUGIN_PATHS`` (with a DeprecationWarning — the scan is removed in flamapy 3.0). Passing
|
|
56
|
+
``entry_points`` explicitly takes full control of discovery (no scan), which is how tests
|
|
57
|
+
inject synthetic plugins.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
_instance: Optional["DiscoverMetamodels"] = None
|
|
61
|
+
|
|
62
|
+
def __init__(self, entry_points: Optional[Iterable[EntryPoint]] = None) -> None:
|
|
63
|
+
self._injected_entry_points = (
|
|
64
|
+
list(entry_points) if entry_points is not None else None
|
|
65
|
+
)
|
|
46
66
|
self.plugins: Plugins = self.discover()
|
|
47
67
|
|
|
68
|
+
@classmethod
|
|
69
|
+
def instance(cls) -> "DiscoverMetamodels":
|
|
70
|
+
"""A process-wide shared instance, avoiding redundant discovery scans."""
|
|
71
|
+
if cls._instance is None:
|
|
72
|
+
cls._instance = cls()
|
|
73
|
+
return cls._instance
|
|
74
|
+
|
|
48
75
|
def search_classes(self, module: ModuleType) -> list[Any]:
|
|
49
76
|
classes = []
|
|
50
77
|
for _, file_name, ispkg in iter_modules(module.__path__, module.__name__ + "."):
|
|
@@ -60,29 +87,65 @@ class DiscoverMetamodels:
|
|
|
60
87
|
|
|
61
88
|
return classes
|
|
62
89
|
|
|
90
|
+
def _build_plugin(self, module: ModuleType) -> Plugin:
|
|
91
|
+
plugin = Plugin(module=module)
|
|
92
|
+
for _, _class in self.search_classes(module):
|
|
93
|
+
if not _class.__module__.startswith(module.__package__):
|
|
94
|
+
continue # Exclude modules not in current package
|
|
95
|
+
inherit = _class.mro()
|
|
96
|
+
|
|
97
|
+
if Operation in inherit:
|
|
98
|
+
plugin.append_operation(_class)
|
|
99
|
+
elif Transformation in inherit:
|
|
100
|
+
plugin.append_transformations(_class)
|
|
101
|
+
elif VariabilityModel in inherit:
|
|
102
|
+
plugin.variability_model = _class
|
|
103
|
+
return plugin
|
|
104
|
+
|
|
63
105
|
def discover(self) -> Plugins:
|
|
64
106
|
plugins = Plugins()
|
|
65
|
-
|
|
107
|
+
covered_modules: set[str] = set()
|
|
108
|
+
|
|
109
|
+
if self._injected_entry_points is not None:
|
|
110
|
+
found_entry_points: list[EntryPoint] = self._injected_entry_points
|
|
111
|
+
else:
|
|
112
|
+
found_entry_points = list(metadata_entry_points(group=ENTRY_POINT_GROUP))
|
|
113
|
+
|
|
114
|
+
for entry_point in found_entry_points:
|
|
115
|
+
try:
|
|
116
|
+
module = entry_point.load()
|
|
117
|
+
except Exception: # pylint: disable=broad-except
|
|
118
|
+
# The distribution is installed but its package is not importable in this
|
|
119
|
+
# environment (e.g. a shadowed namespace during another package's test run).
|
|
120
|
+
LOGGER.exception(
|
|
121
|
+
"Could not load plugin entry point %s=%s", entry_point.name,
|
|
122
|
+
entry_point.value)
|
|
123
|
+
continue
|
|
124
|
+
if module.__name__ in covered_modules:
|
|
125
|
+
continue
|
|
126
|
+
covered_modules.add(module.__name__)
|
|
127
|
+
plugins.append(self._build_plugin(module))
|
|
128
|
+
|
|
129
|
+
if self._injected_entry_points is not None:
|
|
130
|
+
return plugins
|
|
131
|
+
|
|
132
|
+
scan_only: list[str] = []
|
|
133
|
+
for pkg in filter_modules_from_plugin_paths():
|
|
66
134
|
for _, plugin_name, ispkg in iter_modules(pkg.__path__, pkg.__name__ + "."):
|
|
67
|
-
if not ispkg:
|
|
135
|
+
if not ispkg or plugin_name in covered_modules:
|
|
68
136
|
continue
|
|
69
137
|
module = import_module(plugin_name)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
elif Transformation in inherit:
|
|
82
|
-
plugin.append_transformations(_class)
|
|
83
|
-
elif VariabilityModel in inherit:
|
|
84
|
-
plugin.variability_model = _class
|
|
85
|
-
plugins.append(plugin)
|
|
138
|
+
covered_modules.add(plugin_name)
|
|
139
|
+
plugins.append(self._build_plugin(module))
|
|
140
|
+
scan_only.append(plugin_name)
|
|
141
|
+
|
|
142
|
+
if scan_only:
|
|
143
|
+
warnings.warn(
|
|
144
|
+
f"Plugins discovered via the namespace scan without a '{ENTRY_POINT_GROUP}' "
|
|
145
|
+
f"entry point: {', '.join(sorted(scan_only))}. Declare the entry point in the "
|
|
146
|
+
"plugin's packaging metadata; the namespace-scan fallback will be removed in "
|
|
147
|
+
"flamapy 3.0.",
|
|
148
|
+
DeprecationWarning, stacklevel=2)
|
|
86
149
|
return plugins
|
|
87
150
|
|
|
88
151
|
def reload(self) -> None:
|
|
@@ -28,3 +28,11 @@ class DuplicatedFeature(FlamaException):
|
|
|
28
28
|
|
|
29
29
|
class ConfigurationNotFound(FlamaException):
|
|
30
30
|
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class NoCompatibleBackendError(FlamaException):
|
|
34
|
+
"""The planner found no installed plugin able to run an operation on a model."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class OperationTimeout(FlamaException):
|
|
38
|
+
"""An asynchronously executed operation exceeded its wall-clock deadline."""
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Execution helpers: the OperationResult envelope and the async execution engine.
|
|
2
|
+
|
|
3
|
+
This is the seam where provenance is captured: callers that want the envelope (the CLI's
|
|
4
|
+
``-v`` flag, the benchmark harness, and — from 3.0 — the facade itself) run operations
|
|
5
|
+
through :func:`run_and_wrap` instead of calling ``execute``/``get_result`` directly.
|
|
6
|
+
``Operation.execute/get_result`` are untouched: plugin authors need no migration.
|
|
7
|
+
|
|
8
|
+
Since 2.9 the module also hosts the additive async API: :class:`ExecutionEngine` runs
|
|
9
|
+
operations on a thread pool (models are not picklable; the SAT/BDD/SMT solvers release
|
|
10
|
+
the GIL inside their C cores) and hands back :class:`Job` objects with timeout and
|
|
11
|
+
cancellation. Cancellation is **cooperative**: operations that implement the
|
|
12
|
+
:class:`Interruptible` protocol check the interrupt event inside their Python loops, but
|
|
13
|
+
a call that is deep inside solver C code cannot be interrupted — the job is abandoned
|
|
14
|
+
(its thread keeps running to completion) and only the result is discarded.
|
|
15
|
+
"""
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from concurrent.futures import Future
|
|
19
|
+
from concurrent.futures import TimeoutError as FutureTimeoutError
|
|
20
|
+
from enum import Enum
|
|
21
|
+
from typing import Any, Callable, Optional, Protocol, runtime_checkable
|
|
22
|
+
|
|
23
|
+
from flamapy.core.exceptions import OperationTimeout
|
|
24
|
+
from flamapy.core.models import VariabilityModel
|
|
25
|
+
from flamapy.core.operations import Operation
|
|
26
|
+
from flamapy.core.operations.result import OperationResult
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def run_and_wrap(
|
|
30
|
+
operation: Operation,
|
|
31
|
+
model: Optional[VariabilityModel],
|
|
32
|
+
*,
|
|
33
|
+
operation_name: Optional[str] = None,
|
|
34
|
+
plugin: Optional[str] = None,
|
|
35
|
+
model_hash: Optional[str] = None,
|
|
36
|
+
) -> OperationResult[Any]:
|
|
37
|
+
"""Execute ``operation`` on ``model`` and wrap value + provenance in an envelope.
|
|
38
|
+
|
|
39
|
+
``model_hash`` may be passed pre-computed (the facade memoizes it per model);
|
|
40
|
+
otherwise it is taken from ``model.canonical_hash()``. ``model`` is None for
|
|
41
|
+
producer operations, which take no input model.
|
|
42
|
+
"""
|
|
43
|
+
if model_hash is None and model is not None:
|
|
44
|
+
model_hash = model.canonical_hash()
|
|
45
|
+
start = time.perf_counter()
|
|
46
|
+
operation.execute(model) # type: ignore[arg-type]
|
|
47
|
+
wall_time = time.perf_counter() - start
|
|
48
|
+
return OperationResult(
|
|
49
|
+
value=operation.get_result(),
|
|
50
|
+
operation=operation_name or type(operation).__name__,
|
|
51
|
+
plugin=plugin,
|
|
52
|
+
backend=model.get_extension() if model is not None else None,
|
|
53
|
+
exact=getattr(operation, 'exact', True),
|
|
54
|
+
model_hash=model_hash,
|
|
55
|
+
wall_time=wall_time,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@runtime_checkable
|
|
60
|
+
class Interruptible(Protocol):
|
|
61
|
+
"""Operations that can be cancelled cooperatively.
|
|
62
|
+
|
|
63
|
+
The engine hands the operation an event before executing; long-running Python loops
|
|
64
|
+
should check ``event.is_set()`` and stop early (returning a partial or empty result).
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def set_interrupt_event(self, event: threading.Event) -> None:
|
|
68
|
+
... # pylint: disable=unnecessary-ellipsis
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class JobStatus(str, Enum):
|
|
72
|
+
PENDING = 'pending'
|
|
73
|
+
RUNNING = 'running'
|
|
74
|
+
COMPLETED = 'completed'
|
|
75
|
+
FAILED = 'failed'
|
|
76
|
+
CANCELLED = 'cancelled'
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Job:
|
|
80
|
+
"""A handle to an asynchronously executing operation."""
|
|
81
|
+
|
|
82
|
+
def __init__(self, future: 'Future[OperationResult[Any]]',
|
|
83
|
+
interrupt_event: threading.Event,
|
|
84
|
+
result_adapter: Optional[Callable[[Any], Any]] = None) -> None:
|
|
85
|
+
self._future = future
|
|
86
|
+
self._interrupt_event = interrupt_event
|
|
87
|
+
self._result_adapter = result_adapter
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def status(self) -> JobStatus:
|
|
91
|
+
if self._future.cancelled() or self._interrupt_event.is_set():
|
|
92
|
+
return JobStatus.CANCELLED
|
|
93
|
+
if self._future.done():
|
|
94
|
+
return JobStatus.FAILED if self._future.exception() else JobStatus.COMPLETED
|
|
95
|
+
return JobStatus.RUNNING if self._future.running() else JobStatus.PENDING
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def cancelled(self) -> bool:
|
|
99
|
+
return self.status is JobStatus.CANCELLED
|
|
100
|
+
|
|
101
|
+
def cancel(self) -> None:
|
|
102
|
+
"""Request cancellation: cooperative for running jobs, immediate for queued ones."""
|
|
103
|
+
self._interrupt_event.set()
|
|
104
|
+
self._future.cancel()
|
|
105
|
+
|
|
106
|
+
def envelope(self, timeout: Optional[float] = None) -> OperationResult[Any]:
|
|
107
|
+
"""The full OperationResult (blocking). See :meth:`result` for the value only."""
|
|
108
|
+
try:
|
|
109
|
+
return self._future.result(timeout)
|
|
110
|
+
except FutureTimeoutError as error:
|
|
111
|
+
self.cancel()
|
|
112
|
+
raise OperationTimeout(
|
|
113
|
+
f'Operation did not finish within {timeout} seconds '
|
|
114
|
+
'(cancellation requested; a call inside solver C code cannot be '
|
|
115
|
+
'interrupted and is abandoned).') from error
|
|
116
|
+
|
|
117
|
+
def result(self, timeout: Optional[float] = None) -> Any:
|
|
118
|
+
"""The operation's value, waiting up to ``timeout`` seconds.
|
|
119
|
+
|
|
120
|
+
Raises :class:`OperationTimeout` on expiry (after requesting cancellation) and
|
|
121
|
+
re-raises any exception the operation itself raised.
|
|
122
|
+
"""
|
|
123
|
+
value = self.envelope(timeout).value
|
|
124
|
+
return self._result_adapter(value) if self._result_adapter is not None else value
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class ExecutionEngine:
|
|
128
|
+
"""Runs operations asynchronously on bounded daemon worker threads.
|
|
129
|
+
|
|
130
|
+
Daemon threads on purpose: a cancelled job whose operation is stuck inside solver C
|
|
131
|
+
code is *abandoned* — it must never block interpreter exit (a plain
|
|
132
|
+
``ThreadPoolExecutor`` joins its workers at exit, which would hang the process on such
|
|
133
|
+
a job forever). Abandoned jobs simply die with the process; their results are
|
|
134
|
+
discarded.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
def __init__(self, max_workers: Optional[int] = None) -> None:
|
|
138
|
+
import os # noqa: PLC0415
|
|
139
|
+
self._slots = threading.BoundedSemaphore(max_workers or (os.cpu_count() or 4))
|
|
140
|
+
|
|
141
|
+
# pylint: disable=too-many-arguments
|
|
142
|
+
def submit( # noqa: PLR0913 — keyword-only submission inputs are the documented API
|
|
143
|
+
self,
|
|
144
|
+
operation: Operation,
|
|
145
|
+
model: Optional[VariabilityModel],
|
|
146
|
+
*,
|
|
147
|
+
operation_name: Optional[str] = None,
|
|
148
|
+
plugin: Optional[str] = None,
|
|
149
|
+
model_hash: Optional[str] = None,
|
|
150
|
+
result_adapter: Optional[Callable[[Any], Any]] = None,
|
|
151
|
+
) -> Job:
|
|
152
|
+
"""Schedule ``operation`` on ``model``; returns immediately with a :class:`Job`."""
|
|
153
|
+
if isinstance(operation, Interruptible):
|
|
154
|
+
operation_to_interrupt: Optional[Interruptible] = operation
|
|
155
|
+
else:
|
|
156
|
+
operation_to_interrupt = None
|
|
157
|
+
|
|
158
|
+
def task(interrupt_event: threading.Event) -> OperationResult[Any]:
|
|
159
|
+
if operation_to_interrupt is not None:
|
|
160
|
+
operation_to_interrupt.set_interrupt_event(interrupt_event)
|
|
161
|
+
return run_and_wrap(operation, model, operation_name=operation_name,
|
|
162
|
+
plugin=plugin, model_hash=model_hash)
|
|
163
|
+
|
|
164
|
+
return self.submit_call(task, result_adapter=result_adapter)
|
|
165
|
+
|
|
166
|
+
def submit_call(
|
|
167
|
+
self,
|
|
168
|
+
task: Callable[[threading.Event], OperationResult[Any]],
|
|
169
|
+
*,
|
|
170
|
+
result_adapter: Optional[Callable[[Any], Any]] = None,
|
|
171
|
+
) -> Job:
|
|
172
|
+
"""Schedule an arbitrary unit of work that produces an envelope; returns a :class:`Job`.
|
|
173
|
+
|
|
174
|
+
``task`` runs on a worker thread and receives the job's interrupt event, so it can
|
|
175
|
+
do expensive preparation (e.g. transforming a feature model into a solver model)
|
|
176
|
+
*inside* the job — covered by ``Job.result(timeout)`` — and then hand the event to
|
|
177
|
+
the :class:`Interruptible` operation it builds before running it.
|
|
178
|
+
"""
|
|
179
|
+
interrupt_event = threading.Event()
|
|
180
|
+
future: 'Future[OperationResult[Any]]' = Future()
|
|
181
|
+
|
|
182
|
+
def runner() -> None:
|
|
183
|
+
with self._slots:
|
|
184
|
+
if not future.set_running_or_notify_cancel():
|
|
185
|
+
return # cancelled while queued
|
|
186
|
+
try:
|
|
187
|
+
future.set_result(task(interrupt_event))
|
|
188
|
+
except BaseException as error: # pylint: disable=broad-except
|
|
189
|
+
future.set_exception(error)
|
|
190
|
+
|
|
191
|
+
threading.Thread(target=runner, daemon=True, name='flamapy-job').start()
|
|
192
|
+
return Job(future, interrupt_event, result_adapter)
|
|
193
|
+
|
|
194
|
+
def shutdown(self, wait: bool = True) -> None: # pylint: disable=unused-argument
|
|
195
|
+
"""Kept for API symmetry: daemon workers need no explicit shutdown."""
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
_DEFAULT_ENGINE: Optional[ExecutionEngine] = None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def default_engine() -> ExecutionEngine:
|
|
202
|
+
"""The process-wide engine used by the facade's async API."""
|
|
203
|
+
global _DEFAULT_ENGINE # noqa: PLW0603 pylint: disable=global-statement
|
|
204
|
+
if _DEFAULT_ENGINE is None:
|
|
205
|
+
_DEFAULT_ENGINE = ExecutionEngine()
|
|
206
|
+
return _DEFAULT_ENGINE
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Plugin capability manifests.
|
|
2
|
+
|
|
3
|
+
A plugin describes what it can handle by declaring a module attribute ``MANIFEST`` (a
|
|
4
|
+
:class:`PluginManifest`) in its package ``__init__``. Discovery reads it via
|
|
5
|
+
:attr:`flamapy.core.plugins.Plugin.manifest`; plugins without one get a conservative
|
|
6
|
+
synthesized default (BOOLEAN level, exact, default cost), so third-party plugins keep
|
|
7
|
+
working unchanged.
|
|
8
|
+
|
|
9
|
+
The query planner (:mod:`flamapy.core.planner`) routes on manifests when the caller asks for
|
|
10
|
+
``backend='auto'``; the docs generator and the benchmark calibration read them too.
|
|
11
|
+
|
|
12
|
+
Costs are estimated milliseconds: the time the operation (or, for ``transformation_cost``,
|
|
13
|
+
building the plugin's model from the feature model) takes on a model of a given size, measured
|
|
14
|
+
on the calibration models (see each plugin's ``__init__``). They only have to be right relative
|
|
15
|
+
to each other: the planner adds the transformation and the operation estimates of each candidate
|
|
16
|
+
and takes the smallest. A constant ``cost`` describes an operation whose time barely depends on
|
|
17
|
+
the model; a ``cost_curve`` gives the estimate per size bin for the ones that do.
|
|
18
|
+
|
|
19
|
+
A manifest names the run its costs come from in ``calibration``; the planner ranks manifests
|
|
20
|
+
without one (including the synthesized default) after every calibrated candidate, so an
|
|
21
|
+
unmeasured number never looks cheaper than a measured one. ``exact=False`` capabilities are
|
|
22
|
+
only chosen with ``allow_approximate``.
|
|
23
|
+
"""
|
|
24
|
+
from dataclasses import dataclass, field, replace
|
|
25
|
+
from typing import Optional
|
|
26
|
+
|
|
27
|
+
from flamapy.core.models.language_level import LanguageLevel, MajorLevel
|
|
28
|
+
|
|
29
|
+
DEFAULT_COST = 100
|
|
30
|
+
DEFAULT_TRANSFORMATION_COST = 100
|
|
31
|
+
|
|
32
|
+
# ((features, cost), ...) in increasing order of features: the cost applies to models with at
|
|
33
|
+
# most that many features. Beyond the last entry the cost grows in proportion to the size, so a
|
|
34
|
+
# curve measured on small models never makes a larger one look as cheap (the plugin's
|
|
35
|
+
# max_features is the place to stop them altogether).
|
|
36
|
+
CostCurve = tuple[tuple[int, int], ...]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cost_at(curve: CostCurve, n_features: int, default: int) -> int:
|
|
40
|
+
"""The cost of a curve for a model of ``n_features`` features (``default`` when it is empty)."""
|
|
41
|
+
if not curve:
|
|
42
|
+
return default
|
|
43
|
+
for bound, cost in curve:
|
|
44
|
+
if n_features <= bound:
|
|
45
|
+
return cost
|
|
46
|
+
last_bound, last_cost = curve[-1]
|
|
47
|
+
return -(-last_cost * n_features // max(last_bound, 1)) # ceiling of the linear growth
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class OperationCapability:
|
|
52
|
+
"""How a plugin implements one logical operation (keyed by its facade name)."""
|
|
53
|
+
|
|
54
|
+
exact: bool = True # False => approximate/estimated results
|
|
55
|
+
max_features: Optional[int] = None # practical scale ceiling; None => unbounded
|
|
56
|
+
cost: int = DEFAULT_COST # estimated ms when no curve is declared
|
|
57
|
+
cost_curve: CostCurve = () # estimated ms per size bin, see the module docstring
|
|
58
|
+
|
|
59
|
+
def cost_at(self, n_features: int) -> int:
|
|
60
|
+
"""The estimated cost of the operation on a model of ``n_features`` features."""
|
|
61
|
+
return cost_at(self.cost_curve, n_features, self.cost)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class PluginManifest:
|
|
66
|
+
name: str
|
|
67
|
+
extension: Optional[str] = None # the plugin's model extension, if it has a model
|
|
68
|
+
supported_level: LanguageLevel = field(
|
|
69
|
+
default_factory=lambda: LanguageLevel(MajorLevel.BOOLEAN, set()))
|
|
70
|
+
default_cost: int = DEFAULT_COST
|
|
71
|
+
# Plugin-wide scale ceiling, applied to every operation that declares none: the right
|
|
72
|
+
# place for a limit that comes from building the model (e.g. BDD compilation).
|
|
73
|
+
max_features: Optional[int] = None
|
|
74
|
+
# Estimated ms to build this plugin's model from the feature model (constant or per size
|
|
75
|
+
# bin); the planner charges it once per plan unless the model is already built or cached.
|
|
76
|
+
transformation_cost: int = DEFAULT_TRANSFORMATION_COST
|
|
77
|
+
transformation_cost_curve: CostCurve = ()
|
|
78
|
+
# A plugin whose runtime dependency is missing (an external binary, a native package)
|
|
79
|
+
# declares itself unavailable with the reason; the planner reports it instead of choosing
|
|
80
|
+
# a backend that would fail.
|
|
81
|
+
available: bool = True
|
|
82
|
+
unavailable_reason: str = ''
|
|
83
|
+
# The calibration run the costs come from (e.g. '2026-11-06'); None for estimates that were
|
|
84
|
+
# never measured, which the planner ranks after calibrated ones.
|
|
85
|
+
calibration: Optional[str] = None
|
|
86
|
+
operations: dict[str, OperationCapability] = field(default_factory=dict)
|
|
87
|
+
|
|
88
|
+
def supports(self, model_level: LanguageLevel) -> bool:
|
|
89
|
+
"""Whether a model at ``model_level`` is within this plugin's declared capability."""
|
|
90
|
+
return (self.supported_level.major.value >= model_level.major.value
|
|
91
|
+
and model_level.minors <= self.supported_level.minors)
|
|
92
|
+
|
|
93
|
+
def capability(self, operation_name: str) -> OperationCapability:
|
|
94
|
+
"""The declared capability for an operation, or a default-cost exact one; the
|
|
95
|
+
plugin-wide ``max_features`` applies when the operation declares none."""
|
|
96
|
+
declared = self.operations.get(operation_name)
|
|
97
|
+
if declared is None:
|
|
98
|
+
return OperationCapability(cost=self.default_cost, max_features=self.max_features)
|
|
99
|
+
if declared.max_features is None and self.max_features is not None:
|
|
100
|
+
return replace(declared, max_features=self.max_features)
|
|
101
|
+
return declared
|
|
102
|
+
|
|
103
|
+
def transformation_cost_at(self, n_features: int) -> int:
|
|
104
|
+
"""The estimated cost of building this plugin's model for ``n_features`` features."""
|
|
105
|
+
return cost_at(self.transformation_cost_curve, n_features, self.transformation_cost)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def synthesized_manifest(name: str, extension: Optional[str] = None) -> PluginManifest:
|
|
109
|
+
"""The conservative default for plugins that declare no MANIFEST."""
|
|
110
|
+
return PluginManifest(name=name, extension=extension)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from .variability_model import VariabilityModel, VariabilityElement # pylint: disable=cyclic-import
|
|
2
|
+
from .ast import AST, ASTOperation, NodeType # pylint: disable=cyclic-import
|
|
3
|
+
from .language_level import ( # pylint: disable=cyclic-import
|
|
4
|
+
ALL_MINOR_LEVELS,
|
|
5
|
+
LanguageLevel,
|
|
6
|
+
MajorLevel,
|
|
7
|
+
MinorLevel,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = ["ALL_MINOR_LEVELS", "AST", "ASTOperation", "LanguageLevel", "MajorLevel",
|
|
11
|
+
"MinorLevel", "NodeType", "VariabilityElement", "VariabilityModel"]
|