openprocess 0.7.0__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.
Files changed (173) hide show
  1. cpnpy/__init__.py +46 -0
  2. openprocess/__init__.py +57 -0
  3. openprocess/analysis/__init__.py +0 -0
  4. openprocess/analysis/state_space.py +521 -0
  5. openprocess/analysis/state_space_process.py +251 -0
  6. openprocess/cli.py +742 -0
  7. openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/answer.pnml +31 -0
  8. openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/question.md +38 -0
  9. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/answer.pnml +27 -0
  10. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/net.pnml +29 -0
  11. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/question.md +65 -0
  12. openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/log.txt +1 -0
  13. openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/question.md +67 -0
  14. openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/question.md +75 -0
  15. openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/ts.txt +5 -0
  16. openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/net.pnml +27 -0
  17. openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/question.md +65 -0
  18. openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/log.txt +1 -0
  19. openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/question.md +62 -0
  20. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/log.txt +1 -0
  21. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m1.pnml +36 -0
  22. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m2.pnml +28 -0
  23. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m3.pnml +30 -0
  24. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/net.pnml +36 -0
  25. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/question.md +69 -0
  26. openprocess/exercises/pack.md +14 -0
  27. openprocess/flow/__init__.py +50 -0
  28. openprocess/flow/box.py +466 -0
  29. openprocess/flow/boxes/__init__.py +7 -0
  30. openprocess/flow/boxes/check.py +119 -0
  31. openprocess/flow/boxes/compare.py +16 -0
  32. openprocess/flow/boxes/cpn.py +53 -0
  33. openprocess/flow/boxes/discover.py +124 -0
  34. openprocess/flow/boxes/filter.py +80 -0
  35. openprocess/flow/boxes/input.py +124 -0
  36. openprocess/flow/boxes/output.py +52 -0
  37. openprocess/flow/boxes/predict.py +186 -0
  38. openprocess/flow/boxes/science.py +159 -0
  39. openprocess/flow/boxes/sweeps.py +18 -0
  40. openprocess/flow/convert.py +187 -0
  41. openprocess/flow/datasets.py +198 -0
  42. openprocess/flow/explain.py +115 -0
  43. openprocess/flow/library.py +222 -0
  44. openprocess/flow/record.py +385 -0
  45. openprocess/flow/runner.py +357 -0
  46. openprocess/flow/sweep.py +92 -0
  47. openprocess/flow/types.py +290 -0
  48. openprocess/flow/workflow.py +628 -0
  49. openprocess/gui/__init__.py +0 -0
  50. openprocess/gui/app.py +90 -0
  51. openprocess/gui/arc_editing.py +295 -0
  52. openprocess/gui/canvas.py +1414 -0
  53. openprocess/gui/flow/__init__.py +8 -0
  54. openprocess/gui/flow/canvas.py +854 -0
  55. openprocess/gui/flow/page.py +972 -0
  56. openprocess/gui/flow/templates.py +131 -0
  57. openprocess/gui/flow/viewers.py +665 -0
  58. openprocess/gui/items.py +1275 -0
  59. openprocess/gui/learn/answer_boxes.py +978 -0
  60. openprocess/gui/learn/concealment.py +91 -0
  61. openprocess/gui/learn/mode.py +1181 -0
  62. openprocess/gui/panning.py +241 -0
  63. openprocess/gui/resources/openprocess-icon.png +0 -0
  64. openprocess/gui/studio/__init__.py +1 -0
  65. openprocess/gui/studio/__main__.py +3 -0
  66. openprocess/gui/studio/app.py +4031 -0
  67. openprocess/gui/studio/charts.py +115 -0
  68. openprocess/gui/studio/compare_page.py +487 -0
  69. openprocess/gui/studio/cpn_page.py +1858 -0
  70. openprocess/gui/studio/definition_view.py +284 -0
  71. openprocess/gui/studio/derivation_view.py +421 -0
  72. openprocess/gui/studio/documents.py +152 -0
  73. openprocess/gui/studio/dotted_chart.py +1401 -0
  74. openprocess/gui/studio/file_dialogs.py +143 -0
  75. openprocess/gui/studio/filter_dialog.py +247 -0
  76. openprocess/gui/studio/graph_builders.py +176 -0
  77. openprocess/gui/studio/graph_view.py +682 -0
  78. openprocess/gui/studio/instances.py +413 -0
  79. openprocess/gui/studio/log_editor.py +675 -0
  80. openprocess/gui/studio/log_page.py +800 -0
  81. openprocess/gui/studio/markdown_view.py +127 -0
  82. openprocess/gui/studio/mathtext.py +260 -0
  83. openprocess/gui/studio/ml_highlighter.py +75 -0
  84. openprocess/gui/studio/model_page.py +760 -0
  85. openprocess/gui/studio/net_comparison.py +124 -0
  86. openprocess/gui/studio/notes_overlay.py +275 -0
  87. openprocess/gui/studio/petri_page.py +844 -0
  88. openprocess/gui/studio/regions_view.py +502 -0
  89. openprocess/gui/studio/sidebar.py +149 -0
  90. openprocess/gui/studio/style.py +503 -0
  91. openprocess/gui/studio/tool_icons.py +134 -0
  92. openprocess/gui/studio/updates.py +439 -0
  93. openprocess/gui/studio/widgets.py +899 -0
  94. openprocess/gui/studio/workers.py +60 -0
  95. openprocess/gui/studio/workspace.py +447 -0
  96. openprocess/gui/theme.py +394 -0
  97. openprocess/gui/tidy.py +86 -0
  98. openprocess/io/__init__.py +0 -0
  99. openprocess/io/cpn_reader.py +389 -0
  100. openprocess/io/cpn_writer.py +357 -0
  101. openprocess/learn/__init__.py +23 -0
  102. openprocess/learn/answers.py +188 -0
  103. openprocess/learn/checks.py +953 -0
  104. openprocess/learn/computed.py +1180 -0
  105. openprocess/learn/context.py +145 -0
  106. openprocess/learn/exam.py +169 -0
  107. openprocess/learn/exercise-packs.md +325 -0
  108. openprocess/learn/importer.py +216 -0
  109. openprocess/learn/notation.py +474 -0
  110. openprocess/learn/pack.py +511 -0
  111. openprocess/learn/sheet.py +296 -0
  112. openprocess/mining/__init__.py +73 -0
  113. openprocess/mining/analysis.py +689 -0
  114. openprocess/mining/columns.py +282 -0
  115. openprocess/mining/compare_nets.py +246 -0
  116. openprocess/mining/conformance/__init__.py +0 -0
  117. openprocess/mining/conformance/alignments.py +263 -0
  118. openprocess/mining/conformance/quality.py +145 -0
  119. openprocess/mining/conformance/token_replay.py +252 -0
  120. openprocess/mining/csv_import.py +222 -0
  121. openprocess/mining/definitions.py +584 -0
  122. openprocess/mining/dfg.py +187 -0
  123. openprocess/mining/discovery/__init__.py +0 -0
  124. openprocess/mining/discovery/alpha.py +168 -0
  125. openprocess/mining/discovery/heuristics.py +332 -0
  126. openprocess/mining/discovery/inductive.py +477 -0
  127. openprocess/mining/discovery/state_regions.py +62 -0
  128. openprocess/mining/filtering.py +237 -0
  129. openprocess/mining/footprint.py +183 -0
  130. openprocess/mining/invariants.py +191 -0
  131. openprocess/mining/layout.py +279 -0
  132. openprocess/mining/log.py +364 -0
  133. openprocess/mining/petrinet.py +354 -0
  134. openprocess/mining/playout.py +75 -0
  135. openprocess/mining/pm4py_bridge.py +82 -0
  136. openprocess/mining/pnml.py +223 -0
  137. openprocess/mining/processtree.py +216 -0
  138. openprocess/mining/regions.py +476 -0
  139. openprocess/mining/stats.py +160 -0
  140. openprocess/mining/structure.py +374 -0
  141. openprocess/mining/transition_system.py +409 -0
  142. openprocess/mining/xes.py +399 -0
  143. openprocess/ml/__init__.py +0 -0
  144. openprocess/ml/ast_nodes.py +332 -0
  145. openprocess/ml/builtins.py +364 -0
  146. openprocess/ml/colorsets.py +522 -0
  147. openprocess/ml/errors.py +60 -0
  148. openprocess/ml/evaluator.py +754 -0
  149. openprocess/ml/lexer.py +277 -0
  150. openprocess/ml/multiset.py +417 -0
  151. openprocess/ml/parser.py +737 -0
  152. openprocess/ml/values.py +319 -0
  153. openprocess/model/__init__.py +0 -0
  154. openprocess/model/declarations.py +617 -0
  155. openprocess/model/examples.py +98 -0
  156. openprocess/model/net.py +701 -0
  157. openprocess/model/plain.py +192 -0
  158. openprocess/references.py +280 -0
  159. openprocess/sim/__init__.py +0 -0
  160. openprocess/sim/binding.py +620 -0
  161. openprocess/sim/export.py +66 -0
  162. openprocess/sim/simulator.py +315 -0
  163. openprocess/teaching/__init__.py +4 -0
  164. openprocess/teaching/answers.py +4 -0
  165. openprocess/teaching/checks.py +5 -0
  166. openprocess/teaching/pack.py +4 -0
  167. openprocess/teaching/sheet.py +4 -0
  168. openprocess-0.7.0.dist-info/METADATA +927 -0
  169. openprocess-0.7.0.dist-info/RECORD +173 -0
  170. openprocess-0.7.0.dist-info/WHEEL +5 -0
  171. openprocess-0.7.0.dist-info/entry_points.txt +6 -0
  172. openprocess-0.7.0.dist-info/licenses/LICENSE +21 -0
  173. openprocess-0.7.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,617 @@
