flamapy-fw 2.6.0.dev4__tar.gz → 2.6.0.dev5__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 (58) hide show
  1. {flamapy_fw-2.6.0.dev4/flamapy_fw.egg-info → flamapy_fw-2.6.0.dev5}/PKG-INFO +1 -1
  2. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/core/discover.py +9 -7
  3. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/core/models/ast.py +111 -7
  4. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/core/operations/__init__.py +6 -0
  5. flamapy_fw-2.6.0.dev5/flamapy/core/operations/attribute_optimization.py +30 -0
  6. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/core/operations/metrics_operation.py +1 -1
  7. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/core/plugins.py +23 -33
  8. flamapy_fw-2.6.0.dev5/flamapy/core/utils.py +17 -0
  9. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/metamodels/configuration_metamodel/models/configuration.py +15 -0
  10. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5/flamapy_fw.egg-info}/PKG-INFO +1 -1
  11. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy_fw.egg-info/SOURCES.txt +1 -0
  12. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/pyproject.toml +1 -1
  13. flamapy_fw-2.6.0.dev4/flamapy/core/utils.py +0 -9
  14. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/LICENSE +0 -0
  15. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/README.md +0 -0
  16. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/core/__init__.py +0 -0
  17. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/core/config.py +0 -0
  18. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/core/exceptions.py +0 -0
  19. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/core/models/__init__.py +0 -0
  20. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/core/models/variability_model.py +0 -0
  21. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/core/operations/abstract_operation.py +0 -0
  22. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/core/operations/atomic_sets.py +0 -0
  23. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/core/operations/average_branching_factor.py +0 -0
  24. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/core/operations/commonality.py +0 -0
  25. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/core/operations/configurations.py +0 -0
  26. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/core/operations/configurations_number.py +0 -0
  27. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/core/operations/core_features.py +0 -0
  28. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/core/operations/count_leafs.py +0 -0
  29. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/core/operations/dead_features.py +0 -0
  30. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/core/operations/error_detection.py +0 -0
  31. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/core/operations/error_diagnosis.py +0 -0
  32. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/core/operations/estimated_configurations_number.py +0 -0
  33. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/core/operations/false_optional_features.py +0 -0
  34. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/core/operations/filter.py +0 -0
  35. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/core/operations/sampling.py +0 -0
  36. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/core/operations/satisfiable.py +0 -0
  37. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/core/operations/satisfiable_configuration.py +0 -0
  38. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/core/operations/variability.py +0 -0
  39. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/core/transformations/__init__.py +0 -0
  40. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/core/transformations/abstract_transformation.py +0 -0
  41. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/core/transformations/model_to_model.py +0 -0
  42. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/core/transformations/model_to_text.py +0 -0
  43. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/core/transformations/text_to_model.py +0 -0
  44. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/metamodels/configuration_metamodel/__init__.py +0 -0
  45. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/metamodels/configuration_metamodel/models/__init__.py +0 -0
  46. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/metamodels/configuration_metamodel/transformations/__init__.py +0 -0
  47. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/metamodels/configuration_metamodel/transformations/configuration_basic_reader.py +0 -0
  48. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/metamodels/configuration_metamodel/transformations/configuration_basic_writer.py +0 -0
  49. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/metamodels/configuration_metamodel/transformations/configuration_json_reader.py +0 -0
  50. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/metamodels/configuration_metamodel/transformations/configuration_json_writer.py +0 -0
  51. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/metamodels/configuration_metamodel/transformations/uvls_json_reader.py +0 -0
  52. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy/metamodels/configuration_metamodel/transformations/uvls_json_writer.py +0 -0
  53. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy_fw.egg-info/dependency_links.txt +0 -0
  54. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy_fw.egg-info/entry_points.txt +0 -0
  55. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy_fw.egg-info/requires.txt +0 -0
  56. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/flamapy_fw.egg-info/top_level.txt +0 -0
  57. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/setup.cfg +0 -0
  58. {flamapy_fw-2.6.0.dev4 → flamapy_fw-2.6.0.dev5}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flamapy-fw
