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.
Files changed (84) hide show
  1. {flamapy_fw-2.6.0.dev11/flamapy_fw.egg-info → flamapy_fw-2.9.0.dev0}/PKG-INFO +1 -1
  2. flamapy_fw-2.9.0.dev0/flamapy/core/cache.py +141 -0
  3. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/discover.py +84 -21
  4. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/exceptions.py +8 -0
  5. flamapy_fw-2.9.0.dev0/flamapy/core/execution.py +206 -0
  6. flamapy_fw-2.9.0.dev0/flamapy/core/manifest.py +110 -0
  7. flamapy_fw-2.9.0.dev0/flamapy/core/models/__init__.py +11 -0
  8. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/models/ast.py +39 -9
  9. flamapy_fw-2.9.0.dev0/flamapy/core/models/language_level.py +36 -0
  10. flamapy_fw-2.9.0.dev0/flamapy/core/models/variability_model.py +22 -0
  11. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/operations/__init__.py +4 -0
  12. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/operations/abstract_operation.py +5 -0
  13. flamapy_fw-2.9.0.dev0/flamapy/core/operations/atomic_sets.py +47 -0
  14. flamapy_fw-2.9.0.dev0/flamapy/core/operations/commonality.py +51 -0
  15. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/operations/configurations.py +3 -2
  16. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/operations/configurations_number.py +2 -1
  17. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/operations/core_features.py +2 -1
  18. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/operations/dead_features.py +2 -1
  19. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/operations/descriptor.py +1 -0
  20. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/operations/false_optional_features.py +2 -1
  21. flamapy_fw-2.9.0.dev0/flamapy/core/operations/metrics_operation.py +180 -0
  22. flamapy_fw-2.9.0.dev0/flamapy/core/operations/result.py +42 -0
  23. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/operations/satisfiable.py +2 -1
  24. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/operations/satisfiable_configuration.py +2 -1
  25. flamapy_fw-2.9.0.dev0/flamapy/core/operations/streaming.py +21 -0
  26. flamapy_fw-2.9.0.dev0/flamapy/core/planner.py +257 -0
  27. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/plugins.py +66 -6
  28. flamapy_fw-2.9.0.dev0/flamapy/core/reasoning/__init__.py +12 -0
  29. flamapy_fw-2.9.0.dev0/flamapy/core/reasoning/fastdiag.py +95 -0
  30. flamapy_fw-2.9.0.dev0/flamapy/core/reasoning/hsdag/__init__.py +4 -0
  31. flamapy_fw-2.9.0.dev0/flamapy/core/reasoning/hsdag/hsdag.py +315 -0
  32. flamapy_fw-2.9.0.dev0/flamapy/core/reasoning/hsdag/labeler/__init__.py +6 -0
  33. flamapy_fw-2.9.0.dev0/flamapy/core/reasoning/hsdag/labeler/fastdiag_labeler.py +71 -0
  34. flamapy_fw-2.9.0.dev0/flamapy/core/reasoning/hsdag/labeler/labeler.py +49 -0
  35. flamapy_fw-2.9.0.dev0/flamapy/core/reasoning/hsdag/labeler/quickxplain_labeler.py +69 -0
  36. flamapy_fw-2.9.0.dev0/flamapy/core/reasoning/hsdag/node.py +78 -0
  37. flamapy_fw-2.9.0.dev0/flamapy/core/reasoning/protocols.py +18 -0
  38. flamapy_fw-2.9.0.dev0/flamapy/core/reasoning/quickxplain.py +88 -0
  39. flamapy_fw-2.9.0.dev0/flamapy/core/reasoning/utils.py +46 -0
  40. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/transformations/__init__.py +3 -1
  41. flamapy_fw-2.9.0.dev0/flamapy/core/transformations/cacheable.py +31 -0
  42. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0/flamapy_fw.egg-info}/PKG-INFO +1 -1
  43. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy_fw.egg-info/SOURCES.txt +20 -0
  44. flamapy_fw-2.9.0.dev0/flamapy_fw.egg-info/entry_points.txt +5 -0
  45. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/pyproject.toml +4 -1
  46. flamapy_fw-2.6.0.dev11/flamapy/core/models/__init__.py +0 -4
  47. flamapy_fw-2.6.0.dev11/flamapy/core/models/variability_model.py +0 -13
  48. flamapy_fw-2.6.0.dev11/flamapy/core/operations/atomic_sets.py +0 -18
  49. flamapy_fw-2.6.0.dev11/flamapy/core/operations/commonality.py +0 -18
  50. flamapy_fw-2.6.0.dev11/flamapy/core/operations/metrics_operation.py +0 -125
  51. flamapy_fw-2.6.0.dev11/flamapy_fw.egg-info/entry_points.txt +0 -2
  52. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/LICENSE +0 -0
  53. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/README.md +0 -0
  54. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/__init__.py +0 -0
  55. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/config.py +0 -0
  56. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/operations/attribute_optimization.py +0 -0
  57. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/operations/average_branching_factor.py +0 -0
  58. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/operations/count_leafs.py +0 -0
  59. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/operations/error_detection.py +0 -0
  60. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/operations/error_diagnosis.py +0 -0
  61. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/operations/estimated_configurations_number.py +0 -0
  62. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/operations/filter.py +0 -0
  63. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/operations/sampling.py +0 -0
  64. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/operations/variability.py +0 -0
  65. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/transformations/abstract_transformation.py +0 -0
  66. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/transformations/model_to_model.py +0 -0
  67. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/transformations/model_to_text.py +0 -0
  68. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/transformations/text_to_model.py +0 -0
  69. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/core/utils.py +0 -0
  70. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/metamodels/configuration_metamodel/__init__.py +0 -0
  71. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/metamodels/configuration_metamodel/models/__init__.py +0 -0
  72. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/metamodels/configuration_metamodel/models/configuration.py +0 -0
  73. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/metamodels/configuration_metamodel/transformations/__init__.py +0 -0
  74. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/metamodels/configuration_metamodel/transformations/configuration_basic_reader.py +0 -0
  75. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/metamodels/configuration_metamodel/transformations/configuration_basic_writer.py +0 -0
  76. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/metamodels/configuration_metamodel/transformations/configuration_json_reader.py +0 -0
  77. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/metamodels/configuration_metamodel/transformations/configuration_json_writer.py +0 -0
  78. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/metamodels/configuration_metamodel/transformations/uvls_json_reader.py +0 -0
  79. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy/metamodels/configuration_metamodel/transformations/uvls_json_writer.py +0 -0
  80. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy_fw.egg-info/dependency_links.txt +0 -0
  81. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy_fw.egg-info/requires.txt +0 -0
  82. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/flamapy_fw.egg-info/top_level.txt +0 -0
  83. {flamapy_fw-2.6.0.dev11 → flamapy_fw-2.9.0.dev0}/setup.cfg +0 -0
  84. {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.6.0.dev11
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
- def __init__(self) -> None:
45
- self.module_paths = filter_modules_from_plugin_paths()
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
- for pkg in self.module_paths:
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
- plugin = Plugin(module=module)
71
-
72
- classes = self.search_classes(module)
73
-
74
- for _, _class in classes:
75
- if not _class.__module__.startswith(module.__package__):
76
- continue # Exclude modules not in current package
77
- inherit = _class.mro()
78
-
79
- if Operation in inherit:
80
- plugin.append_operation(_class)
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"]