coom-core 0.0.1a1__py3-none-any.whl
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.
- coom/check/__init__.py +18 -0
- coom/check/_profiles.py +147 -0
- coom/check/_structure.py +35 -0
- coom/check/_typing.py +315 -0
- coom/check/py.typed +0 -0
- coom/configuration/__init__.py +30 -0
- coom/configuration/_configuration.py +311 -0
- coom/configuration/_json.py +65 -0
- coom/configuration/arithmetic.py +358 -0
- coom/configuration/evaluate.py +632 -0
- coom/configuration/py.typed +0 -0
- coom/configuration/visibility.py +117 -0
- coom/diagnostics/__init__.py +25 -0
- coom/diagnostics/py.typed +0 -0
- coom/model/__init__.py +155 -0
- coom/model/_base.py +22 -0
- coom/model/behaviors.py +103 -0
- coom/model/formulas.py +157 -0
- coom/model/graph.py +106 -0
- coom/model/paths.py +103 -0
- coom/model/py.typed +0 -0
- coom/model/testing.py +108 -0
- coom/model/traversal.py +111 -0
- coom/model/types.py +208 -0
- coom_core-0.0.1a1.dist-info/METADATA +32 -0
- coom_core-0.0.1a1.dist-info/RECORD +28 -0
- coom_core-0.0.1a1.dist-info/WHEEL +4 -0
- coom_core-0.0.1a1.dist-info/licenses/LICENSE +21 -0
coom/check/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Static checks of a compiled model and detection of the profiles it uses.
|
|
2
|
+
|
|
3
|
+
The checks decide whether a model is valid regardless of any configuration. They cover the typing of formulas,
|
|
4
|
+
comparisons, assignments, table cells and test steps, values outside declared ranges, empty index ranges and
|
|
5
|
+
structures that contain themselves.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from coom.check._profiles import Profile, detect_profiles
|
|
9
|
+
from coom.check._structure import check_structure
|
|
10
|
+
from coom.check._typing import check_types
|
|
11
|
+
from coom.diagnostics import Diagnostic
|
|
12
|
+
from coom.model import Model
|
|
13
|
+
|
|
14
|
+
__all__ = ["Profile", "check", "detect_profiles"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def check(model: Model) -> tuple[Diagnostic, ...]:
|
|
18
|
+
return (*check_structure(model), *check_types(model))
|
coom/check/_profiles.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Detection of the language profiles and modules a model uses."""
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
|
|
5
|
+
from coom.check._typing import AGGREGATES, formula_type, formulas
|
|
6
|
+
from coom.model import (
|
|
7
|
+
Behavior,
|
|
8
|
+
Binary,
|
|
9
|
+
BinaryOp,
|
|
10
|
+
Call,
|
|
11
|
+
Combinations,
|
|
12
|
+
Compare,
|
|
13
|
+
Default,
|
|
14
|
+
Feature,
|
|
15
|
+
FeatureStep,
|
|
16
|
+
Formula,
|
|
17
|
+
IsDefined,
|
|
18
|
+
Model,
|
|
19
|
+
Numerical,
|
|
20
|
+
Optimize,
|
|
21
|
+
PathAccess,
|
|
22
|
+
Require,
|
|
23
|
+
Visibility,
|
|
24
|
+
parts,
|
|
25
|
+
walk,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Profile(StrEnum):
|
|
30
|
+
"""A profile or module of the language, CORE being the one every model uses."""
|
|
31
|
+
|
|
32
|
+
CORE = "CORE"
|
|
33
|
+
N_OUT = "N-OUT"
|
|
34
|
+
N_LIN = "N-LIN"
|
|
35
|
+
N_FULL = "N-FULL"
|
|
36
|
+
C_FIX = "C-FIX"
|
|
37
|
+
C_USER = "C-USER"
|
|
38
|
+
C_OPEN = "C-OPEN"
|
|
39
|
+
D_BASE = "D-BASE"
|
|
40
|
+
D_COND = "D-COND"
|
|
41
|
+
OPTIMIZE = "O"
|
|
42
|
+
UI = "UI"
|
|
43
|
+
TEST = "TEST"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def detect_profiles(model: Model) -> tuple[Profile, ...]:
|
|
47
|
+
"""Returns the smallest profile of each family that covers the model, in the order of the enumeration.
|
|
48
|
+
|
|
49
|
+
Numbers. N-OUT if no condition, constraint or table reads a number, so calculations stay outside the solution
|
|
50
|
+
space. Otherwise N-LIN if all arithmetic is linear, that is sums, differences, products and quotients with a
|
|
51
|
+
constant factor or divisor, and the aggregates. Otherwise N-FULL.
|
|
52
|
+
|
|
53
|
+
Cardinalities. C-FIX if every cardinality is fixed. C-OPEN if a cardinality is unbounded or a behavior aggregates
|
|
54
|
+
over or ranges across a feature whose number of instances varies, since then the reasoner decides that number.
|
|
55
|
+
Otherwise C-USER, where the user decides it.
|
|
56
|
+
|
|
57
|
+
Defaults. D-BASE if no default has a condition or an explicit priority, otherwise D-COND.
|
|
58
|
+
"""
|
|
59
|
+
profiles = [Profile.CORE]
|
|
60
|
+
numbers = _numbers(model)
|
|
61
|
+
if numbers is not None:
|
|
62
|
+
profiles.append(numbers)
|
|
63
|
+
cardinalities = _cardinalities(model)
|
|
64
|
+
if cardinalities is not None:
|
|
65
|
+
profiles.append(cardinalities)
|
|
66
|
+
defaults = [behavior for behavior in model.behaviors if isinstance(behavior, Default)]
|
|
67
|
+
if defaults:
|
|
68
|
+
conditioned = any(default.conditions or default.priority is not None for default in defaults)
|
|
69
|
+
profiles.append(Profile.D_COND if conditioned else Profile.D_BASE)
|
|
70
|
+
if any(isinstance(behavior, Optimize) for behavior in model.behaviors):
|
|
71
|
+
profiles.append(Profile.OPTIMIZE)
|
|
72
|
+
if any(isinstance(behavior, Visibility) for behavior in model.behaviors):
|
|
73
|
+
profiles.append(Profile.UI)
|
|
74
|
+
if model.tests:
|
|
75
|
+
profiles.append(Profile.TEST)
|
|
76
|
+
return tuple(profiles)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _numbers(model: Model) -> Profile | None:
|
|
80
|
+
declared = any(isinstance(feature.type, Numerical) for feature in model.features()) or any(
|
|
81
|
+
isinstance(attribute.type, Numerical)
|
|
82
|
+
for enumeration in model.enumerations
|
|
83
|
+
for attribute in enumeration.attributes
|
|
84
|
+
)
|
|
85
|
+
if not declared:
|
|
86
|
+
return None
|
|
87
|
+
if not any(_reads_number(behavior) for behavior in model.behaviors):
|
|
88
|
+
return Profile.N_OUT
|
|
89
|
+
linear = all(_linear(formula) for behavior in model.behaviors for formula in formulas(behavior))
|
|
90
|
+
return Profile.N_LIN if linear else Profile.N_FULL
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _reads_number(behavior: Behavior) -> bool:
|
|
94
|
+
"""Whether a condition, constraint or table of the behavior depends on a numeric value."""
|
|
95
|
+
conditions = (*behavior.conditions, behavior.condition) if isinstance(behavior, Require) else behavior.conditions
|
|
96
|
+
tested: list[Formula] = list(behavior.columns) if isinstance(behavior, Combinations) else []
|
|
97
|
+
for node in walk(*conditions):
|
|
98
|
+
if isinstance(node, Compare):
|
|
99
|
+
tested += [node.left, node.right]
|
|
100
|
+
elif isinstance(node, IsDefined):
|
|
101
|
+
tested.append(node.formula)
|
|
102
|
+
return any(_is_number(formula) for formula in tested)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _is_number(formula: Formula) -> bool:
|
|
106
|
+
typed = formula_type(formula)
|
|
107
|
+
return typed is not None and isinstance(typed.base, Numerical)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _constant(formula: Formula) -> bool:
|
|
111
|
+
return not any(isinstance(node, PathAccess) for node in walk(formula))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _linear(formula: Formula) -> bool:
|
|
115
|
+
match formula:
|
|
116
|
+
case Binary(op=BinaryOp.MUL):
|
|
117
|
+
return _constant(formula.left) or _constant(formula.right)
|
|
118
|
+
case Binary(op=BinaryOp.DIV):
|
|
119
|
+
return _constant(formula.right)
|
|
120
|
+
case Binary(op=BinaryOp.POW):
|
|
121
|
+
return _constant(formula)
|
|
122
|
+
case Call():
|
|
123
|
+
return formula.function in AGGREGATES or _constant(formula)
|
|
124
|
+
case _:
|
|
125
|
+
return True
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _cardinalities(model: Model) -> Profile | None:
|
|
129
|
+
varying = [feature for feature in model.features() if (feature.min, feature.max) != (1, 1)]
|
|
130
|
+
if not varying:
|
|
131
|
+
return None
|
|
132
|
+
if all(feature.min == feature.max for feature in varying):
|
|
133
|
+
return Profile.C_FIX
|
|
134
|
+
if any(feature.max is None for feature in varying):
|
|
135
|
+
return Profile.C_OPEN
|
|
136
|
+
ranged = {feature for feature in varying if feature.min != feature.max}
|
|
137
|
+
for behavior in model.behaviors:
|
|
138
|
+
for node in walk(*parts(behavior)):
|
|
139
|
+
if isinstance(node, Call) and node.function in AGGREGATES:
|
|
140
|
+
for argument in walk(*node.args):
|
|
141
|
+
if isinstance(argument, PathAccess) and _ranges_over(argument, ranged):
|
|
142
|
+
return Profile.C_OPEN
|
|
143
|
+
return Profile.C_USER
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _ranges_over(access: PathAccess, ranged: set[Feature]) -> bool:
|
|
147
|
+
return any(isinstance(step, FeatureStep) and step.feature in ranged and step.is_range for step in access.path.steps)
|
coom/check/_structure.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Checks of the product hierarchy."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
|
|
5
|
+
from coom.diagnostics import Diagnostic, Severity
|
|
6
|
+
from coom.model import Feature, Model, Structure
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def check_structure(model: Model) -> Iterator[Diagnostic]:
|
|
10
|
+
"""Reports every structure that contains itself, which would require infinitely many instances to configure."""
|
|
11
|
+
reported: set[Structure] = set()
|
|
12
|
+
for structure in model.structures:
|
|
13
|
+
cycle = _cycle(structure, structure, [])
|
|
14
|
+
if cycle is None or structure in reported:
|
|
15
|
+
continue
|
|
16
|
+
reported.update(feature.owner for feature in cycle)
|
|
17
|
+
through = " > ".join(feature.qualified_name for feature in cycle)
|
|
18
|
+
yield Diagnostic(
|
|
19
|
+
Severity.ERROR, f"the structure {structure.name} contains itself through {through}", cycle[0].span
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _cycle(start: Structure, current: Structure, trail: list[Feature]) -> list[Feature] | None:
|
|
24
|
+
"""Returns the features that lead from the current structure back to the start, depth first."""
|
|
25
|
+
for feature in current.features:
|
|
26
|
+
if not isinstance(feature.type, Structure) or feature in trail:
|
|
27
|
+
continue
|
|
28
|
+
if feature.type is start:
|
|
29
|
+
return [*trail, feature]
|
|
30
|
+
if any(step.owner is feature.type for step in trail):
|
|
31
|
+
continue
|
|
32
|
+
found = _cycle(start, feature.type, [*trail, feature])
|
|
33
|
+
if found is not None:
|
|
34
|
+
return found
|
|
35
|
+
return None
|
coom/check/_typing.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""Typing of formulas and the type rules of conditions, assignments, tables and tests."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
from typing import NamedTuple, assert_never
|
|
6
|
+
|
|
7
|
+
from coom.diagnostics import Diagnostic, Severity
|
|
8
|
+
from coom.model import (
|
|
9
|
+
And,
|
|
10
|
+
Behavior,
|
|
11
|
+
Binary,
|
|
12
|
+
Boolean,
|
|
13
|
+
Call,
|
|
14
|
+
Combinations,
|
|
15
|
+
Compare,
|
|
16
|
+
CompareOp,
|
|
17
|
+
Condition,
|
|
18
|
+
Constant,
|
|
19
|
+
ConstantValue,
|
|
20
|
+
Default,
|
|
21
|
+
Enumeration,
|
|
22
|
+
FeatureStep,
|
|
23
|
+
Formula,
|
|
24
|
+
Function,
|
|
25
|
+
Imply,
|
|
26
|
+
IsAvailable,
|
|
27
|
+
IsBlocked,
|
|
28
|
+
IsDefined,
|
|
29
|
+
IsEmpty,
|
|
30
|
+
IsFixed,
|
|
31
|
+
IsHidden,
|
|
32
|
+
IsReadonly,
|
|
33
|
+
IsShown,
|
|
34
|
+
Model,
|
|
35
|
+
Negate,
|
|
36
|
+
Not,
|
|
37
|
+
Numerical,
|
|
38
|
+
Optimize,
|
|
39
|
+
Option,
|
|
40
|
+
Or,
|
|
41
|
+
Path,
|
|
42
|
+
PathAccess,
|
|
43
|
+
RemoveValue,
|
|
44
|
+
Require,
|
|
45
|
+
ResetValue,
|
|
46
|
+
SetValue,
|
|
47
|
+
Span,
|
|
48
|
+
Structure,
|
|
49
|
+
TestCase,
|
|
50
|
+
Textual,
|
|
51
|
+
Type,
|
|
52
|
+
Visibility,
|
|
53
|
+
parts,
|
|
54
|
+
type_name,
|
|
55
|
+
walk,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
NUMBER = Numerical()
|
|
59
|
+
"""The type of computed numbers, whose grid and bounds do not matter for typing."""
|
|
60
|
+
|
|
61
|
+
AGGREGATES = frozenset({Function.COUNT, Function.SUM, Function.MIN, Function.MAX})
|
|
62
|
+
EQUALITIES = frozenset({CompareOp.EQ, CompareOp.NE})
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Typed(NamedTuple):
|
|
66
|
+
"""The type of a formula, a collection if the formula may evaluate to several values."""
|
|
67
|
+
|
|
68
|
+
base: Type
|
|
69
|
+
collection: bool = False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def describe(typed: Typed) -> str:
|
|
73
|
+
"""Names the type for messages, such as "a number" or "a collection of options of Color"."""
|
|
74
|
+
match typed.base:
|
|
75
|
+
case Numerical():
|
|
76
|
+
single, plural = "a number", "numbers"
|
|
77
|
+
case Textual():
|
|
78
|
+
single, plural = "a text", "texts"
|
|
79
|
+
case Boolean():
|
|
80
|
+
single, plural = "a truth value", "truth values"
|
|
81
|
+
case Enumeration():
|
|
82
|
+
single, plural = f"an option of {typed.base.name}", f"options of {typed.base.name}"
|
|
83
|
+
case Structure():
|
|
84
|
+
name = "the product" if typed.base.is_root else typed.base.name
|
|
85
|
+
single, plural = f"an instance of {name}", f"instances of {name}"
|
|
86
|
+
case _:
|
|
87
|
+
assert_never(typed.base)
|
|
88
|
+
return f"a collection of {plural}" if typed.collection else single
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def comparable(op: CompareOp, left: Type, right: Type) -> bool:
|
|
92
|
+
"""Whether values of the two types can be compared by the operator, ignoring collections."""
|
|
93
|
+
match left, right:
|
|
94
|
+
case (Numerical(), Numerical()) | (Textual(), Textual()):
|
|
95
|
+
return True
|
|
96
|
+
case Enumeration(), Enumeration():
|
|
97
|
+
return left is right
|
|
98
|
+
case (Boolean(), Boolean()):
|
|
99
|
+
return op in EQUALITIES
|
|
100
|
+
case Structure(), Structure():
|
|
101
|
+
return left is right and op in EQUALITIES
|
|
102
|
+
case _:
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def constant_type(value: ConstantValue) -> Type:
|
|
107
|
+
match value:
|
|
108
|
+
case bool():
|
|
109
|
+
return Boolean()
|
|
110
|
+
case Decimal():
|
|
111
|
+
return NUMBER
|
|
112
|
+
case str():
|
|
113
|
+
return Textual()
|
|
114
|
+
case Option():
|
|
115
|
+
return value.owner
|
|
116
|
+
case _:
|
|
117
|
+
assert_never(value)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def in_range(numerical: Numerical, value: Decimal) -> bool:
|
|
121
|
+
return (numerical.min is None or value >= numerical.min) and (numerical.max is None or value <= numerical.max)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class TypeChecker:
|
|
125
|
+
"""Infers the types of formulas and reports where the type rules of the language are violated."""
|
|
126
|
+
|
|
127
|
+
def __init__(self) -> None:
|
|
128
|
+
self.diagnostics: list[Diagnostic] = []
|
|
129
|
+
|
|
130
|
+
def error(self, span: Span | None, message: str) -> None:
|
|
131
|
+
self.diagnostics.append(Diagnostic(Severity.ERROR, message, span))
|
|
132
|
+
|
|
133
|
+
# Formulas
|
|
134
|
+
|
|
135
|
+
def formula(self, formula: Formula) -> Typed | None:
|
|
136
|
+
"""Returns the type of a formula, or None if it has a type error that is already reported."""
|
|
137
|
+
match formula:
|
|
138
|
+
case Constant():
|
|
139
|
+
return Typed(constant_type(formula.value))
|
|
140
|
+
case PathAccess():
|
|
141
|
+
self.path(formula.path)
|
|
142
|
+
return Typed(formula.path.type, formula.path.is_range)
|
|
143
|
+
case Negate():
|
|
144
|
+
return Typed(NUMBER) if self.number(formula.operand) else None
|
|
145
|
+
case Binary():
|
|
146
|
+
left, right = self.number(formula.left), self.number(formula.right)
|
|
147
|
+
return Typed(NUMBER) if left and right else None
|
|
148
|
+
case Call():
|
|
149
|
+
return self._call(formula)
|
|
150
|
+
case _:
|
|
151
|
+
assert_never(formula)
|
|
152
|
+
|
|
153
|
+
def number(self, formula: Formula) -> bool:
|
|
154
|
+
"""Checks that a formula is a single number, as arithmetic requires."""
|
|
155
|
+
typed = self.formula(formula)
|
|
156
|
+
if typed is None:
|
|
157
|
+
return False
|
|
158
|
+
if typed.collection:
|
|
159
|
+
self.error(formula.span, f"arithmetic needs a single number, not {describe(typed)}")
|
|
160
|
+
return False
|
|
161
|
+
if not isinstance(typed.base, Numerical):
|
|
162
|
+
self.error(formula.span, f"arithmetic needs a number, not {describe(typed)}")
|
|
163
|
+
return False
|
|
164
|
+
return True
|
|
165
|
+
|
|
166
|
+
def _call(self, call: Call) -> Typed | None:
|
|
167
|
+
if call.function not in AGGREGATES:
|
|
168
|
+
valid = [self.number(argument) for argument in call.args]
|
|
169
|
+
return Typed(NUMBER) if all(valid) else None
|
|
170
|
+
types = [self.formula(argument) for argument in call.args]
|
|
171
|
+
if any(typed is None for typed in types):
|
|
172
|
+
return None
|
|
173
|
+
bases = [typed.base for typed in types if typed is not None]
|
|
174
|
+
match call.function:
|
|
175
|
+
case Function.COUNT:
|
|
176
|
+
return Typed(NUMBER)
|
|
177
|
+
case Function.SUM:
|
|
178
|
+
if not all(isinstance(base, Numerical) for base in bases):
|
|
179
|
+
self.error(call.span, "sum needs numbers")
|
|
180
|
+
return None
|
|
181
|
+
return Typed(NUMBER)
|
|
182
|
+
case _:
|
|
183
|
+
first = bases[0]
|
|
184
|
+
if not all(comparable(CompareOp.LT, first, other) for other in bases):
|
|
185
|
+
self.error(call.span, f"{call.function} needs values that can be ordered against each other")
|
|
186
|
+
return None
|
|
187
|
+
return Typed(NUMBER if isinstance(first, Numerical) else first)
|
|
188
|
+
|
|
189
|
+
def path(self, path: Path) -> None:
|
|
190
|
+
for step in path.steps:
|
|
191
|
+
index = step.index if isinstance(step, FeatureStep) else None
|
|
192
|
+
if index is not None and (index.first >= 0) == (index.last >= 0) and index.first > index.last:
|
|
193
|
+
self.error(step.span, f"the index range [{index}] is empty")
|
|
194
|
+
|
|
195
|
+
# Conditions
|
|
196
|
+
|
|
197
|
+
def condition(self, condition: Condition) -> None:
|
|
198
|
+
match condition:
|
|
199
|
+
case Compare():
|
|
200
|
+
self.compare(condition.op, condition.left, condition.right, condition.span)
|
|
201
|
+
case IsDefined():
|
|
202
|
+
self.formula(condition.formula)
|
|
203
|
+
case Not():
|
|
204
|
+
self.condition(condition.operand)
|
|
205
|
+
case And() | Or():
|
|
206
|
+
for operand in condition.operands:
|
|
207
|
+
self.condition(operand)
|
|
208
|
+
case Constant():
|
|
209
|
+
pass
|
|
210
|
+
case _:
|
|
211
|
+
assert_never(condition)
|
|
212
|
+
|
|
213
|
+
def compare(self, op: CompareOp, left: Formula, right: Formula, span: Span | None) -> None:
|
|
214
|
+
left_type, right_type = self.formula(left), self.formula(right)
|
|
215
|
+
if left_type is None or right_type is None:
|
|
216
|
+
return
|
|
217
|
+
if left_type.collection and right_type.collection:
|
|
218
|
+
self.error(span, "cannot compare two collections with each other")
|
|
219
|
+
elif not comparable(op, left_type.base, right_type.base):
|
|
220
|
+
self.error(span, f"cannot compare {describe(left_type)} with {describe(right_type)} by {op}")
|
|
221
|
+
|
|
222
|
+
# Assignments
|
|
223
|
+
|
|
224
|
+
def assignment(self, target: Path, value: Formula, span: Span | None) -> None:
|
|
225
|
+
"""Checks a value written to a feature, by imply, default or a test step."""
|
|
226
|
+
self.path(target)
|
|
227
|
+
if isinstance(target.type, Structure):
|
|
228
|
+
self.error(span, f"cannot assign a value to the structure feature {target}")
|
|
229
|
+
return
|
|
230
|
+
typed = self.formula(value)
|
|
231
|
+
if typed is None:
|
|
232
|
+
return
|
|
233
|
+
if typed.collection:
|
|
234
|
+
self.error(value.span, f"cannot assign {describe(typed)} to {target}")
|
|
235
|
+
elif not comparable(CompareOp.EQ, target.type, typed.base):
|
|
236
|
+
self.error(value.span, f"cannot assign {describe(typed)} to {target}, which holds {type_name(target.type)}")
|
|
237
|
+
elif (
|
|
238
|
+
isinstance(value, Constant)
|
|
239
|
+
and isinstance(value.value, Decimal)
|
|
240
|
+
and isinstance(target.type, Numerical)
|
|
241
|
+
and not in_range(target.type, value.value)
|
|
242
|
+
):
|
|
243
|
+
self.error(value.span, f"{value.value} is outside the range {type_name(target.type)} of {target}")
|
|
244
|
+
|
|
245
|
+
# Model items
|
|
246
|
+
|
|
247
|
+
def behavior(self, behavior: Behavior) -> None:
|
|
248
|
+
for condition in behavior.conditions:
|
|
249
|
+
self.condition(condition)
|
|
250
|
+
match behavior:
|
|
251
|
+
case Require():
|
|
252
|
+
self.condition(behavior.condition)
|
|
253
|
+
case Imply() | Default():
|
|
254
|
+
self.assignment(behavior.target, behavior.value, behavior.span)
|
|
255
|
+
case Optimize():
|
|
256
|
+
self.path(behavior.target)
|
|
257
|
+
if not isinstance(behavior.target.type, Numerical):
|
|
258
|
+
self.error(behavior.span, f"{behavior.direction} needs a numeric feature, not {behavior.target}")
|
|
259
|
+
case Visibility():
|
|
260
|
+
self.path(behavior.target)
|
|
261
|
+
case Combinations():
|
|
262
|
+
for column in behavior.columns:
|
|
263
|
+
self.formula(column)
|
|
264
|
+
for row in behavior.rows:
|
|
265
|
+
for column, cell in zip(behavior.columns, row.cells, strict=True):
|
|
266
|
+
for atom in cell or ():
|
|
267
|
+
self.compare(atom.op, column, atom.value, atom.span)
|
|
268
|
+
case _:
|
|
269
|
+
assert_never(behavior)
|
|
270
|
+
|
|
271
|
+
def test(self, test: TestCase) -> None:
|
|
272
|
+
for step in test.steps:
|
|
273
|
+
match step:
|
|
274
|
+
case SetValue() | IsFixed() | IsShown() | IsAvailable() | IsBlocked() | IsHidden():
|
|
275
|
+
if step.value is None:
|
|
276
|
+
self.path(step.path)
|
|
277
|
+
else:
|
|
278
|
+
self.assignment(step.path, step.value, step.span)
|
|
279
|
+
case RemoveValue() | ResetValue() | IsEmpty() | IsReadonly():
|
|
280
|
+
self.path(step.path)
|
|
281
|
+
case _:
|
|
282
|
+
assert_never(step)
|
|
283
|
+
|
|
284
|
+
def options(self, enumeration: Enumeration) -> None:
|
|
285
|
+
for option in enumeration.options:
|
|
286
|
+
for attribute, value in option.values.items():
|
|
287
|
+
numerical = attribute.type
|
|
288
|
+
if isinstance(numerical, Numerical) and isinstance(value, Decimal) and not in_range(numerical, value):
|
|
289
|
+
self.error(
|
|
290
|
+
option.span,
|
|
291
|
+
f"{value} is outside the range {type_name(numerical)} of the attribute {attribute.name}",
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def check_types(model: Model) -> Iterator[Diagnostic]:
|
|
296
|
+
checker = TypeChecker()
|
|
297
|
+
for enumeration in model.enumerations:
|
|
298
|
+
checker.options(enumeration)
|
|
299
|
+
for behavior in model.behaviors:
|
|
300
|
+
checker.behavior(behavior)
|
|
301
|
+
for test in model.tests:
|
|
302
|
+
checker.test(test)
|
|
303
|
+
yield from checker.diagnostics
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def formula_type(formula: Formula) -> Typed | None:
|
|
307
|
+
"""Returns the type of a formula without reporting, None if the formula has a type error."""
|
|
308
|
+
return TypeChecker().formula(formula)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def formulas(item: Behavior | TestCase) -> Iterator[Formula]:
|
|
312
|
+
"""Yields every formula of a behavior or test, nested ones included."""
|
|
313
|
+
for node in walk(*parts(item)):
|
|
314
|
+
if isinstance(node, Constant | PathAccess | Negate | Binary | Call):
|
|
315
|
+
yield node
|
coom/check/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Configurations of a model and their evaluation.
|
|
2
|
+
|
|
3
|
+
A configuration is a tree of instances below the instance of the product. Every feature of an instance has numbered
|
|
4
|
+
slots from 0 up to the maximum cardinality, and each slot is empty or holds an assignment, a value together with the
|
|
5
|
+
role that says why it is there. A structure feature holds instances, which then belong to that slot. Numbers are
|
|
6
|
+
stored as decimals on the grid of their feature, rounded half away from zero when they are assigned. Options, truth
|
|
7
|
+
values and texts are stored as the Option, bool and str objects of the model.
|
|
8
|
+
|
|
9
|
+
The evaluate module decides the truth of conditions and behaviors in a configuration, and the arithmetic module
|
|
10
|
+
defines the exact calculation both rely on. The visibility module says which values a user interface hides or locks.
|
|
11
|
+
Their main entry points are re-exported here.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from coom.configuration._configuration import Assignment, Configuration, Instance, Role, Value
|
|
15
|
+
from coom.configuration.evaluate import Truth, Verdict, truth, verdicts
|
|
16
|
+
from coom.configuration.visibility import Visibilities, visibilities
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"Assignment",
|
|
20
|
+
"Configuration",
|
|
21
|
+
"Instance",
|
|
22
|
+
"Role",
|
|
23
|
+
"Truth",
|
|
24
|
+
"Value",
|
|
25
|
+
"Verdict",
|
|
26
|
+
"Visibilities",
|
|
27
|
+
"truth",
|
|
28
|
+
"verdicts",
|
|
29
|
+
"visibilities",
|
|
30
|
+
]
|