3
- Version: 2.6.0.dev4
3
+ Version: 2.6.0.dev5
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
@@ -15,6 +15,7 @@ from flamapy.core.plugins import Operations, Plugin, Plugins
15
15
  from flamapy.core.transformations import Transformation
16
16
  from flamapy.core.transformations.text_to_model import TextToModel
17
17
  from flamapy.core.transformations.model_to_model import ModelToModel
18
+ from flamapy.core.utils import filename_matches_extension
18
19
  from flamapy.metamodels.configuration_metamodel.models.configuration import Configuration
19
20
 
20
21
 
@@ -256,14 +257,15 @@ class DiscoverMetamodels:
256
257
 
257
258
  def __transform_to_model_from_file(self, file: str) -> VariabilityModel:
258
259
  t2m_transformations = self.get_transformations_t2m()
259
- extension = file.rsplit(".", maxsplit=1)[-1]
260
- t2m_filters = filter(
261
- lambda t2m: t2m.get_source_extension() == extension, t2m_transformations
262
- )
263
-
264
- t2m = next(t2m_filters, None)
265
- if t2m is None:
260
+ # Prefer the most specific matching extension (e.g. 'uvl.json' over 'json').
261
+ candidates = [
262
+ t2m
263
+ for t2m in t2m_transformations
264
+ if filename_matches_extension(file, t2m.get_source_extension())
265
+ ]
266
+ if not candidates:
266
267
  raise TransformationNotFound()
268
+ t2m = max(candidates, key=lambda t: len(t.get_source_extension()))
267
269
 
268
270
  return t2m(file).transform()
269
271
 
@@ -1,6 +1,8 @@
1
1
  from typing import Any, Optional
2
2
  from enum import Enum
3
3
 
4
+ from flamapy.core.exceptions import FlamaException
5
+
4
6
 
5
7
  class ASTOperation(Enum):
6
8
  # logical operators
@@ -187,10 +189,31 @@ class AST:
187
189
  def to_cnf(self) -> "AST":
188
190
  return convert_into_cnf(self)
189
191
 
190
- def get_clauses(self) -> list[list[Any]]:
192
+ def get_clauses(self, method: str = 'distributive') -> list[list[Any]]:
193
+ """Return the CNF clauses of this AST.
194
+
195
+ ``method='distributive'`` (default) uses the classic distributive expansion and
196
+ returns clauses over the formula's own variables. ``method='tseytin'`` uses the
197
+ Tseytin transformation; the returned clauses may contain fresh auxiliary
198
+ variables. Use :meth:`get_clauses_with_aux` when those auxiliary variable names
199
+ are needed (e.g. to register them as non-feature solver variables).
200
+ """
201
+ if method == 'tseytin':
202
+ clauses, _ = tseytin_cnf(self)
203
+ return clauses
191
204
  ast = self.to_cnf()
192
205
  return get_clauses(ast)
193
206
 
207
+ def get_clauses_with_aux(self, method: str = 'tseytin') -> tuple[list[list[Any]], list[str]]:
208
+ """Return ``(clauses, aux_names)`` for the given CNF ``method``.
209
+
210
+ For ``'tseytin'`` this exposes the auxiliary variable names introduced by the
211
+ encoding. For ``'distributive'`` the auxiliary list is always empty.
212
+ """
213
+ if method == 'tseytin':
214
+ return tseytin_cnf(self)
215
+ return get_clauses(self.to_cnf()), []
216
+
194
217
  def get_operators(self) -> list[ASTOperation]:
195
218
  operators = []
196
219
  stack = [self.root]
@@ -263,20 +286,22 @@ def simplify_formula(ast: AST) -> AST:
263
286
  )
264
287
  elif logic_op == ASTOperation.EQUIVALENCE:
265
288
  # Replace P <=> Q with P => Q ∧ Q => P.
