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,357 @@
1
+ """Writer for CPN Tools ``.cpn`` files.
2
+
3
+ The goal is a file that CPN Tools 4.0 and CPN IDE will both open without
4
+ complaint, so we emit the full element set they expect -- including the
5
+ graphical attribute groups that are structurally required even when they carry
6
+ only defaults.
7
+
8
+ Round-trip policy
9
+ -----------------
10
+ Everything this implementation models (declarations, pages, places,
11
+ transitions, arcs, their inscriptions and their layout) is written from the
12
+ model objects, so edits are reflected. Everything it does *not* model
13
+ (monitors, binders, workspace options, the hierarchy index) is copied verbatim
14
+ from the tree the file was read from, when there is one. That combination
15
+ means:
16
+
17
+ * a file we opened and saved without editing is semantically identical;
18
+ * a file we edited keeps its monitors and workspace settings;
19
+ * a file we created from scratch gets a minimal but valid set of those
20
+ sections.
21
+
22
+ Colour set declarations are written as ``<layout>`` text plus the minimum
23
+ structured markup CPN Tools needs to recognise the kind. CPN Tools re-derives
24
+ its internal representation from the layout text on load, so this is the
25
+ faithful thing to write.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import xml.etree.ElementTree as ElementTree
31
+ from pathlib import Path
32
+ from xml.dom import minidom
33
+
34
+ from ..model.net import Arc, CPNet, Graphics, Page, Place, Transition
35
+
36
+ #: The DOCTYPE CPN Tools writes. Kept byte-identical so that tools which
37
+ #: sniff the header recognise our output.
38
+ DOCTYPE = (
39
+ '<!DOCTYPE workspaceElements PUBLIC "-//CPN//DTD CPNXML 1.0//EN" '
40
+ '"http://cpntools.org/DTD/6/cpn.dtd">'
41
+ )
42
+
43
+ _ID_SEQUENCE = [0]
44
+
45
+
46
+ def _fresh_id() -> str:
47
+ """Ids for elements that need one but that the model does not track
48
+ (annotation boxes, type labels, and so on)."""
49
+ _ID_SEQUENCE[0] += 1
50
+ return f"IDW{_ID_SEQUENCE[0]}"
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Attribute groups
55
+ # ---------------------------------------------------------------------------
56
+ def _add_graphics(parent: ElementTree.Element, graphics: Graphics) -> None:
57
+ """Write the four attribute elements every graphical object carries."""
58
+ ElementTree.SubElement(parent, "posattr", x=f"{graphics.x:.6f}", y=f"{graphics.y:.6f}")
59
+ ElementTree.SubElement(
60
+ parent, "fillattr", colour=graphics.fill_colour, pattern="", filled="false"
61
+ )
62
+ ElementTree.SubElement(
63
+ parent, "lineattr", colour=graphics.line_colour,
64
+ thick=str(int(graphics.line_width)), type="Solid",
65
+ )
66
+ ElementTree.SubElement(parent, "textattr", colour=graphics.text_colour, bold="false")
67
+
68
+
69
+ def _at(graphics: Graphics, tag: str, default_x: float, default_y: float) -> tuple[float, float]:
70
+ """Absolute position for a node's inscription: its stored offset (or a
71
+ default offset) added to the node's own position."""
72
+ dx, dy = graphics.label_offsets.get(tag, (default_x, default_y))
73
+ return graphics.x + dx, graphics.y + dy
74
+
75
+
76
+ def _add_annotation(parent: ElementTree.Element, tag: str, text: str,
77
+ x: float = 0.0, y: float = 0.0) -> ElementTree.Element:
78
+ """Write an inscription element such as ``<initmark>`` or ``<cond>``.
79
+
80
+ CPN Tools represents every inscription as a small graphical object with its
81
+ own id, position and a ``<text>`` payload -- even an empty one, which is
82
+ why we always emit the element rather than skipping it when the text is
83
+ blank.
84
+ """
85
+ element = ElementTree.SubElement(parent, tag, id=_fresh_id())
86
+ _add_graphics(element, Graphics(x=x, y=y))
87
+ text_element = ElementTree.SubElement(element, "text")
88
+ text_element.text = text
89
+ return element
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Declarations
94
+ # ---------------------------------------------------------------------------
95
+ def _structured_colour_set(parent: ElementTree.Element, source: str) -> None:
96
+ """Emit the minimal structured markup for a colour set declaration.
97
+
98
+ CPN Tools reads the ``<layout>`` text authoritatively, but its parser still
99
+ expects one of the recognised kind elements to be present. We infer the
100
+ kind from the declaration text -- which we know is well-formed, because the
101
+ model compiled it -- and emit that one element.
102
+ """
103
+ body = source.split("=", 1)[1] if "=" in source else source
104
+ body = body.strip().rstrip(";").strip()
105
+ if body.endswith("timed"):
106
+ body = body[: -len("timed")].strip()
107
+
108
+ first_word = body.split()[0] if body.split() else "unit"
109
+ simple = {"unit", "bool", "int", "intinf", "real", "string"}
110
+ if first_word in simple:
111
+ ElementTree.SubElement(parent, first_word)
112
+ elif first_word == "with":
113
+ enumeration = ElementTree.SubElement(parent, "enum")
114
+ for constant in body[len("with"):].split("|"):
115
+ identifier = ElementTree.SubElement(enumeration, "id")
116
+ identifier.text = constant.strip()
117
+ elif first_word in ("product", "record", "list", "union", "index", "subset"):
118
+ ElementTree.SubElement(parent, first_word)
119
+ else:
120
+ ElementTree.SubElement(parent, "alias")
121
+
122
+
123
+ def _write_globbox(cpnet: ElementTree.Element, net: CPNet) -> None:
124
+ """Write all declarations into a single block.
125
+
126
+ CPN Tools groups declarations into named blocks purely for display. We
127
+ write one block called "Declarations" and keep the original ordering, which
128
+ is what determines whether forward references resolve.
129
+ """
130
+ globbox = ElementTree.SubElement(cpnet, "globbox")
131
+ block = ElementTree.SubElement(globbox, "block", id=_fresh_id())
132
+ block_id = ElementTree.SubElement(block, "id")
133
+ block_id.text = "Declarations"
134
+
135
+ for name, source in net.declarations.colour_set_sources:
136
+ colour = ElementTree.SubElement(block, "color", id=_fresh_id())
137
+ identifier = ElementTree.SubElement(colour, "id")
138
+ identifier.text = name
139
+ _structured_colour_set(colour, source)
140
+ if source.rstrip().rstrip(";").rstrip().endswith("timed"):
141
+ ElementTree.SubElement(colour, "timed")
142
+ layout = ElementTree.SubElement(colour, "layout")
143
+ layout.text = source
144
+
145
+ # Group variables that share a declaration line back together, so the file
146
+ # reads the way the modeller wrote it.
147
+ seen_sources: set[str] = set()
148
+ for _name, colour_set_name, source in net.declarations.variable_sources:
149
+ if source in seen_sources:
150
+ continue
151
+ seen_sources.add(source)
152
+ variable = ElementTree.SubElement(block, "var", id=_fresh_id())
153
+ type_element = ElementTree.SubElement(variable, "type")
154
+ type_id = ElementTree.SubElement(type_element, "id")
155
+ type_id.text = colour_set_name
156
+ for name, other_colour_set, other_source in net.declarations.variable_sources:
157
+ if other_source == source:
158
+ identifier = ElementTree.SubElement(variable, "id")
159
+ identifier.text = name
160
+ layout = ElementTree.SubElement(variable, "layout")
161
+ layout.text = source
162
+
163
+ for source in net.declarations.ml_sources:
164
+ ml = ElementTree.SubElement(block, "ml", id=_fresh_id())
165
+ ml.text = source
166
+ layout = ElementTree.SubElement(ml, "layout")
167
+ layout.text = source
168
+
169
+ for name, initial in net.declarations.globref_sources:
170
+ globref = ElementTree.SubElement(block, "globref", id=_fresh_id())
171
+ identifier = ElementTree.SubElement(globref, "id")
172
+ identifier.text = name
173
+ ml = ElementTree.SubElement(globref, "ml")
174
+ ml.text = initial
175
+ layout = ElementTree.SubElement(globref, "layout")
176
+ layout.text = f"globref {name} = {initial};"
177
+
178
+
179
+ # ---------------------------------------------------------------------------
180
+ # Net elements
181
+ # ---------------------------------------------------------------------------
182
+ def _write_place(page_element: ElementTree.Element, place: Place) -> None:
183
+ element = ElementTree.SubElement(page_element, "place", id=place.id)
184
+ _add_graphics(element, place.graphics)
185
+
186
+ text = ElementTree.SubElement(element, "text")
187
+ text.text = place.name
188
+
189
+ ElementTree.SubElement(
190
+ element, "ellipse",
191
+ w=f"{place.graphics.width:.6f}", h=f"{place.graphics.height:.6f}",
192
+ )
193
+ # The token and marking elements position the little circle and the count
194
+ # that CPN Tools draws next to a marked place (offsets, kept from the file).
195
+ offsets = place.graphics.label_offsets
196
+ token_x, token_y = offsets.get("token", (-10.0, 0.0))
197
+ ElementTree.SubElement(element, "token", x=f"{token_x:.6f}", y=f"{token_y:.6f}")
198
+ marking_x, marking_y = offsets.get("marking", (0.0, 0.0))
199
+ marking = ElementTree.SubElement(
200
+ element, "marking", x=f"{marking_x:.6f}", y=f"{marking_y:.6f}",
201
+ hidden="true" if place.graphics.marking_hidden else "false",
202
+ )
203
+ ElementTree.SubElement(
204
+ marking, "snap", snap_id="0",
205
+ **{"anchor.horizontal": "0", "anchor.vertical": "0"},
206
+ )
207
+
208
+ # Inscriptions go where they were (relative to the place, so they follow
209
+ # it when it is moved), or at CPN Tools' usual spots for new places.
210
+ _add_annotation(element, "type", place.colour_set_name,
211
+ *_at(place.graphics, "type", 30.0, -20.0))
212
+ _add_annotation(element, "initmark", place.initial_marking_text,
213
+ *_at(place.graphics, "initmark", 35.0, 20.0))
214
+
215
+ if place.port_type:
216
+ ElementTree.SubElement(element, "port", id=_fresh_id(), type=place.port_type)
217
+ if place.fusion_group:
218
+ ElementTree.SubElement(
219
+ element, "fusioninfo", id=_fresh_id(), name=place.fusion_group
220
+ )
221
+
222
+
223
+ def _write_transition(page_element: ElementTree.Element, transition: Transition) -> None:
224
+ element = ElementTree.SubElement(
225
+ page_element, "trans", id=transition.id, explicit="false"
226
+ )
227
+ _add_graphics(element, transition.graphics)
228
+
229
+ text = ElementTree.SubElement(element, "text")
230
+ text.text = transition.name
231
+
232
+ ElementTree.SubElement(
233
+ element, "box",
234
+ w=f"{transition.graphics.width:.6f}", h=f"{transition.graphics.height:.6f}",
235
+ )
236
+ ElementTree.SubElement(element, "binding", x="7.200000", y="-3.000000")
237
+
238
+ graphics = transition.graphics
239
+ _add_annotation(element, "cond", transition.guard_text, *_at(graphics, "cond", -40.0, 20.0))
240
+ _add_annotation(element, "time", transition.time_text, *_at(graphics, "time", 40.0, 20.0))
241
+ _add_annotation(element, "code", transition.code_text, *_at(graphics, "code", 40.0, -20.0))
242
+ _add_annotation(element, "priority", transition.priority_text,
243
+ *_at(graphics, "priority", -40.0, -20.0))
244
+
245
+ if transition.is_substitution:
246
+ portsock = "".join(
247
+ f"({socket},{port})" for socket, port in transition.port_assignments.items()
248
+ )
249
+ ElementTree.SubElement(
250
+ element, "subst",
251
+ subpage=transition.substitution_subpage or "",
252
+ portsock=portsock,
253
+ )
254
+
255
+
256
+ def _write_arc(page_element: ElementTree.Element, arc: Arc, order: int,
257
+ page: Page | None = None) -> None:
258
+ element = ElementTree.SubElement(
259
+ page_element, "arc", id=arc.id, orientation=arc.orientation, order=str(order)
260
+ )
261
+ _add_graphics(element, arc.graphics)
262
+ ElementTree.SubElement(element, "arrowattr", headsize="1.200000", currentcyckle="2")
263
+ ElementTree.SubElement(element, "transend", idref=arc.transition_id)
264
+ ElementTree.SubElement(element, "placeend", idref=arc.place_id)
265
+
266
+ for serial, (x, y) in enumerate(arc.bendpoints, start=1):
267
+ bendpoint = ElementTree.SubElement(
268
+ element, "bendpoint", id=_fresh_id(), serial=str(serial)
269
+ )
270
+ _add_graphics(bendpoint, Graphics(x=x, y=y))
271
+
272
+ # Arc inscriptions are stored as absolute positions; new arcs get theirs
273
+ # halfway between the two nodes.
274
+ position = arc.graphics.label_offsets.get("annot")
275
+ if position is None and page is not None:
276
+ place, transition = page.place(arc.place_id), page.transition(arc.transition_id)
277
+ if place is not None and transition is not None:
278
+ position = ((place.graphics.x + transition.graphics.x) / 2 + 8,
279
+ (place.graphics.y + transition.graphics.y) / 2 + 8)
280
+ x, y = position if position is not None else (arc.graphics.x, arc.graphics.y)
281
+ _add_annotation(element, "annot", arc.expression_text, x=x, y=y)
282
+
283
+
284
+ def _write_page(cpnet: ElementTree.Element, page: Page) -> None:
285
+ element = ElementTree.SubElement(cpnet, "page", id=page.id)
286
+ ElementTree.SubElement(element, "pageattr", name=page.name)
287
+ for place in page.places:
288
+ _write_place(element, place)
289
+ for transition in page.transitions:
290
+ _write_transition(element, transition)
291
+ for order, arc in enumerate(page.arcs, start=1):
292
+ _write_arc(element, arc, order, page)
293
+ ElementTree.SubElement(element, "constraints")
294
+
295
+
296
+ # ---------------------------------------------------------------------------
297
+ # Sections we preserve rather than model
298
+ # ---------------------------------------------------------------------------
299
+ _PRESERVED_SECTIONS = ("instances", "options", "binders", "monitorblock", "IndexNode")
300
+
301
+
302
+ def _write_preserved(cpnet: ElementTree.Element, net: CPNet) -> None:
303
+ """Copy sections we do not model from the original file, or emit defaults."""
304
+ original = None
305
+ if net.source_tree is not None:
306
+ original = net.source_tree.find("cpnet")
307
+ if original is None and net.source_tree.tag == "cpnet":
308
+ original = net.source_tree
309
+
310
+ for section in _PRESERVED_SECTIONS:
311
+ existing = original.find(section) if original is not None else None
312
+ if existing is not None:
313
+ cpnet.append(existing)
314
+ continue
315
+ if section == "instances":
316
+ instances = ElementTree.SubElement(cpnet, "instances")
317
+ for page in net.pages:
318
+ ElementTree.SubElement(
319
+ instances, "instance", id=_fresh_id(), page=page.id
320
+ )
321
+ elif section == "monitorblock":
322
+ ElementTree.SubElement(cpnet, "monitorblock", name="Monitors")
323
+ elif section in ("options", "binders"):
324
+ ElementTree.SubElement(cpnet, section)
325
+
326
+
327
+ # ---------------------------------------------------------------------------
328
+ # Public entry points
329
+ # ---------------------------------------------------------------------------
330
+ def to_xml_string(net: CPNet, pretty: bool = True) -> str:
331
+ """Serialise a model to ``.cpn`` XML text (including the DOCTYPE)."""
332
+ from .. import __version__
333
+ root = ElementTree.Element("workspaceElements")
334
+ ElementTree.SubElement(
335
+ root, "generator", tool="OpenProcess", version=__version__, format="6"
336
+ )
337
+ cpnet = ElementTree.SubElement(root, "cpnet")
338
+
339
+ _write_globbox(cpnet, net)
340
+ for page in net.pages:
341
+ _write_page(cpnet, page)
342
+ _write_preserved(cpnet, net)
343
+
344
+ raw = ElementTree.tostring(root, encoding="unicode")
345
+ if pretty:
346
+ # minidom's pretty printer is slow but the files are small, and a
347
+ # readable .cpn is genuinely useful when diffing models in git.
348
+ parsed = minidom.parseString(raw)
349
+ raw = parsed.documentElement.toprettyxml(indent=" ")
350
+ return f'<?xml version="1.0" encoding="iso-8859-1"?>\n{DOCTYPE}\n{raw}'
351
+
352
+
353
+ def write_cpn(net: CPNet, path: str | Path, pretty: bool = True) -> Path:
354
+ """Save a model to disk and return the path written."""
355
+ file_path = Path(path)
356
+ file_path.write_text(to_xml_string(net, pretty=pretty), encoding="utf-8")
357
+ return file_path
@@ -0,0 +1,23 @@
1
+ """OpenProcess Learn: exercise packs, their answer boxes, and checking them.
2
+
3
+ * :mod:`.sheet` -- reading ``question.md``: Markdown with ``answer`` blocks
4
+ and front matter;
5
+ * :mod:`.pack` -- exercise folders, packs of them, the student's progress,
6
+ points and exam settings;
7
+ * :mod:`.answers` and :mod:`.notation` -- reading typed answers: sets, pairs,
8
+ traces, numbers, markings, matrices, cuts, trees, alignments…;
9
+ * :mod:`.computed` -- the right answers worked out from the exercise's files
10
+ (``compute: alpha.T_I``), one documented function each;
11
+ * :mod:`.checks` -- checking an answer, with partial credit;
12
+ * :mod:`.exam` -- exam mode, the clock, marks and variants;
13
+ * :mod:`.importer` -- a past exam's text into a skeleton pack.
14
+
15
+ No Qt here: the same code checks answers in the app (``openprocess.gui.learn``)
16
+ and in ``openprocess exercises check`` (see ``openprocess/learn/exercise-packs.md``).
17
+ """
18
+
19
+ from .pack import Exercise, ExerciseFiles, Pack, exercise_files, load_pack, pack_root
20
+ from .sheet import Sheet, SheetError, Task, parse_sheet
21
+
22
+ __all__ = ["Exercise", "ExerciseFiles", "Pack", "Sheet", "SheetError", "Task",
23
+ "exercise_files", "load_pack", "pack_root", "parse_sheet"]
@@ -0,0 +1,188 @@
1
+ """Reading what a student typed: sets, pairs, traces, numbers.
2
+
3
+ Answers are typed the way they are written on paper, and read leniently:
4
+
5
+ ========================== =========================================
6
+ typed read as
7
+ ========================== =========================================
8
+ ``a, b, c`` the set {a, b, c}
9
+ ``{a, b}`` the set {a, b} (the braces are optional)
10
+ ``{s1,s3}, {s2,s3}`` a set of two sets
11
+ ``({a},{b,d}), ({b},{e})`` a set of two pairs (as in X_L and Y_L)
12
+ ``<a, b, c>`` or ``⟨a,b⟩`` a sequence (a trace)
13
+ ``∅`` or ``{}`` the empty set
14
+ ========================== =========================================
15
+
16
+ Names are compared without regard to case or spacing, and ``s_1``,
17
+ ``s_{1}`` and ``s1`` are the same name (subscripts as typed in LaTeX). The
18
+ same reading is applied to the expected answer, so a professor can write it
19
+ in any of these forms too.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import re
25
+
26
+
27
+ class Sequence(tuple):
28
+ """``<a, b, c>``: kept apart from a pair ``(a, b)``."""
29
+
30
+ def __repr__(self) -> str:
31
+ return "Sequence" + super().__repr__()
32
+
33
+
34
+ _SUBSCRIPT = re.compile(r"_\{([^{}]*)\}|_(\w)")
35
+ _LATEX = {"\\{": "{", "\\}": "}", "\\langle": "⟨", "\\rangle": "⟩", "\\emptyset": "∅",
36
+ "\\varnothing": "∅", "\\;": " ", "\\,": " ", "\\ ": " "}
37
+ _OPEN = {"{": "}", "(": ")", "<": ">", "⟨": "⟩", "[": "]"}
38
+ _CLOSE = set(_OPEN.values())
39
+
40
+
41
+ def name(text: str) -> str:
42
+ """One name, as compared: ``" S_1 "`` and ``"s1"`` are the same."""
43
+ text = _SUBSCRIPT.sub(lambda m: m.group(1) if m.group(1) is not None else m.group(2), text)
44
+ return " ".join(text.replace("$", "").split()).casefold()
45
+
46
+
47
+ def _tokens(text: str) -> list[str]:
48
+ # s_{1} first: its braces are not a set.
49
+ text = _SUBSCRIPT.sub(lambda m: m.group(1) if m.group(1) is not None else m.group(2), text)
50
+ for latex, plain in _LATEX.items():
51
+ text = text.replace(latex, plain)
52
+ text = text.replace("$", "")
53
+ tokens, current = [], []
54
+ for char in text:
55
+ if char in _OPEN or char in _CLOSE or char in ",;":
56
+ if "".join(current).strip():
57
+ tokens.append("".join(current).strip())
58
+ current = []
59
+ tokens.append("," if char == ";" else char)
60
+ else:
61
+ current.append(char)
62
+ if "".join(current).strip():
63
+ tokens.append("".join(current).strip())
64
+ return tokens
65
+
66
+
67
+ class AnswerSyntaxError(ValueError):
68
+ pass
69
+
70
+
71
+ def _parse_items(tokens: list[str], position: int, closing: str | None):
72
+ items, expecting_item = [], True
73
+ while position < len(tokens):
74
+ token = tokens[position]
75
+ if token == closing:
76
+ return items, position + 1
77
+ if token in _CLOSE:
78
+ raise AnswerSyntaxError(f"“{token}” does not close anything here")
79
+ if token == ",":
80
+ expecting_item = True
81
+ position += 1
82
+ continue
83
+ if not expecting_item:
84
+ raise AnswerSyntaxError("a comma is missing between two items")
85
+ if token in _OPEN:
86
+ inner, position = _parse_items(tokens, position + 1, _OPEN[token])
87
+ if token == "{":
88
+ item = frozenset(inner)
89
+ elif token in ("<", "⟨"):
90
+ item = Sequence(inner)
91
+ elif token == "[":
92
+ item = tuple(inner) # a multiset/list: order kept
93
+ else:
94
+ item = tuple(inner)
95
+ items.append(item)
96
+ elif token == "∅":
97
+ items.append(frozenset())
98
+ position += 1
99
+ else:
100
+ items.append(name(token))
101
+ position += 1
102
+ expecting_item = False
103
+ if closing is not None:
104
+ raise AnswerSyntaxError(f"“{closing}” is missing")
105
+ return items, position
106
+
107
+
108
+ def parse(text: str) -> list:
109
+ """The top-level items of an answer (each a name, set, pair or sequence)."""
110
+ items, _ = _parse_items(_tokens(text), 0, None)
111
+ return items
112
+
113
+
114
+ def readings(text: str) -> list:
115
+ """The ways ``text`` can be read as a set: the items as a set, and, when it
116
+ is one braced set, that set itself (``{a, b}`` and ``a, b`` agree, and
117
+ ``{{s1}}`` is the same as ``{s1}`` written with outer braces)."""
118
+ items = parse(text)
119
+ result = [frozenset(items)]
120
+ if len(items) == 1 and isinstance(items[0], frozenset):
121
+ result.append(items[0])
122
+ return result
123
+
124
+
125
+ def as_set(value) -> frozenset:
126
+ """An expected value (from a compute or a parsed answer) as a frozenset."""
127
+ if isinstance(value, str):
128
+ options = readings(value)
129
+ return options[-1] if len(options) > 1 else options[0]
130
+ return frozenset(normalised(v) for v in value)
131
+
132
+
133
+ def normalised(value):
134
+ """Names inside a computed value, compared as typed answers are."""
135
+ if isinstance(value, str):
136
+ return name(value)
137
+ if isinstance(value, frozenset | set):
138
+ return frozenset(normalised(v) for v in value)
139
+ if isinstance(value, Sequence):
140
+ return Sequence(normalised(v) for v in value)
141
+ if isinstance(value, tuple | list):
142
+ return tuple(normalised(v) for v in value)
143
+ return value
144
+
145
+
146
+ def show(value) -> str:
147
+ """A value written back in the notation of the course."""
148
+ if isinstance(value, str):
149
+ return value
150
+ if isinstance(value, frozenset | set):
151
+ if not value:
152
+ return "∅"
153
+ return "{" + ", ".join(sorted(show(v) for v in value)) + "}"
154
+ if isinstance(value, Sequence):
155
+ return "⟨" + ", ".join(show(v) for v in value) + "⟩"
156
+ if isinstance(value, tuple | list):
157
+ return "(" + ", ".join(show(v) for v in value) + ")"
158
+ return str(value)
159
+
160
+
161
+ def show_set(value) -> str:
162
+ """A set of items without its outer braces when they are themselves sets or
163
+ pairs: ``{a}, {b}`` rather than ``{{a}, {b}}``."""
164
+ if isinstance(value, frozenset | set) and value and all(
165
+ isinstance(v, frozenset | tuple) for v in value):
166
+ return ", ".join(sorted(show(v) for v in value))
167
+ return show(value)
168
+
169
+
170
+ def number(text: str) -> float:
171
+ """``0.75``, ``0,75``, ``3/4`` or ``75%``."""
172
+ text = text.strip().replace(" ", "")
173
+ if text.endswith("%"):
174
+ return float(text[:-1].replace(",", ".")) / 100
175
+ if "/" in text:
176
+ top, bottom = text.split("/", 1)
177
+ return float(top.replace(",", ".")) / float(bottom.replace(",", "."))
178
+ return float(text.replace(",", "."))
179
+
180
+
181
+ def trace(text: str) -> list[str]:
182
+ """A firing sequence: ``register, send letter`` or ``⟨register, send letter⟩``."""
183
+ items = parse(text)
184
+ if len(items) == 1 and isinstance(items[0], Sequence | tuple):
185
+ items = list(items[0])
186
+ if not all(isinstance(item, str) for item in items):
187
+ raise AnswerSyntaxError("write the steps as names separated by commas")
188
+ return items