1
+ """Parsing and compiling a model's declaration block.
2
+
3
+ A CPN model's declarations live in what CPN Tools calls the *global box*
4
+ (``<globbox>`` in the file format). They come in four kinds:
5
+
6
+ ``colset`` -- a colour set (a type)
7
+ ``colset PACKET = product INT * STRING timed;``
8
+ ``var`` -- a typed variable usable in inscriptions
9
+ ``var p : PACKET; var n, m : INT;``
10
+ ``val`` / ``fun`` -- ordinary ML value and function declarations
11
+ ``val limit = 10; fun next n = n + 1;``
12
+ ``globref`` -- a mutable global reference
13
+ ``globref counter = 0;``
14
+
15
+ This module turns that text into:
16
+
17
+ * a **colour set registry**: name -> :class:`~openprocess.ml.colorsets.ColourSet`;
18
+ * a **variable table**: name -> the colour set it ranges over (the binder needs
19
+ this to know what to enumerate);
20
+ * **global environment entries**: enumeration constants, union constructors,
21
+ and any ``val``/``fun`` bindings, all installed into the evaluator.
22
+
23
+ Order matters -- a colour set may refer to earlier ones -- so declarations are
24
+ compiled in the order they appear, and a forward reference is an error with a
25
+ message naming the missing colour set.
26
+
27
+ Why a separate parser
28
+ ---------------------
29
+ Declaration syntax is *not* ML expression syntax: ``product A * B`` and
30
+ ``union C1:A + C2`` are CPN Tools' own notation. We reuse the ML
31
+ :func:`~openprocess.ml.lexer.tokenise` for the token stream, then apply a small
32
+ purpose-built grammar here.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from dataclasses import dataclass, field
38
+ from typing import Any, Sequence
39
+
40
+ from ..ml.colorsets import (
41
+ AliasColourSet, BoolColourSet, ColourSet, EnumColourSet, IndexColourSet,
42
+ IntColourSet, IntInfColourSet, ListColourSet, ProductColourSet,
43
+ RealColourSet, RecordColourSet, StringColourSet, SubsetColourSet,
44
+ UnionColourSet, UnitColourSet, standard_colour_sets,
45
+ )
46
+ from ..ml.errors import CPNMLError, ParseError
47
+ from ..ml.evaluator import ConstructorFunction, Evaluator
48
+ from ..ml.lexer import Token, tokenise
49
+ from ..ml.parser import Parser, parse_declarations
50
+ from ..ml.values import Constructor
51
+
52
+
53
+ @dataclass
54
+ class VariableDeclaration:
55
+ """``var name : COLOURSET;`` -- one variable usable in inscriptions."""
56
+
57
+ name: str
58
+ colour_set_name: str
59
+
60
+
61
+ @dataclass
62
+ class DeclarationBlock:
63
+ """The parsed contents of a model's global box.
64
+
65
+ Kept as ordered lists of *source text* alongside the compiled results, so
66
+ that saving a model reproduces exactly what the modeller wrote rather than
67
+ a pretty-printed approximation.
68
+ """
69
+
70
+ #: ``(name, source_text)`` for every ``colset`` declaration, in order.
71
+ colour_set_sources: list[tuple[str, str]] = field(default_factory=list)
72
+ #: ``(name, colour_set_name, source_text)`` for every ``var`` declaration.
73
+ variable_sources: list[tuple[str, str, str]] = field(default_factory=list)
74
+ #: Raw ``val`` / ``fun`` declaration text, in order.
75
+ ml_sources: list[str] = field(default_factory=list)
76
+ #: ``(name, initial_expression_text)`` for every ``globref``.
77
+ globref_sources: list[tuple[str, str]] = field(default_factory=list)
78
+
79
+ # Filled in by :meth:`compile`.
80
+ colour_sets: dict[str, ColourSet] = field(default_factory=dict)
81
+ variables: dict[str, str] = field(default_factory=dict)
82
+
83
+ # -- compilation ---------------------------------------------------------
84
+ def compile(self, evaluator: Evaluator) -> None:
85
+ """Build the colour sets and install everything into ``evaluator``.
86
+
87
+ Called once when a model is loaded and again whenever the modeller
88
+ edits a declaration. It is idempotent: the registry is rebuilt from
89
+ the standard colour sets each time, so removing a declaration really
90
+ removes it.
91
+ """
92
+ from ..ml.parser import parse_expression
93
+ self.colour_sets = standard_colour_sets()
94
+ self.variables = {}
95
+
96
+ for colour_set in self.colour_sets.values(): # INT.all () ...
97
+ _install_colour_set_functions(colour_set, evaluator)
98
+
99
+ # Colour sets and ML declarations may depend on each other in either
100
+ # direction: `val n = 5; colset N = int with 1..n;`, a subset by a
101
+ # predicate function, or `val phs = PH.all ();`. The three lists do
102
+ # not record how the modeller interleaved them, so compile in passes:
103
+ # each pass compiles whatever it can, and a declaration that fails
104
+ # waits for the next pass. Only when a whole pass makes no progress
105
+ # is the first remaining problem reported.
106
+ pending_sets = list(self.colour_sets_in_order())
107
+ pending_ml = list(self.ml_sources)
108
+ pending_refs = list(self.globref_sources)
109
+ while pending_sets or pending_ml or pending_refs:
110
+ problems: list[CPNMLError] = []
111
+ progress = False
112
+
113
+ waiting_sets = []
114
+ for name, source in pending_sets:
115
+ try:
116
+ colour_set = parse_colour_set_declaration(source, self.colour_sets, evaluator)
117
+ except CPNMLError as problem:
118
+ waiting_sets.append((name, source))
119
+ # Say which declaration failed: positions are relative to it.
120
+ problems.append(type(problem)(f"colset {name}: {problem.message}",
121
+ problem.position))
122
+ continue
123
+ self.colour_sets[colour_set.name] = colour_set
124
+ _install_constructors(colour_set, evaluator)
125
+ _install_colour_set_functions(colour_set, evaluator)
126
+ progress = True
127
+ pending_sets = waiting_sets
128
+
129
+ waiting_ml = []
130
+ for source in pending_ml:
131
+ try:
132
+ evaluator.run_declarations(parse_declarations(source))
133
+ except CPNMLError as problem:
134
+ waiting_ml.append(source)
135
+ problems.append(problem)
136
+ continue
137
+ progress = True
138
+ pending_ml = waiting_ml
139
+
140
+ waiting_refs = []
141
+ for name, initial in pending_refs:
142
+ try:
143
+ evaluator.globals.define(name, evaluator.evaluate(parse_expression(initial)))
144
+ except CPNMLError as problem:
145
+ waiting_refs.append((name, initial))
146
+ problems.append(type(problem)(f"globref {name}: {problem.message}",
147
+ problem.position))
148
+ continue
149
+ progress = True
150
+ pending_refs = waiting_refs
151
+
152
+ if not progress:
153
+ raise problems[0]
154
+
155
+ for name, colour_set_name, _source in self.variable_sources:
156
+ if colour_set_name not in self.colour_sets:
157
+ raise CPNMLError(
158
+ f"variable '{name}' is declared with unknown colour set "
159
+ f"'{colour_set_name}'"
160
+ )
161
+ self.variables[name] = colour_set_name
162
+
163
+ def colour_sets_in_order(self) -> list[tuple[str, str]]:
164
+ return list(self.colour_set_sources)
165
+
166
+ def variable_colour_set(self, name: str) -> ColourSet | None:
167
+ """The colour set a variable ranges over, or ``None`` if undeclared."""
168
+ colour_set_name = self.variables.get(name)
169
+ if colour_set_name is None:
170
+ return None
171
+ return self.colour_sets.get(colour_set_name)
172
+
173
+
174
+ def _install_constructors(colour_set: ColourSet, evaluator: Evaluator) -> None:
175
+ """Make a colour set's constants and constructors usable in inscriptions.
176
+
177
+ * enumeration constants (``red``) become bound values;
178
+ * index tags (``proc``) and union tags with a payload become functions;
179
+ * nullary union tags become bound values, like enumeration constants.
180
+
181
+ Without this step an inscription mentioning ``red`` would fail with
182
+ "unbound identifier".
183
+ """
184
+ if isinstance(colour_set, EnumColourSet):
185
+ for constant in colour_set.constants:
186
+ evaluator.globals.define(constant, Constructor(constant))
187
+ elif isinstance(colour_set, IndexColourSet):
188
+ evaluator.globals.define(colour_set.tag, ConstructorFunction(colour_set.tag))
189
+ elif isinstance(colour_set, UnionColourSet):
190
+ for tag, payload in colour_set.variants:
191
+ if payload is None:
192
+ evaluator.globals.define(tag, Constructor(tag))
193
+ else:
194
+ evaluator.globals.define(tag, ConstructorFunction(tag))
195
+ elif isinstance(colour_set, UnitColourSet) and colour_set.alias:
196
+ from ..ml.values import UNIT
197
+ evaluator.globals.define(colour_set.alias, UNIT)
198
+ elif isinstance(colour_set, BoolColourSet):
199
+ if colour_set.false_alias:
200
+ evaluator.globals.define(colour_set.false_alias, False)
201
+ if colour_set.true_alias:
202
+ evaluator.globals.define(colour_set.true_alias, True)
203
+
204
+
205
+ def _install_colour_set_functions(colour_set: ColourSet, evaluator: Evaluator) -> None:
206
+ """CPN Tools' colour set functions: ``PH.all()``, ``PH.ran()`` and friends.
207
+
208
+ Every colour set ``CS`` comes with
209
+
210
+ ``CS.all ()`` the multiset with one of each colour (finite sets only)
211
+ ``CS.size ()`` the number of colours
212
+ ``CS.ran ()`` a colour drawn at random
213
+ ``CS.ord c`` the position of ``c``, counting from 0
214
+ ``CS.col i`` the colour at position ``i``
215
+ ``CS.legal c`` whether ``c`` is a member
216
+ ``CS.mkstr c`` ``c`` as a string
217
+
218
+ The colours are listed on first use only, so declaring a large colour set
219
+ costs nothing until one of these is called.
220
+ """
221
+ from ..ml.builtins import Builtin
222
+ from ..ml.errors import EvalError
223
+ from ..ml.multiset import Multiset
224
+ from ..ml.values import format_value
225
+
226
+ name = colour_set.name
227
+ cache: list[list[Any]] = []
228
+
229
+ def members() -> list[Any]:
230
+ if not cache:
231
+ if not colour_set.is_finite():
232
+ raise EvalError(f"{name} is infinite, so it has no {name}.all/ran/ord/col")
233
+ cache.append(list(colour_set.members()))
234
+ return cache[0]
235
+
236
+ def ordinal(colour: Any) -> int:
237
+ try:
238
+ return members().index(colour)
239
+ except ValueError:
240
+ raise EvalError(f"{format_value(colour)} is not a colour of {name}") from None
241
+
242
+ def column(index: Any) -> Any:
243
+ colours = members()
244
+ if isinstance(index, bool) or not isinstance(index, int) or not 0 <= index < len(colours):
245
+ raise EvalError(f"{name}.col expects 0..{len(colours) - 1}, got {format_value(index)}")
246
+ return colours[index]
247
+
248
+ def random_colour(_unit: Any) -> Any:
249
+ colours = members()
250
+ if not colours:
251
+ raise EvalError(f"{name} is empty, so {name}.ran has nothing to choose")
252
+ return evaluator.rng.choice(colours)
253
+
254
+ functions = {
255
+ "all": lambda _unit: Multiset.from_values(members()),
256
+ "size": lambda _unit: len(members()),
257
+ "ran": random_colour,
258
+ "ord": ordinal,
259
+ "col": column,
260
+ "legal": lambda colour: bool(colour_set.contains(colour)),
261
+ "mkstr": lambda colour: format_value(colour),
262
+ }
263
+ for function_name, function in functions.items():
264
+ qualified = f"{name}.{function_name}"
265
+ evaluator.globals.define(qualified, Builtin(qualified, function))
266
+
267
+
268
+ # ===========================================================================
269
+ # The colour set declaration parser
270
+ # ===========================================================================
271
+ class _ColourSetParser(Parser):
272
+ """Extends the ML parser cursor with the ``colset`` grammar."""
273
+
274
+ def __init__(self, tokens: Sequence[Token], registry: dict[str, ColourSet], source: str,
275
+ evaluator: Evaluator | None = None) -> None:
276
+ super().__init__(tokens, source)
277
+ self.registry = registry
278
+ #: Evaluates named range bounds and subset predicates; without it only
279
+ #: literals are accepted and subsets keep every value.
280
+ self.evaluator = evaluator
281
+
282
+ # -- helpers -------------------------------------------------------------
283
+ def _resolve(self, name: str) -> ColourSet:
284
+ if name not in self.registry:
285
+ raise ParseError(
286
+ f"unknown colour set '{name}' -- declare it before it is used",
287
+ self.peek().position,
288
+ )
289
+ return self.registry[name]
290
+
291
+ def _constant_int(self) -> int:
292
+ """Read an integer bound: a literal, or an expression such as ``n``
293
+ or ``n * 2`` over values declared with ``val`` (as CPN Tools allows).
294
+ """
295
+ token = self.peek()
296
+ if token.kind == "INT" and (self.peek(1).kind == "EOF" or self._bound_ends(1)):
297
+ self.next_token()
298
+ return int(token.value) # type: ignore[arg-type]
299
+ value = self._evaluate_until_bound_end("a colour set range")
300
+ if isinstance(value, bool) or not isinstance(value, int):
301
+ raise ParseError(f"a colour set range must be an integer, got {value!r}",
302
+ token.position)
303
+ return value
304
+
305
+ def _bound_ends(self, offset: int = 0) -> bool:
306
+ """Does the token at ``offset`` end a range bound?"""
307
+ token = self.peek(offset)
308
+ if token.kind == "EOF":
309
+ return True
310
+ if token.kind == "OP" and token.value in ("..", ";"):
311
+ return True
312
+ return token.kind in ("ID", "KEYWORD") and token.value in ("timed", "and")
313
+
314
+ def _evaluate_until_bound_end(self, what: str) -> Any:
315
+ """Evaluate the tokens up to the end of a bound (``..``, ``;``, ...)."""
316
+ from ..ml.parser import parse_expression
317
+ start = self.peek().position
318
+ while not self._bound_ends():
319
+ self.next_token()
320
+ end = self.peek().position if not self.at_end() else len(self.source)
321
+ text = self.source[start:end].strip()
322
+ if not text:
323
+ raise ParseError(f"expected {what}", start)
324
+ if self.evaluator is None:
325
+ raise ParseError(f"{what} must be an integer literal here (found {text!r})", start)
326
+ return self.evaluator.evaluate(parse_expression(text))
327
+
328
+ def _string_literal(self) -> str:
329
+ token = self.next_token()
330
+ if token.kind not in ("STRING", "CHAR"):
331
+ raise ParseError("expected a string literal", token.position)
332
+ return str(token.value)
333
+
334
+ # -- the grammar ---------------------------------------------------------
335
+ def parse(self) -> ColourSet:
336
+ """``colset NAME = <definition> [timed];``"""
337
+ if self.peek().kind == "ID" and self.peek().value == "colset":
338
+ self.next_token()
339
+ name = self.expect_identifier()
340
+ self.expect_operator("=")
341
+ colour_set = self._parse_definition(name)
342
+ # The optional `timed` keyword follows the definition.
343
+ if self.peek().kind == "ID" and self.peek().value == "timed":
344
+ self.next_token()
345
+ colour_set.timed = True
346
+ if self.at_operator(";"):
347
+ self.next_token()
348
+ return colour_set
349
+
350
+ def _parse_definition(self, name: str) -> ColourSet:
351
+ token = self.peek()
352
+
353
+ # `with a | b | c` -- an enumeration.
354
+ if self._at_word("with"):
355
+ self.next_token()
356
+ constants = [self.expect_identifier()]
357
+ while self.at_operator("|"):
358
+ self.next_token()
359
+ constants.append(self.expect_identifier())
360
+ return EnumColourSet(name, constants)
361
+
362
+ if token.kind != "ID":
363
+ raise ParseError(
364
+ f"expected a colour set definition, found {token.value!r}", token.position
365
+ )
366
+
367
+ word = str(token.value)
368
+
369
+ if word == "unit":
370
+ self.next_token()
371
+ alias = None
372
+ if self._at_word("with"):
373
+ self.next_token()
374
+ alias = self.expect_identifier()
375
+ return UnitColourSet(name, alias=alias)
376
+
377
+ if word == "bool":
378
+ self.next_token()
379
+ false_alias = true_alias = None
380
+ if self._at_word("with"):
381
+ self.next_token()
382
+ self.expect_operator("(")
383
+ false_alias = self.expect_identifier()
384
+ self.expect_operator(",")
385
+ true_alias = self.expect_identifier()
386
+ self.expect_operator(")")
387
+ return BoolColourSet(name, false_alias=false_alias, true_alias=true_alias)
388
+
389
+ if word in ("int", "intinf"):
390
+ self.next_token()
391
+ low = high = None
392
+ if self._at_word("with"):
393
+ self.next_token()
394
+ low = self._constant_int()
395
+ self.expect_operator("..")
396
+ high = self._constant_int()
397
+ cls = IntColourSet if word == "int" else IntInfColourSet
398
+ return cls(name, low=low, high=high)
399
+
400
+ if word == "time":
401
+ # CPN Tools' built-in model-time type. Model time is a number
402
+ # (integer or real, depending on the simulator setting), so it
403
+ # behaves like an unbounded real colour set.
404
+ self.next_token()
405
+ return RealColourSet(name)
406
+
407
+ if word == "real":
408
+ self.next_token()
409
+ low = high = None
410
+ if self._at_word("with"):
411
+ self.next_token()
412
+ low = float(self.next_token().value) # type: ignore[arg-type]
413
+ self.expect_operator("..")
414
+ high = float(self.next_token().value) # type: ignore[arg-type]
415
+ return RealColourSet(name, low=low, high=high)
416
+
417
+ if word == "string":
418
+ self.next_token()
419
+ char_low = char_high = None
420
+ length_low = length_high = None
421
+ if self._at_word("with"):
422
+ self.next_token()
423
+ char_low = self._string_literal()
424
+ self.expect_operator("..")
425
+ char_high = self._string_literal()
426
+ if self._at_word("and"):
427
+ self.next_token()
428
+ length_low = self._constant_int()
429
+ self.expect_operator("..")
430
+ length_high = self._constant_int()
431
+ return StringColourSet(
432
+ name, char_low=char_low, char_high=char_high,
433
+ length_low=length_low, length_high=length_high,
434
+ )
435
+
436
+ if word == "index":
437
+ self.next_token()
438
+ tag = self.expect_identifier()
439
+ self._expect_word("with")
440
+ low = self._constant_int()
441
+ self.expect_operator("..")
442
+ high = self._constant_int()
443
+ return IndexColourSet(name, tag, low, high)
444
+
445
+ if word == "product":
446
+ self.next_token()
447
+ components = [self._resolve(self.expect_identifier())]
448
+ while self.at_operator("*"):
449
+ self.next_token()
450
+ components.append(self._resolve(self.expect_identifier()))
451
+ return ProductColourSet(name, components)
452
+
453
+ if word == "record":
454
+ self.next_token()
455
+ fields = [self._parse_record_field()]
456
+ while self.at_operator("*"):
457
+ self.next_token()
458
+ fields.append(self._parse_record_field())
459
+ return RecordColourSet(name, fields)
460
+
461
+ if word == "list":
462
+ self.next_token()
463
+ element = self._resolve(self.expect_identifier())
464
+ length_low = length_high = None
465
+ if self._at_word("with"):
466
+ self.next_token()
467
+ length_low = self._constant_int()
468
+ self.expect_operator("..")
469
+ length_high = self._constant_int()
470
+ return ListColourSet(name, element, length_low=length_low, length_high=length_high)
471
+
472
+ if word == "union":
473
+ self.next_token()
474
+ variants = [self._parse_union_variant()]
475
+ while self.at_operator("+"):
476
+ self.next_token()
477
+ variants.append(self._parse_union_variant())
478
+ return UnionColourSet(name, variants)
479
+
480
+ if word == "subset":
481
+ self.next_token()
482
+ base = self._resolve(self.expect_identifier())
483
+ return self._parse_subset(name, base)
484
+
485
+ # Anything else is an alias for an existing colour set.
486
+ self.next_token()
487
+ return AliasColourSet(name, self._resolve_named(word, token.position))
488
+
489
+ # -- pieces --------------------------------------------------------------
490
+ def _resolve_named(self, name: str, position: int) -> ColourSet:
491
+ if name not in self.registry:
492
+ raise ParseError(f"unknown colour set '{name}'", position)
493
+ return self.registry[name]
494
+
495
+ def _parse_record_field(self) -> tuple[str, ColourSet]:
496
+ field_name = self.expect_identifier()
497
+ self.expect_operator(":")
498
+ return field_name, self._resolve(self.expect_identifier())
499
+
500
+ def _parse_union_variant(self) -> tuple[str, ColourSet | None]:
501
+ tag = self.expect_identifier()
502
+ if self.at_operator(":"):
503
+ self.next_token()
504
+ return tag, self._resolve(self.expect_identifier())
505
+ return tag, None
506
+
507
+ def _parse_subset(self, name: str, base: ColourSet) -> ColourSet:
508
+ """``subset A by pred`` or ``subset A with [v1, v2]``.
509
+
510
+ ``by`` takes a predicate function, ``with`` an explicit member list.
511
+ The predicate is stored as source text and compiled lazily by the
512
+ model, because it may refer to functions declared further down.
513
+ """
514
+ if self._at_word("by"):
515
+ self.next_token()
516
+ start = self.peek().position
517
+ # The predicate runs to the end of the declaration.
518
+ while not self.at_end() and not self.at_operator(";") and not self._at_word("timed"):
519
+ self.next_token()
520
+ end = self.peek().position if not self.at_end() else len(self.source)
521
+ source = self.source[start:end].strip()
522
+ if self.evaluator is None:
523
+ # Parsed only for its shape (no evaluator): keep every value.
524
+ return SubsetColourSet(name, base, lambda _v: True, predicate_source=source)
525
+ from ..ml.parser import parse_expression
526
+ evaluator = self.evaluator
527
+ # Evaluated now, so an undeclared predicate makes this colour set
528
+ # wait until the ML declarations have defined it.
529
+ function = evaluator.evaluate(parse_expression(source))
530
+
531
+ def predicate(value: Any) -> bool:
532
+ try:
533
+ return evaluator.apply(function, value) is True
534
+ except CPNMLError:
535
+ return False
536
+
537
+ return SubsetColourSet(name, base, predicate, predicate_source=source)
538
+
539
+ if self._at_word("with"):
540
+ self.next_token()
541
+ self.expect_operator("[")
542
+ start = self.peek().position
543
+ depth = 1
544
+ while depth:
545
+ if self.at_operator("["):
546
+ depth += 1
547
+ elif self.at_operator("]"):
548
+ depth -= 1
549
+ if depth == 0:
550
+ break
551
+ self.next_token()
552
+ end = self.peek().position
553
+ source = self.source[start:end]
554
+ self.expect_operator("]")
555
+ if self.evaluator is None:
556
+ return SubsetColourSet(name, base, lambda _v: True,
557
+ predicate_source=f"MEMBERS[{source}]")
558
+ from ..ml.parser import parse_expression
559
+ listed = list(self.evaluator.evaluate(parse_expression(f"[{source}]")))
560
+ for value in listed:
561
+ if not base.contains(value):
562
+ raise ParseError(f"{value!r} in subset '{name}' is not a member of "
563
+ f"'{base.name}'", start)
564
+ return SubsetColourSet(name, base, lambda value: value in listed,
565
+ predicate_source=f"MEMBERS[{source}]",
566
+ members=listed)
567
+
568
+ raise ParseError("'subset' must be followed by 'by' or 'with'", self.peek().position)
569
+
570
+ # -- word helpers (CPN keywords are plain identifiers to the ML lexer) ---
571
+ def _at_word(self, word: str) -> bool:
572
+ token = self.peek()
573
+ return token.kind == "ID" and token.value == word
574
+
575
+ def _expect_word(self, word: str) -> None:
576
+ if not self._at_word(word):
577
+ raise ParseError(f"expected '{word}'", self.peek().position)
578
+ self.next_token()
579
+
580
+
581
+ def parse_colour_set_declaration(source: str, registry: dict[str, ColourSet],
582
+ evaluator: Evaluator | None = None) -> ColourSet:
583
+ """Parse one ``colset ... = ...;`` declaration against ``registry``.
584
+
585
+ With an ``evaluator``, range bounds may name declared values and subset
586
+ predicates are compiled; without one, only literal bounds are accepted.
587
+ """
588
+ return _ColourSetParser(tokenise(source), registry, source, evaluator).parse()
589
+
590
+
591
+ def parse_variable_declaration(source: str) -> list[VariableDeclaration]:
592
+ """Parse ``var a, b : COLOURSET;`` into one entry per name."""
593
+ parser = Parser(tokenise(source), source)
594
+ if parser.peek().kind == "ID" and parser.peek().value == "var":
595
+ parser.next_token()
596
+ names = [parser.expect_identifier()]
597
+ while parser.at_operator(","):
598
+ parser.next_token()
599
+ names.append(parser.expect_identifier())
600
+ parser.expect_operator(":")
601
+ colour_set_name = parser.expect_identifier()
602
+ return [VariableDeclaration(name, colour_set_name) for name in names]
603
+
604
+
605
+ def classify_declaration(source: str) -> str:
606
+ """Return ``'colset'``, ``'var'``, ``'globref'`` or ``'ml'`` for a snippet.
607
+
608
+ Used by the ``.cpn`` reader, which sometimes receives a declaration block
609
+ as one lump of text rather than as individually tagged elements.
610
+ """
611
+ stripped = source.lstrip()
612
+ for keyword in ("colset", "var", "globref"):
613
+ if stripped.startswith(keyword) and (
614
+ len(stripped) == len(keyword) or not stripped[len(keyword)].isalnum()
615
+ ):
616
+ return keyword
617
+ return "ml"