266
- left = simplify_formula(AST.create_binary_operation(ASTOperation.IMPLIES, left, right)).root
267
- right = simplify_formula(
289
+ new_left = simplify_formula(
290
+ AST.create_binary_operation(ASTOperation.IMPLIES, left, right)
291
+ ).root
292
+ new_right = simplify_formula(
268
293
  AST.create_binary_operation(ASTOperation.IMPLIES, right, left)
269
294
  ).root
270
- result = AST.create_binary_operation(ASTOperation.AND, left, right)
295
+ result = AST.create_binary_operation(ASTOperation.AND, new_left, new_right)
271
296
  elif logic_op == ASTOperation.XOR:
272
297
  # Replace P XOR Q with (P ∧ !Q) v (!P ∧ Q).
273
- left = simplify_formula(
298
+ new_left = simplify_formula(
274
299
  AST.create_binary_operation(ASTOperation.AND, left, Node(ASTOperation.NOT, right))
275
300
  ).root
276
- left = simplify_formula(
301
+ new_right = simplify_formula(
277
302
  AST.create_binary_operation(ASTOperation.AND, Node(ASTOperation.NOT, left), right)
278
303
  ).root
279
- result = AST.create_binary_operation(ASTOperation.OR, left, right)
304
+ result = AST.create_binary_operation(ASTOperation.OR, new_left, new_right)
280
305
  elif logic_op == ASTOperation.AND:
281
306
  left = simplify_formula(AST(left)).root
282
307
  right = simplify_formula(AST(right)).root
@@ -376,6 +401,85 @@ def propagate_negation(node: Node, negated: bool = False) -> AST:
376
401
  return result
377
402
 
378
403
 
404
+ TSEYTIN_AUX_PREFIX = "__tseytin_"
405
+
406
+
407
+ def _negate_literal(literal: Any) -> Any:
408
+ """Flip the sign of a CNF literal that uses the ``"-name"`` string convention."""
409
+ if isinstance(literal, str) and literal.startswith("-"):
410
+ return literal[1:]
411
+ return "-" + str(literal)
412
+
413
+
414
+ def _tseytin_gate_clauses(op: ASTOperation, gate: Any, a: Any, b: Any) -> list[list[Any]]:
415
+ """Biconditional clauses encoding ``gate <=> (a op b)`` for a binary logical ``op``.
416
+
417
+ ``a`` and ``b`` are literals; ``gate`` is the fresh auxiliary literal. Encoding each
418
+ connective directly (rather than expanding it first) is what keeps the transformation
419
+ linear in the formula size.
420
+ """
421
+ g, na, nb, ng = gate, _negate_literal(a), _negate_literal(b), _negate_literal(gate)
422
+ if op == ASTOperation.AND:
423
+ return [[ng, a], [ng, b], [g, na, nb]]
424
+ if op == ASTOperation.OR:
425
+ return [[g, na], [g, nb], [ng, a, b]]
426
+ if op in (ASTOperation.IMPLIES, ASTOperation.REQUIRES): # a -> b == (not a) or b
427
+ return [[g, a], [g, nb], [ng, na, b]]
428
+ if op == ASTOperation.EXCLUDES: # (not a) or (not b)
429
+ return [[g, a], [g, b], [ng, na, nb]]
430
+ if op == ASTOperation.EQUIVALENCE: # a <-> b
431
+ return [[ng, na, b], [ng, a, nb], [g, a, b], [g, na, nb]]
432
+ if op == ASTOperation.XOR: # a xor b
433
+ return [[ng, a, b], [ng, na, nb], [g, na, b], [g, a, nb]]
434
+ raise FlamaException(
435
+ f"Operation '{op}' is not a propositional connective supported by the Tseytin "
436
+ f"transformation."
437
+ )
438
+
439
+
440
+ def tseytin_cnf(ast: AST) -> tuple[list[list[Any]], list[str]]:
441
+ """Return an equisatisfiable CNF of ``ast`` using the Tseytin transformation.
442
+
443
+ Unlike the distributive :func:`to_cnf`, this produces a CNF whose size is linear
444
+ in the size of the formula by introducing one fresh auxiliary variable per gate.
445
+ Each logical connective is encoded directly (no distributive expansion), so formulas
446
+ that blow up under :func:`to_cnf` (XOR chains, wide equivalences) stay compact. Gates
447
+ are encoded as full biconditionals (``g <=> subformula``) so that every model of the
448
+ original formula extends to exactly one model of the encoding; this preserves model
449
+ counts and configuration enumeration (once auxiliary variables are projected out).
450
+
451
+ Returns a tuple ``(clauses, aux_names)`` where ``clauses`` is a list of clauses
452
+ (each a list of literals in the same ``"-name"`` string convention as
453
+ :func:`get_clauses`) and ``aux_names`` lists every auxiliary variable name
454
+ introduced (each prefixed with :data:`TSEYTIN_AUX_PREFIX`) so callers can register
455
+ them as solver variables that are not features.
456
+
457
+ Raises :class:`FlamaException` if the formula contains a non-propositional operator
458
+ (arithmetic, comparison or aggregation).
459
+ """
460
+ clauses: list[list[Any]] = []
461
+ aux_names: list[str] = []
462
+ counter = [0]
463
+
464
+ def literal_of(node: Node) -> Any:
465
+ if node.is_term():
466
+ return node.data
467
+ if node.data == ASTOperation.NOT:
468
+ # Negation of a single literal is free; no gate needed.
469
+ return _negate_literal(literal_of(node.left))
470
+ left_lit = literal_of(node.left)
471
+ right_lit = literal_of(node.right)
472
+ counter[0] += 1
473
+ gate = f"{TSEYTIN_AUX_PREFIX}{counter[0]}"
474
+ aux_names.append(gate)
475
+ clauses.extend(_tseytin_gate_clauses(node.data, gate, left_lit, right_lit))
476
+ return gate
477
+
478
+ root_literal = literal_of(ast.root)
479
+ clauses.append([root_literal]) # assert the whole formula is true
480
+ return clauses, aux_names
481
+
482
+
379
483
  def get_clauses(ast: AST) -> list[list[Any]]:
380
484
  """Return the list of clauses represented by the AST root node in conjunctive normal form."""
381
485
  node = ast.root
@@ -17,9 +17,14 @@ from .variability import Variability # pylint: disable=cyclic-import
17
17
  from .sampling import Sampling # pylint: disable=cyclic-import
18
18
  from .atomic_sets import AtomicSets # pylint: disable=cyclic-import
19
19
  from .metrics_operation import Metrics # pylint: disable=cyclic-import
20
+ from .attribute_optimization import ( # pylint: disable=cyclic-import
21
+ AttributeOptimization,
22
+ OptimizationGoal,
23
+ )
20
24
 
21
25
  __all__ = [
22
26
  "AtomicSets",
27
+ "AttributeOptimization",
23
28
  "AverageBranchingFactor",
24
29
  "Commonality",
25
30
  "Configurations",
@@ -34,6 +39,7 @@ __all__ = [
34
39
  "Filter",
35
40
  "Metrics",
36
41
  "Operation",
42
+ "OptimizationGoal",
37
43
  "Sampling",
38
44
  "Satisfiable",
39
45
  "SatisfiableConfiguration",
@@ -0,0 +1,30 @@
1
+ from abc import abstractmethod
2
+ from enum import Enum
3
+ from typing import Any
4
+
5
+ from flamapy.core.operations import Operation
6
+
7
+
8
+ class OptimizationGoal(Enum):
9
+ MAXIMIZE = 'Maximize'
10
+ MINIMIZE = 'Minimize'
11
+
12
+
13
+ class AttributeOptimization(Operation):
14
+ """Return the configuration(s) that optimize the given numeric feature attribute(s).
15
+
16
+ Attributes are identified by name and mapped to an :class:`OptimizationGoal`. The
17
+ objective for each attribute is the sum of its values over the selected features.
18
+ """
19
+
20
+ @abstractmethod
21
+ def __init__(self) -> None:
22
+ pass
23
+
24
+ @abstractmethod
25
+ def set_attributes(self, attributes: dict[str, OptimizationGoal]) -> None:
26
+ """Set the attributes to optimize as a mapping ``{attribute_name: OptimizationGoal}``."""
27
+
28
+ @abstractmethod
29
+ def optimize(self) -> list[Any]:
30
+ """Return the optimal configuration(s)."""
@@ -19,10 +19,10 @@ class Metrics(Operation, metaclass=ABCMeta):
19
19
  """
20
20
 
21
21
  filter: Optional[list[str]] = None
22
- result: list[dict[str, Any]] = []
23
22
 
24
23
  def __init__(self) -> None:
25
24
  self.model: Optional[VariabilityModel] = None
25
+ self.result: list[dict[str, Any]] = []
26
26
 
27
27
  @abstractmethod
28
28
  def calculate_metamodel_metrics(self, model: VariabilityModel) -> list[dict[str, Any]]:
@@ -15,7 +15,7 @@ from flamapy.core.transformations import (
15
15
  ModelToText,
16
16
  ModelToModel,
17
17
  )
18
- from flamapy.core.utils import extract_filename_extension
18
+ from flamapy.core.utils import filename_matches_extension
19
19
 
20
20
 
21
21
  class Transformations(UserList[Type[Transformation]]):
@@ -30,11 +30,7 @@ class Operations(UserList[Type[Operation]]):
30
30
  lambda op: name in [op.__name__, op.__base__.__name__ if op.__base__ else ""], self.data
31
31
  )
32
32
 
33
- try:
34
- operation = next(candidates, None)
35
- except StopIteration:
36
- raise OperationNotFound(f"Operation '{name}' not found.") from StopIteration
37
-
33
+ operation = next(candidates, None)
38
34
  if not operation:
39
35
  raise OperationNotFound(f"Operation '{name}' not found.")
40
36
  return operation
@@ -51,11 +47,7 @@ class Plugin:
51
47
  self, filter_transformation: Callable[..., bool]
52
48
  ) -> Type[Transformation]:
53
49
  candidates = filter(filter_transformation, self.transformations)
54
- try:
55
- transformation = next(candidates, None)
56
- except StopIteration:
57
- raise TransformationNotFound from StopIteration
58
-
50
+ transformation = next(candidates, None)
59
51
  if not transformation:
60
52
  raise TransformationNotFound
61
53
  return transformation
@@ -74,32 +66,30 @@ class Plugin:
74
66
  return operation.execute(model=src)
75
67
 
76
68
  def use_transformation_t2m(self, src: str) -> VariabilityModel:
77
- extension = extract_filename_extension(src)
78
-
79
- def filter_transformations(transformation: Type[Transformation]) -> bool:
80
- return (
81
- issubclass(transformation, TextToModel)
82
- and transformation.get_source_extension() == extension
83
- )
84
-
85
- transformation: Type[TextToModel] = cast(
86
- Type[TextToModel], self.__get_transformation(filter_transformations)
87
- )
69
+ # Prefer the most specific matching extension (e.g. 'uvl.json' over 'json').
70
+ candidates = [
71
+ transformation
72
+ for transformation in self.transformations
73
+ if issubclass(transformation, TextToModel)
74
+ and filename_matches_extension(src, transformation.get_source_extension())
75
+ ]
76
+ if not candidates:
77
+ raise TransformationNotFound
78
+ transformation = max(candidates, key=lambda t: len(t.get_source_extension()))
88
79
  result = transformation(src)
89
80
  return result.transform()
90
81
 
91
82
  def use_transformation_m2t(self, src: VariabilityModel, dst: str) -> str:
92
- extension = extract_filename_extension(dst)
93
-
94
- def filter_transformations(transformation: Type[Transformation]) -> bool:
95
- return (
96
- issubclass(transformation, ModelToText)
97
- and transformation.get_destination_extension() == extension
98
- )
99
-
100
- transformation: Type[ModelToText] = cast(
101
- Type[ModelToText], self.__get_transformation(filter_transformations)
102
- )
83
+ # Prefer the most specific matching extension (e.g. 'uvl.json' over 'json').
84
+ candidates = [
85
+ transformation
86
+ for transformation in self.transformations
87
+ if issubclass(transformation, ModelToText)
88
+ and filename_matches_extension(dst, transformation.get_destination_extension())
89
+ ]
90
+ if not candidates:
91
+ raise TransformationNotFound
92
+ transformation = max(candidates, key=lambda t: len(t.get_destination_extension()))
103
93
  result = transformation(path=dst, source_model=src)
104
94
  return result.transform()
105
95
 
@@ -0,0 +1,17 @@
1
+ import os.path
2
+
3
+
4
+ def extract_filename_extension(filename: str) -> str:
5
+ return filename.rsplit(".", maxsplit=1)[-1]
6
+
7
+
8
+ def filename_matches_extension(filename: str, extension: str) -> bool:
9
+ """Whether the filename ends with the given extension.
10
+
11
+ Supports compound extensions such as 'uvl.json'.
12
+ """
13
+ return filename.endswith(f".{extension}")
14
+
15
+
16
+ def file_exists(filepath: str) -> bool:
17
+ return os.path.isfile(filepath)
@@ -30,10 +30,25 @@ class Configuration(VariabilityModel):
30
30
  def __init__(self, elements: dict[Any, Any]) -> None:
31
31
  self.elements: dict[Any, Any] = elements
32
32
  self.is_full: bool = False
33
+ # Optional decision provenance: element -> 'user' or 'propagated'. Empty by default
34
+ # and not part of the configuration's identity (equality/hash ignore it). Useful for
35
+ # interactive configurators that distinguish user choices from implied decisions.
36
+ self.provenance: dict[Any, str] = {}
33
37
 
34
38
  def set_full(self, is_full: bool) -> None:
35
39
  self.is_full = is_full
36
40
 
41
+ def set_provenance(self, element: Any, source: str) -> None:
42
+ """Record whether an element's value came from the user or from propagation."""
43
+ self.provenance[element] = source
44
+
45
+ def get_provenance(self, element: Any) -> str:
46
+ """Return 'user', 'propagated', or 'unknown' if not recorded."""
47
+ return self.provenance.get(element, 'unknown')
48
+
49
+ def is_user_decision(self, element: Any) -> bool:
50
+ return self.provenance.get(element) == 'user'
51
+
37
52
  def get_selected_elements(self) -> list[Any]:
38
53
  """Get the list of selected elements in the configuration."""
39
54
  return [e for e in self.elements if self.is_selected(e)]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flamapy-fw
3
- Version: 2.6.0.dev4
3
+ Version: 2.6.0.dev5
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
@@ -14,6 +14,7 @@ flamapy/core/models/variability_model.py
14
14
  flamapy/core/operations/__init__.py
15
15
  flamapy/core/operations/abstract_operation.py
16
16
  flamapy/core/operations/atomic_sets.py
17
+ flamapy/core/operations/attribute_optimization.py
17
18
  flamapy/core/operations/average_branching_factor.py
18
19
  flamapy/core/operations/commonality.py
19
20
  flamapy/core/operations/configurations.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "flamapy-fw"
7
- version = "2.6.0.dev4"
7
+ version = "2.6.0.dev5"
8
8
  description = "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."
9
9
  readme = "README.md"
10
10
  license = "GPL-3.0-or-later"
@@ -1,9 +0,0 @@
1
- import os.path
2
-
3
-
4
- def extract_filename_extension(filename: str) -> str:
5
- return filename.rsplit(".", maxsplit=1)[-1]
6
-
7
-
8
- def file_exists(filepath: str) -> bool:
9
- return os.path.isfile(filepath)
File without changes