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,389 @@
1
+ """Reader for CPN Tools ``.cpn`` files.
2
+
3
+ File shape
4
+ ----------
5
+ A ``.cpn`` file is XML with this skeleton (attributes elided)::
6
+
7
+ <workspaceElements>
8
+ <generator tool="CPN Tools" version="4.0.1" format="6"/>
9
+ <cpnet>
10
+ <globbox> <!-- declarations, grouped into blocks -->
11
+ <block><id>...</id>
12
+ <color id="..."><id>E</id>... <layout>colset E = ...;</layout></color>
13
+ <var id="..."><type><id>E</id></type><id>c</id><layout>var c : E;</layout></var>
14
+ <ml id="...">fun f x = ...<layout>fun f x = ...</layout></ml>
15
+ </block>
16
+ </globbox>
17
+ <page id="...">
18
+ <pageattr name="Top"/>
19
+ <place id="..."> ... </place>
20
+ <trans id="..."> ... </trans>
21
+ <arc id="..." orientation="PtoT"> ... </arc>
22
+ </page>
23
+ <instances/> <options/> <binders/> <monitorblock/>
24
+ </cpnet>
25
+ </workspaceElements>
26
+
27
+ Robustness strategy
28
+ -------------------
29
+ CPN Tools has emitted several format revisions and third-party tools emit
30
+ near-misses, so the reader is deliberately forgiving:
31
+
32
+ * unknown elements and attributes are ignored, not rejected;
33
+ * every field has a sensible default, so a missing ``<lineattr>`` costs you a
34
+ line colour, not the whole file;
35
+ * declaration text is taken from the ``<layout>`` element whenever present,
36
+ because that is the exact source the modeller typed. Only if it is missing
37
+ do we reconstruct the declaration from the structured sub-elements.
38
+
39
+ The DTD is referenced in the file's DOCTYPE but is not fetched: parsing runs
40
+ with entity resolution off, so a ``.cpn`` file cannot make us open a network
41
+ connection or read a local file.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import xml.etree.ElementTree as ElementTree
47
+ from pathlib import Path
48
+ from typing import Any
49
+
50
+ from ..model.declarations import parse_variable_declaration
51
+ from ..model.net import Arc, CPNet, Graphics, Page, Place, Transition
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Small helpers for pulling values out of the tree
56
+ # ---------------------------------------------------------------------------
57
+ def _text_of(element: ElementTree.Element | None, default: str = "") -> str:
58
+ """Text content of an element, with whitespace trimmed."""
59
+ if element is None or element.text is None:
60
+ return default
61
+ return element.text.strip()
62
+
63
+ def _child_text(parent: ElementTree.Element, tag: str, default: str = "") -> str:
64
+ """Text of ``parent/tag/text`` -- the shape CPN Tools uses for inscriptions."""
65
+ child = parent.find(tag)
66
+ if child is None:
67
+ return default
68
+ inner = child.find("text")
69
+ if inner is not None:
70
+ return _text_of(inner, default)
71
+ return _text_of(child, default)
72
+
73
+
74
+ def _float_attribute(element: ElementTree.Element | None, name: str, default: float) -> float:
75
+ if element is None:
76
+ return default
77
+ raw = element.get(name)
78
+ if raw is None:
79
+ return default
80
+ try:
81
+ return float(raw)
82
+ except ValueError:
83
+ return default
84
+
85
+
86
+ def _read_graphics(element: ElementTree.Element,
87
+ shape_tag: str | None = None) -> Graphics:
88
+ """Read the ``posattr`` / ``fillattr`` / ``lineattr`` / ``textattr`` group.
89
+
90
+ ``shape_tag`` is ``"ellipse"`` for places and ``"box"`` for transitions;
91
+ both carry the width and height as ``w`` and ``h``.
92
+ """
93
+ graphics = Graphics()
94
+ position = element.find("posattr")
95
+ graphics.x = _float_attribute(position, "x", 0.0)
96
+ graphics.y = _float_attribute(position, "y", 0.0)
97
+
98
+ fill = element.find("fillattr")
99
+ if fill is not None:
100
+ graphics.fill_colour = fill.get("colour", graphics.fill_colour)
101
+
102
+ line = element.find("lineattr")
103
+ if line is not None:
104
+ graphics.line_colour = line.get("colour", graphics.line_colour)
105
+ try:
106
+ graphics.line_width = float(line.get("thick", graphics.line_width))
107
+ except ValueError:
108
+ pass
109
+
110
+ text = element.find("textattr")
111
+ if text is not None:
112
+ graphics.text_colour = text.get("colour", graphics.text_colour)
113
+
114
+ if shape_tag:
115
+ shape = element.find(shape_tag)
116
+ graphics.width = _float_attribute(shape, "w", graphics.width)
117
+ graphics.height = _float_attribute(shape, "h", graphics.height)
118
+
119
+ # Where the modeller put each inscription (the centre of its text), kept
120
+ # as an offset from the element so the label moves with it. Arcs have no
121
+ # position of their own: their offsets are absolute and the canvas turns
122
+ # them into offsets from the arc's midpoint.
123
+ for tag in ("type", "initmark", "cond", "time", "annot"):
124
+ region = element.find(tag)
125
+ if region is None:
126
+ continue
127
+ region_position = region.find("posattr")
128
+ if region_position is None:
129
+ continue
130
+ x = _float_attribute(region_position, "x", 0.0)
131
+ y = _float_attribute(region_position, "y", 0.0)
132
+ if shape_tag:
133
+ graphics.label_offsets[tag] = (x - graphics.x, y - graphics.y)
134
+ else:
135
+ graphics.label_offsets[tag] = (x, y)
136
+
137
+ # The current-marking bubble (``token``) and text (``marking``) of a place.
138
+ for tag in ("token", "marking"):
139
+ region = element.find(tag)
140
+ if region is not None:
141
+ graphics.label_offsets[tag] = (_float_attribute(region, "x", 0.0),
142
+ _float_attribute(region, "y", 0.0))
143
+ marking = element.find("marking")
144
+ if marking is not None:
145
+ graphics.marking_hidden = marking.get("hidden", "false").strip().lower() == "true"
146
+
147
+ return graphics
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # Declarations
152
+ # ---------------------------------------------------------------------------
153
+ def _reconstruct_colour_set_declaration(element: ElementTree.Element) -> str:
154
+ """Rebuild ``colset NAME = ...;`` from the structured XML.
155
+
156
+ Only used when the ``<layout>`` element is absent. We cover the common
157
+ forms; anything exotic falls back to an alias of ``UNIT`` so that the model
158
+ still loads and the problem shows up as a compile issue rather than a
159
+ crash.
160
+ """
161
+ name = _text_of(element.find("id"), "UNNAMED")
162
+ timed = " timed" if element.find("timed") is not None else ""
163
+
164
+ if element.find("enum") is not None:
165
+ constants = [_text_of(i) for i in element.find("enum").findall("id")]
166
+ return f"colset {name} = with {' | '.join(constants)}{timed};"
167
+
168
+ if element.find("product") is not None:
169
+ parts = [_text_of(i) for i in element.find("product").findall("id")]
170
+ return f"colset {name} = product {' * '.join(parts)}{timed};"
171
+
172
+ if element.find("record") is not None:
173
+ fields = []
174
+ for record_field in element.find("record").findall("recordfield"):
175
+ fields.append(
176
+ f"{_text_of(record_field.find('id'))}:{_text_of(record_field.find('id'), '')}"
177
+ )
178
+ return f"colset {name} = record {' * '.join(fields)}{timed};"
179
+
180
+ if element.find("list") is not None:
181
+ inner = _text_of(element.find("list").find("id"), "UNIT")
182
+ return f"colset {name} = list {inner}{timed};"
183
+
184
+ if element.find("index") is not None:
185
+ index = element.find("index")
186
+ tag = _text_of(index.find("id"), "idx")
187
+ bounds = [_text_of(m) for m in index.findall("ml")]
188
+ low, high = (bounds + ["1", "1"])[:2]
189
+ return f"colset {name} = index {tag} with {low}..{high}{timed};"
190
+
191
+ if element.find("union") is not None:
192
+ variants = []
193
+ for union_field in element.find("union").findall("unionfield"):
194
+ tag = _text_of(union_field.find("id"))
195
+ payload = union_field.find("id[2]")
196
+ variants.append(f"{tag}:{_text_of(payload)}" if payload is not None else tag)
197
+ return f"colset {name} = union {' + '.join(variants)}{timed};"
198
+
199
+ for simple in ("unit", "bool", "int", "intinf", "real", "string"):
200
+ if element.find(simple) is not None:
201
+ return f"colset {name} = {simple}{timed};"
202
+
203
+ # `alias` or anything unrecognised.
204
+ alias = element.find("alias")
205
+ if alias is not None:
206
+ return f"colset {name} = {_text_of(alias.find('id'), 'UNIT')}{timed};"
207
+ return f"colset {name} = UNIT{timed};"
208
+
209
+
210
+ def _read_globbox(globbox: ElementTree.Element, net: CPNet) -> None:
211
+ """Walk the declaration tree, in document order, filling the model.
212
+
213
+ Blocks may nest, so this recurses. Order is preserved because a colour set
214
+ may refer to earlier ones.
215
+ """
216
+
217
+ def visit(node: ElementTree.Element) -> None:
218
+ for child in node:
219
+ tag = child.tag
220
+ if tag == "block":
221
+ visit(child)
222
+ elif tag == "color":
223
+ layout = _text_of(child.find("layout"))
224
+ source = layout or _reconstruct_colour_set_declaration(child)
225
+ name = _text_of(child.find("id"), "UNNAMED")
226
+ net.declarations.colour_set_sources.append((name, source))
227
+ elif tag == "var":
228
+ layout = _text_of(child.find("layout"))
229
+ if layout:
230
+ try:
231
+ for declaration in parse_variable_declaration(layout):
232
+ net.declarations.variable_sources.append(
233
+ (declaration.name, declaration.colour_set_name, layout)
234
+ )
235
+ continue
236
+ except Exception:
237
+ pass # fall through to the structured form
238
+ colour_set_name = _text_of(child.find("type/id"), "UNIT")
239
+ for identifier in child.findall("id"):
240
+ variable_name = _text_of(identifier)
241
+ if variable_name:
242
+ net.declarations.variable_sources.append(
243
+ (variable_name, colour_set_name,
244
+ f"var {variable_name} : {colour_set_name};")
245
+ )
246
+ elif tag == "ml":
247
+ layout = _text_of(child.find("layout"))
248
+ source = layout or _text_of(child)
249
+ if source:
250
+ net.declarations.ml_sources.append(source)
251
+ elif tag == "globref":
252
+ name = _text_of(child.find("id"), "")
253
+ initial = _text_of(child.find("ml"), "0")
254
+ if name:
255
+ net.declarations.globref_sources.append((name, initial))
256
+
257
+ visit(globbox)
258
+
259
+
260
+ # ---------------------------------------------------------------------------
261
+ # Net elements
262
+ # ---------------------------------------------------------------------------
263
+ def _read_place(element: ElementTree.Element) -> Place:
264
+ place = Place(
265
+ id=element.get("id", ""),
266
+ name=_text_of(element.find("text")),
267
+ colour_set_name=_child_text(element, "type", "UNIT"),
268
+ initial_marking_text=_child_text(element, "initmark", ""),
269
+ graphics=_read_graphics(element, "ellipse"),
270
+ source_element=element,
271
+ )
272
+ port = element.find("port")
273
+ if port is not None:
274
+ place.port_type = port.get("type", "General")
275
+ fusion = element.find("fusioninfo")
276
+ if fusion is not None:
277
+ place.fusion_group = fusion.get("name")
278
+ return place
279
+
280
+
281
+ def _read_transition(element: ElementTree.Element) -> Transition:
282
+ transition = Transition(
283
+ id=element.get("id", ""),
284
+ name=_text_of(element.find("text")),
285
+ guard_text=_child_text(element, "cond", ""),
286
+ time_text=_child_text(element, "time", ""),
287
+ code_text=_child_text(element, "code", ""),
288
+ priority_text=_child_text(element, "priority", ""),
289
+ graphics=_read_graphics(element, "box"),
290
+ source_element=element,
291
+ )
292
+ substitution = element.find("subst")
293
+ if substitution is not None:
294
+ transition.substitution_subpage = substitution.get("subpage")
295
+ # `portsock` is a flat string of alternating socket/port ids in
296
+ # parentheses, e.g. "(ID1,ID2)(ID3,ID4)".
297
+ raw = substitution.get("portsock", "")
298
+ for pair in raw.replace(")", ")\n").split():
299
+ cleaned = pair.strip("()")
300
+ if "," in cleaned:
301
+ socket, port = cleaned.split(",", 1)
302
+ transition.port_assignments[socket.strip()] = port.strip()
303
+ return transition
304
+
305
+
306
+ def _read_arc(element: ElementTree.Element) -> Arc:
307
+ place_end = element.find("placeend")
308
+ transition_end = element.find("transend")
309
+ arc = Arc(
310
+ id=element.get("id", ""),
311
+ place_id=place_end.get("idref", "") if place_end is not None else "",
312
+ transition_id=transition_end.get("idref", "") if transition_end is not None else "",
313
+ orientation=element.get("orientation", "PtoT"),
314
+ expression_text=_child_text(element, "annot", ""),
315
+ graphics=_read_graphics(element),
316
+ source_element=element,
317
+ )
318
+ for bendpoint in element.findall("bendpoint"):
319
+ position = bendpoint.find("posattr")
320
+ arc.bendpoints.append(
321
+ (_float_attribute(position, "x", 0.0), _float_attribute(position, "y", 0.0))
322
+ )
323
+ return arc
324
+
325
+
326
+ def _read_page(element: ElementTree.Element) -> Page:
327
+ attributes = element.find("pageattr")
328
+ page = Page(
329
+ id=element.get("id", ""),
330
+ name=attributes.get("name", "Page") if attributes is not None else "Page",
331
+ source_element=element,
332
+ )
333
+ page.places = [_read_place(e) for e in element.findall("place")]
334
+ page.transitions = [_read_transition(e) for e in element.findall("trans")]
335
+ arcs = [_read_arc(e) for e in element.findall("arc")]
336
+ # Files edited in CPN Tools sometimes keep arcs whose place or transition
337
+ # was deleted (the course's PlaneBoardingExample.cpn has two). CPN Tools
338
+ # ignores them; so do we -- an arc to nowhere cannot be simulated or drawn.
339
+ place_ids = {p.id for p in page.places}
340
+ transition_ids = {t.id for t in page.transitions}
341
+ page.arcs = [a for a in arcs if a.place_id in place_ids and a.transition_id in transition_ids]
342
+ page.dropped_arcs = len(arcs) - len(page.arcs)
343
+ return page
344
+
345
+
346
+ # ---------------------------------------------------------------------------
347
+ # Public entry points
348
+ # ---------------------------------------------------------------------------
349
+ def parse_cpn(xml_text: str, name: str = "Untitled") -> CPNet:
350
+ """Parse ``.cpn`` XML held in a string."""
351
+ # ``XMLParser`` with the default target does not expand external entities,
352
+ # so a malicious DOCTYPE cannot read local files or hit the network.
353
+ root = ElementTree.fromstring(xml_text)
354
+
355
+ net = CPNet(name=name)
356
+ net.source_tree = root
357
+
358
+ cpnet = root.find("cpnet") if root.tag != "cpnet" else root
359
+ if cpnet is None:
360
+ raise ValueError("not a CPN Tools file: no <cpnet> element found")
361
+
362
+ globbox = cpnet.find("globbox")
363
+ if globbox is not None:
364
+ _read_globbox(globbox, net)
365
+
366
+ for page_element in cpnet.findall("page"):
367
+ net.pages.append(_read_page(page_element))
368
+
369
+ # Fusion sets are listed separately; each names the places it unifies.
370
+ for fusion in cpnet.findall(".//fusion"):
371
+ group = fusion.get("name", "")
372
+ members = [e.get("idref", "") for e in fusion.findall("fusion_elm")]
373
+ if group:
374
+ net.fusion_sets[group] = members
375
+
376
+ net.compile()
377
+ return net
378
+
379
+
380
+ def read_cpn(path: str | Path) -> CPNet:
381
+ """Load a ``.cpn`` file from disk.
382
+
383
+ The model's name is taken from the file stem, which is what CPN Tools shows
384
+ in its title bar.
385
+ """
386
+ file_path = Path(path)
387
+ net = parse_cpn(file_path.read_text(encoding="utf-8", errors="replace"),
388
+ name=file_path.stem)
389
+ return net