atform 0.1__2-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.
- atform/__init__.py +42 -0
- atform/content.py +463 -0
- atform/error.py +175 -0
- atform/field.py +142 -0
- atform/format.py +146 -0
- atform/id.py +218 -0
- atform/image.py +121 -0
- atform/label.py +72 -0
- atform/misc.py +120 -0
- atform/pdf.py +1157 -0
- atform/ref.py +88 -0
- atform/sig.py +33 -0
- atform/textstyle.py +135 -0
- atform/vcs.py +74 -0
- atform/version.py +60 -0
- atform-0.1.dist-info/METADATA +60 -0
- atform-0.1.dist-info/RECORD +19 -0
- atform-0.1.dist-info/WHEEL +4 -0
- atform-0.1.dist-info/licenses/LICENSE.txt +28 -0
atform/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from atform.content import (
|
|
2
|
+
generate,
|
|
3
|
+
Test,
|
|
4
|
+
)
|
|
5
|
+
|
|
6
|
+
from atform.field import (
|
|
7
|
+
add_field,
|
|
8
|
+
set_active_fields,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
from atform.format import (
|
|
12
|
+
bullet_list,
|
|
13
|
+
format_text,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from atform.id import (
|
|
17
|
+
section,
|
|
18
|
+
set_id_depth,
|
|
19
|
+
skip_test,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from atform.image import (
|
|
23
|
+
add_logo,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from atform.misc import (
|
|
27
|
+
add_copyright,
|
|
28
|
+
set_project_info,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
from atform.ref import (
|
|
32
|
+
add_reference_category,
|
|
33
|
+
get_xref,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
from atform.sig import (
|
|
37
|
+
add_signature,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
from atform.version import (
|
|
41
|
+
require_version,
|
|
42
|
+
)
|
atform/content.py
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
# This module implements the objects storing test procedure content as it is
|
|
2
|
+
# created.
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
from . import error
|
|
6
|
+
from . import id
|
|
7
|
+
from . import field
|
|
8
|
+
from . import label
|
|
9
|
+
from . import misc
|
|
10
|
+
from . import pdf
|
|
11
|
+
from . import ref
|
|
12
|
+
from . import vcs
|
|
13
|
+
import collections
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# All Test() instances in the order they were created.
|
|
17
|
+
tests = []
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ProcedureStep(object):
|
|
21
|
+
"""Object containing all user-provided content for a single procedure step.
|
|
22
|
+
|
|
23
|
+
This is not created directly by the user, but is instantiated using
|
|
24
|
+
an item, string or dict, from the procedure parameter list of Test.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, raw, num):
|
|
28
|
+
data = self._normalize_type(raw)
|
|
29
|
+
self.text = self._validate_text(data)
|
|
30
|
+
self.fields = self._validate_fields(data)
|
|
31
|
+
self._validate_label(data, num)
|
|
32
|
+
self._check_undefined_keys(data)
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def _normalize_type(raw):
|
|
36
|
+
"""Normalizes the raw data into a dict."""
|
|
37
|
+
# Convert a string to a dict with text key.
|
|
38
|
+
if isinstance(raw, str):
|
|
39
|
+
normalized = {"text": raw}
|
|
40
|
+
|
|
41
|
+
elif isinstance(raw, dict):
|
|
42
|
+
normalized = raw
|
|
43
|
+
|
|
44
|
+
else:
|
|
45
|
+
raise error.UserScriptError(
|
|
46
|
+
f"Invalid procedure step data type: {type(raw).__name__}",
|
|
47
|
+
"A procedure step must be a string or dictionary.",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
return normalized
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def _check_undefined_keys(data):
|
|
54
|
+
"""Raises an exception for any unconsumed keys."""
|
|
55
|
+
if data:
|
|
56
|
+
keys = [str(k) for k in data.keys()]
|
|
57
|
+
raise error.UserScriptError(
|
|
58
|
+
"Undefined procedure step dictionary key(s): {0}".format(
|
|
59
|
+
", ".join(keys)
|
|
60
|
+
))
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def _validate_text(data):
|
|
64
|
+
"""Validates the text key."""
|
|
65
|
+
try:
|
|
66
|
+
text = data.pop("text")
|
|
67
|
+
except KeyError:
|
|
68
|
+
raise error.UserScriptError(
|
|
69
|
+
'A procedure step dictionary must have a "text" key.',
|
|
70
|
+
"""Add a "text" key with a string value containing
|
|
71
|
+
instructions for the step.""",
|
|
72
|
+
)
|
|
73
|
+
return misc.nonempty_string("Procedure step text", text)
|
|
74
|
+
|
|
75
|
+
def _validate_fields(self, data):
|
|
76
|
+
"""Validates the fields key."""
|
|
77
|
+
tpls = data.pop("fields", [])
|
|
78
|
+
if not isinstance(tpls, list):
|
|
79
|
+
raise error.UserScriptError(
|
|
80
|
+
f"""
|
|
81
|
+
Invalid procedure step fields data type:
|
|
82
|
+
{type(tpls).__name__}
|
|
83
|
+
""",
|
|
84
|
+
"Procedure step fields must be a list.",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
fields = []
|
|
88
|
+
for i in range(len(tpls)):
|
|
89
|
+
try:
|
|
90
|
+
fields.append(self._create_field(tpls[i]))
|
|
91
|
+
except error.UserScriptError as e:
|
|
92
|
+
e.add_field("Procedure Step Field #", i+1)
|
|
93
|
+
raise
|
|
94
|
+
return fields
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def _create_field(tpl):
|
|
98
|
+
"""
|
|
99
|
+
Converts a raw procedure step field definition tuple into a
|
|
100
|
+
named tuple.
|
|
101
|
+
"""
|
|
102
|
+
if not isinstance(tpl, tuple):
|
|
103
|
+
raise error.UserScriptError(
|
|
104
|
+
f"""
|
|
105
|
+
Invalid procedure step field list item data type:
|
|
106
|
+
{type(tpl).__name__}
|
|
107
|
+
""",
|
|
108
|
+
"""
|
|
109
|
+
Each item in the list of fields for a procedure step must
|
|
110
|
+
be a tuple.
|
|
111
|
+
""",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Validate the required items: title and length.
|
|
115
|
+
try:
|
|
116
|
+
raw_title = tpl[0]
|
|
117
|
+
raw_length = tpl[1]
|
|
118
|
+
except IndexError:
|
|
119
|
+
raise error.UserScriptError(
|
|
120
|
+
"""
|
|
121
|
+
Procedure step field tuple is too short.
|
|
122
|
+
""",
|
|
123
|
+
"""
|
|
124
|
+
A tuple defining a data entry field for a procedure step
|
|
125
|
+
must have at least two members: title and length.
|
|
126
|
+
""",
|
|
127
|
+
)
|
|
128
|
+
else:
|
|
129
|
+
title = misc.nonempty_string(
|
|
130
|
+
"Procedure step field title",
|
|
131
|
+
raw_title
|
|
132
|
+
)
|
|
133
|
+
length = misc.validate_field_length(raw_length)
|
|
134
|
+
|
|
135
|
+
# Validate suffix, providing a default value if omitted.
|
|
136
|
+
try:
|
|
137
|
+
raw = tpl[2]
|
|
138
|
+
except IndexError:
|
|
139
|
+
suffix = ""
|
|
140
|
+
else:
|
|
141
|
+
suffix = misc.nonempty_string("Procedure step field suffix", raw)
|
|
142
|
+
|
|
143
|
+
if len(tpl) > 3:
|
|
144
|
+
raise error.UserScriptError(
|
|
145
|
+
"""
|
|
146
|
+
Procedure step field tuple is too long.
|
|
147
|
+
""",
|
|
148
|
+
"""
|
|
149
|
+
A tuple defining a data entry field for a procedure step
|
|
150
|
+
may not exceed three members: title, length, and suffix.
|
|
151
|
+
""",
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
return ProcedureStepField(title, length, suffix)
|
|
155
|
+
|
|
156
|
+
@staticmethod
|
|
157
|
+
def _validate_label(data, num):
|
|
158
|
+
"""Creates a label referencing this step."""
|
|
159
|
+
try:
|
|
160
|
+
lbl = data.pop("label")
|
|
161
|
+
|
|
162
|
+
# Label is optional; do nothing if omitted.
|
|
163
|
+
except KeyError:
|
|
164
|
+
pass
|
|
165
|
+
|
|
166
|
+
else:
|
|
167
|
+
label.add(lbl, str(num))
|
|
168
|
+
|
|
169
|
+
def resolve_labels(self):
|
|
170
|
+
"""Replaces label placeholders with their target IDs."""
|
|
171
|
+
self.text = label.resolve(self.text)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# Container to hold normalized procedure step field definitions. This is
|
|
175
|
+
# not part of the public API as fields are defined via normal tuples, which
|
|
176
|
+
# are then validated to create instances of this named tuple.
|
|
177
|
+
ProcedureStepField = collections.namedtuple(
|
|
178
|
+
"ProcedureStepField",
|
|
179
|
+
["title", "length", "suffix"],
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
################################################################################
|
|
184
|
+
# Public API
|
|
185
|
+
#
|
|
186
|
+
# Items in this area are documented and exported for use by end users.
|
|
187
|
+
################################################################################
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@error.exit_on_script_error
|
|
191
|
+
class Test(object):
|
|
192
|
+
"""Creates a single test procedure.
|
|
193
|
+
|
|
194
|
+
Numeric identifiers will be incrementally assigned to each test in the
|
|
195
|
+
order they appear.
|
|
196
|
+
|
|
197
|
+
.. seealso:: :ref:`write`
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
title (str): A short phrase describing the test procedure, that is
|
|
201
|
+
combined with the automatically-assigned numeric ID to identify
|
|
202
|
+
this specific test.
|
|
203
|
+
label (str, optional): An identifier for use in content strings to
|
|
204
|
+
refer back to this test. See :ref:`labels`.
|
|
205
|
+
include_fields (list[str], optional): Names of fields to add to
|
|
206
|
+
this test. See :py:func:`atform.add_field`.
|
|
207
|
+
exclude_fields (list[str], optional): Names of fields to remove
|
|
208
|
+
from this test. See :py:func:`atform.add_field`.
|
|
209
|
+
active_fields (list[str], optional): Names of fields to apply
|
|
210
|
+
to this test. See :py:func:`atform.add_field`.
|
|
211
|
+
objective (str, optional): A longer narrative, possibly spanning
|
|
212
|
+
several sentences or paragraphs, describing the intent of the
|
|
213
|
+
test procedure.
|
|
214
|
+
references (dict, optional): A mapping from category labels
|
|
215
|
+
defined with :py:func:`atform.add_reference_category`
|
|
216
|
+
to lists of reference strings for that category.
|
|
217
|
+
For example, ``{"C1":["rA", "rB"]}`` would result in references
|
|
218
|
+
``"rA"`` and ``"rB"`` to be listed under the ``"C1"`` category.
|
|
219
|
+
See :ref:`ref`.
|
|
220
|
+
equipment (list[str], optional): A list of equipment required to
|
|
221
|
+
perform the procedure; will be rendered as a bullet list under
|
|
222
|
+
a dedicated section heading.
|
|
223
|
+
preconditions (list[str], optional): A list of conditions that must be
|
|
224
|
+
met before the procedure can commence.
|
|
225
|
+
procedure (list[str or dict], optional): A list of procedure steps to
|
|
226
|
+
be output as an enumerated list. See :ref:`procedure`.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
def __init__(self,
|
|
230
|
+
title,
|
|
231
|
+
label=None,
|
|
232
|
+
include_fields=[],
|
|
233
|
+
exclude_fields=[],
|
|
234
|
+
active_fields=None,
|
|
235
|
+
objective=None,
|
|
236
|
+
references={},
|
|
237
|
+
equipment=[],
|
|
238
|
+
preconditions=[],
|
|
239
|
+
procedure=[],
|
|
240
|
+
):
|
|
241
|
+
global tests
|
|
242
|
+
self.id = id.get_id()
|
|
243
|
+
try:
|
|
244
|
+
self.title = misc.nonempty_string("Title", title)
|
|
245
|
+
self._store_label(label)
|
|
246
|
+
self.fields = field.get_active_fields(
|
|
247
|
+
include_fields,
|
|
248
|
+
exclude_fields,
|
|
249
|
+
active_fields,
|
|
250
|
+
)
|
|
251
|
+
self.objective = self._validate_objective(objective)
|
|
252
|
+
self.references = self._validate_refs(references)
|
|
253
|
+
self.equipment = self._validate_equipment(equipment)
|
|
254
|
+
self.preconditions = self._validate_string_list("Preconditions",
|
|
255
|
+
preconditions)
|
|
256
|
+
self.procedure = self._validate_procedure(procedure)
|
|
257
|
+
except error.UserScriptError as e:
|
|
258
|
+
self._add_exception_context(e)
|
|
259
|
+
|
|
260
|
+
# The current project information is captured using copy() because
|
|
261
|
+
# the project information dictionary may change for later tests;
|
|
262
|
+
# copy() ensures this instance's values are unaffected.
|
|
263
|
+
self.project_info = misc.project_info.copy()
|
|
264
|
+
|
|
265
|
+
tests.append(self)
|
|
266
|
+
|
|
267
|
+
def _store_label(self, lbl):
|
|
268
|
+
"""Assigns this test to a given label."""
|
|
269
|
+
if lbl is not None:
|
|
270
|
+
id_string = id.to_string(self.id)
|
|
271
|
+
label.add(lbl, id_string)
|
|
272
|
+
|
|
273
|
+
@staticmethod
|
|
274
|
+
def _validate_objective(obj):
|
|
275
|
+
"""Validates the objective parameter."""
|
|
276
|
+
if obj is not None:
|
|
277
|
+
return misc.nonempty_string("Objective", obj)
|
|
278
|
+
|
|
279
|
+
def _validate_refs(self, refs):
|
|
280
|
+
"""Validates the references parameter."""
|
|
281
|
+
if not isinstance(refs, dict):
|
|
282
|
+
raise error.UserScriptError("References must be a dictionary.")
|
|
283
|
+
raise error.UserScriptError(
|
|
284
|
+
f"Invalid references data type: {type(refs).__name__}",
|
|
285
|
+
"References must be a dictionary.",
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
validated = {}
|
|
289
|
+
[validated.update(self._validate_ref_category(label, refs[label]))
|
|
290
|
+
for label in refs]
|
|
291
|
+
return validated
|
|
292
|
+
|
|
293
|
+
@staticmethod
|
|
294
|
+
def _validate_ref_category(label, refs):
|
|
295
|
+
"""Validates a single reference category and associated references."""
|
|
296
|
+
label = misc.nonempty_string("Reference label", label)
|
|
297
|
+
|
|
298
|
+
# Ensure the label has been defined by add_reference_category().
|
|
299
|
+
try:
|
|
300
|
+
ref.titles[label]
|
|
301
|
+
except KeyError:
|
|
302
|
+
raise error.UserScriptError(
|
|
303
|
+
f"Invalid reference label: {label}",
|
|
304
|
+
"""Use a reference label that has been previously defined
|
|
305
|
+
with atform.add_reference_category.""",
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
# Check the list of references for this category.
|
|
309
|
+
validated_refs = []
|
|
310
|
+
|
|
311
|
+
if not isinstance(refs, list):
|
|
312
|
+
raise TypeError(
|
|
313
|
+
f'Reference items for "{label}" category must be contained '
|
|
314
|
+
"in a list")
|
|
315
|
+
|
|
316
|
+
for reference in refs:
|
|
317
|
+
try:
|
|
318
|
+
if not isinstance(reference, str):
|
|
319
|
+
raise error.UserScriptError(
|
|
320
|
+
f"""
|
|
321
|
+
Invalid reference list item data type:
|
|
322
|
+
{type(reference).__name__}
|
|
323
|
+
""",
|
|
324
|
+
"""
|
|
325
|
+
Items in the list for a reference category
|
|
326
|
+
must be strings.
|
|
327
|
+
""",
|
|
328
|
+
)
|
|
329
|
+
reference = reference.strip()
|
|
330
|
+
|
|
331
|
+
# Reject duplicate references.
|
|
332
|
+
if reference in validated_refs:
|
|
333
|
+
raise error.UserScriptError(
|
|
334
|
+
f"Duplicate reference: {reference}",
|
|
335
|
+
"""Ensure all references within a category are
|
|
336
|
+
unique."""
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
except error.UserScriptError as e:
|
|
340
|
+
e.add_field("Reference Category", label)
|
|
341
|
+
raise
|
|
342
|
+
|
|
343
|
+
# Ignore blank/empty references.
|
|
344
|
+
if reference:
|
|
345
|
+
validated_refs.append(reference)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
return {label: validated_refs}
|
|
349
|
+
|
|
350
|
+
def _validate_equipment(self, equip):
|
|
351
|
+
"""Validates the equipment parameter."""
|
|
352
|
+
return self._validate_string_list("Equipment", equip)
|
|
353
|
+
|
|
354
|
+
@staticmethod
|
|
355
|
+
def _validate_procedure(lst):
|
|
356
|
+
"""Validates the procedure parameter."""
|
|
357
|
+
if not isinstance(lst, list):
|
|
358
|
+
raise error.UserScriptError("Procedure must be a list.")
|
|
359
|
+
steps = []
|
|
360
|
+
for i in range(len(lst)):
|
|
361
|
+
num = i + 1 # Step numbers are one-based.
|
|
362
|
+
try:
|
|
363
|
+
steps.append(ProcedureStep(lst[i], num))
|
|
364
|
+
except error.UserScriptError as e:
|
|
365
|
+
e.add_field("Procedure Step", num)
|
|
366
|
+
raise
|
|
367
|
+
return steps
|
|
368
|
+
|
|
369
|
+
@staticmethod
|
|
370
|
+
def _validate_string_list(name, lst):
|
|
371
|
+
"""Checks a list to ensure it contains only non-empty/blank strings."""
|
|
372
|
+
if not isinstance(lst, list):
|
|
373
|
+
raise error.UserScriptError(
|
|
374
|
+
f"{name} must be a list of strings.",
|
|
375
|
+
)
|
|
376
|
+
items = []
|
|
377
|
+
for i in range(len(lst)):
|
|
378
|
+
try:
|
|
379
|
+
items.append(misc.nonempty_string(f"{name} list item", lst[i]))
|
|
380
|
+
except error.UserScriptError as e:
|
|
381
|
+
e.add_field(f"{name} item #", i+1)
|
|
382
|
+
raise
|
|
383
|
+
return items
|
|
384
|
+
|
|
385
|
+
@error.external_call
|
|
386
|
+
def _pregenerate(self):
|
|
387
|
+
"""
|
|
388
|
+
Performs tasks that need to occur after all tests have been defined,
|
|
389
|
+
but before actual output is generated.
|
|
390
|
+
"""
|
|
391
|
+
try:
|
|
392
|
+
self._resolve_labels()
|
|
393
|
+
except error.UserScriptError as e:
|
|
394
|
+
self._add_exception_context(e)
|
|
395
|
+
|
|
396
|
+
def _resolve_labels(self):
|
|
397
|
+
"""Replaces label placeholders with their target IDs."""
|
|
398
|
+
if self.objective:
|
|
399
|
+
try:
|
|
400
|
+
self.objective = label.resolve(self.objective)
|
|
401
|
+
except error.UserScriptError as e:
|
|
402
|
+
e.add_field("Test Section", "Objective")
|
|
403
|
+
raise
|
|
404
|
+
|
|
405
|
+
for i in range(len(self.preconditions)):
|
|
406
|
+
try:
|
|
407
|
+
self.preconditions[i] = label.resolve(self.preconditions[i])
|
|
408
|
+
except error.UserScriptError as e:
|
|
409
|
+
e.add_field("Precondition Item", i+1)
|
|
410
|
+
raise
|
|
411
|
+
|
|
412
|
+
for i in range(len(self.procedure)):
|
|
413
|
+
try:
|
|
414
|
+
self.procedure[i].resolve_labels()
|
|
415
|
+
except error.UserScriptError as e:
|
|
416
|
+
e.add_field("Procedure Step", i+1)
|
|
417
|
+
raise
|
|
418
|
+
|
|
419
|
+
def _add_exception_context(self, e):
|
|
420
|
+
"""Adds information identifying this test to a UserScriptError."""
|
|
421
|
+
try:
|
|
422
|
+
self.title
|
|
423
|
+
except AttributeError:
|
|
424
|
+
pass
|
|
425
|
+
else:
|
|
426
|
+
e.add_field("Test Title", self.title)
|
|
427
|
+
|
|
428
|
+
e.add_field("Test ID", id.to_string(self.id))
|
|
429
|
+
raise e
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
@error.exit_on_script_error
|
|
433
|
+
def generate(path="pdf"):
|
|
434
|
+
"""Builds PDF output files for all defined tests.
|
|
435
|
+
|
|
436
|
+
Should be called once near the end of the script after tests have been
|
|
437
|
+
created with :py:class:`atform.Test`.
|
|
438
|
+
|
|
439
|
+
.. warning::
|
|
440
|
+
|
|
441
|
+
The generated tests will *overwrite* files in the output directory.
|
|
442
|
+
Any content in the output directory that needs to be preserved
|
|
443
|
+
must be copied elsewhere before generating output documents.
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
path (str, optional): Output directory where PDFs will be saved.
|
|
447
|
+
"""
|
|
448
|
+
if not isinstance(path, str):
|
|
449
|
+
raise error.UserScriptError(
|
|
450
|
+
"Output path must be a string.",
|
|
451
|
+
)
|
|
452
|
+
[t._pregenerate() for t in tests]
|
|
453
|
+
|
|
454
|
+
try:
|
|
455
|
+
git = vcs.Git()
|
|
456
|
+
except vcs.NoVersionControlError:
|
|
457
|
+
draft = False
|
|
458
|
+
version = None
|
|
459
|
+
else:
|
|
460
|
+
draft = not git.clean
|
|
461
|
+
version = git.version
|
|
462
|
+
|
|
463
|
+
[pdf.TestDocument(t, path, draft, version) for t in tests]
|
atform/error.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# This module implements handling errors with the user script. Nothing in this
|
|
2
|
+
# module is exported to the public API because script errors are not intended
|
|
3
|
+
# to be caught with try/except blocks, but rather simply exit with a
|
|
4
|
+
# message describing the problem. Furthermore, this implementation is
|
|
5
|
+
# intended to generate a simplified message, as opposed to the normal
|
|
6
|
+
# stack trace which is unnecessary and possibly confusing for users new
|
|
7
|
+
# to programming or Python.
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
import collections
|
|
11
|
+
import functools
|
|
12
|
+
import inspect
|
|
13
|
+
import textwrap
|
|
14
|
+
import traceback
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Setting to true will revert to normal Python exception handling,
|
|
18
|
+
# generating a complete traceback.
|
|
19
|
+
DEBUG = False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def exit_on_script_error(api):
|
|
23
|
+
"""Decorator to exit upon catching a ScriptError.
|
|
24
|
+
|
|
25
|
+
This must only be applied to public API objects to ensure all context
|
|
26
|
+
is added to the original exception. When stacked with other decorators
|
|
27
|
+
it must be outermost, i.e., listed first.
|
|
28
|
+
"""
|
|
29
|
+
@functools.wraps(api)
|
|
30
|
+
def wrapper(*args, **kwargs):
|
|
31
|
+
|
|
32
|
+
# Capture the location where this API was called from the
|
|
33
|
+
# user script. The normal exception traceback is not used
|
|
34
|
+
# because it is difficult to determine which frame represents
|
|
35
|
+
# the departure from the user script, whereas it is always
|
|
36
|
+
# in the same location in a traceback relative to this wrapper
|
|
37
|
+
# function.
|
|
38
|
+
call_frame = traceback.extract_stack(limit=2)[0]
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
result = api(*args, **kwargs)
|
|
42
|
+
|
|
43
|
+
except UserScriptError as e:
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
frame = e.call_frame
|
|
47
|
+
|
|
48
|
+
# Use the frame from this call if the exception does not
|
|
49
|
+
# provide one.
|
|
50
|
+
except AttributeError:
|
|
51
|
+
e.call_frame = call_frame
|
|
52
|
+
e.api = api
|
|
53
|
+
|
|
54
|
+
if DEBUG:
|
|
55
|
+
raise
|
|
56
|
+
|
|
57
|
+
# Translate the original exception to SystemExit, which doesn't
|
|
58
|
+
# print the stack trace.
|
|
59
|
+
else:
|
|
60
|
+
raise SystemExit(e) from e
|
|
61
|
+
|
|
62
|
+
# For API classes, store the call frame where the object was created
|
|
63
|
+
# in the instance. This attribute is needed by the
|
|
64
|
+
# @external_call decorator.
|
|
65
|
+
if inspect.isclass(api):
|
|
66
|
+
result._call_frame = call_frame
|
|
67
|
+
|
|
68
|
+
return result
|
|
69
|
+
|
|
70
|
+
return wrapper
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def external_call(method):
|
|
74
|
+
"""Decorator for methods called after an object is created.
|
|
75
|
+
|
|
76
|
+
Methods that can raise UserScriptError to indicate a problem with
|
|
77
|
+
data provided when the object was initially created, but are called
|
|
78
|
+
after the instance was created, i.e., indirectly by some other API,
|
|
79
|
+
need to have the traceback point back to where the instance was created,
|
|
80
|
+
not the API that called the method. This decorator adds the call frame
|
|
81
|
+
stored in the object, which was cached by @exit_on_script_error
|
|
82
|
+
when the object was originally created, to a raised UserScriptError,
|
|
83
|
+
overriding the call frame from the top-level API that called this method.
|
|
84
|
+
"""
|
|
85
|
+
@functools.wraps(method)
|
|
86
|
+
def wrapper(self, *args, **kwargs):
|
|
87
|
+
try:
|
|
88
|
+
return method(self, *args, **kwargs)
|
|
89
|
+
except UserScriptError as e:
|
|
90
|
+
e.call_frame = self._call_frame
|
|
91
|
+
raise
|
|
92
|
+
|
|
93
|
+
return wrapper
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class UserScriptError(Exception):
|
|
97
|
+
"""Raised when a problem was encountered in a user script.
|
|
98
|
+
|
|
99
|
+
Implements storing key:value fields to help describe the context of
|
|
100
|
+
the error, which can be added as the exception propagates up.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
# String separating keys and values in the formatted presentation string.
|
|
104
|
+
FIELD_SEP = ": "
|
|
105
|
+
|
|
106
|
+
# These fields may contain lengthy strings, and are therefore line wrapped
|
|
107
|
+
# in the string output.
|
|
108
|
+
MULTILINE_FIELDS = set([
|
|
109
|
+
"Description",
|
|
110
|
+
"Remedy",
|
|
111
|
+
])
|
|
112
|
+
|
|
113
|
+
def __init__(self, desc, remedy=None, *args, **kwargs):
|
|
114
|
+
super().__init__(*args)
|
|
115
|
+
self.fields = collections.OrderedDict()
|
|
116
|
+
if remedy:
|
|
117
|
+
self.fields["Remedy"] = remedy
|
|
118
|
+
self.fields["Description"] = desc
|
|
119
|
+
|
|
120
|
+
def add_field(self, key, value):
|
|
121
|
+
"""Appends an item describing the context of the error."""
|
|
122
|
+
self.fields[key] = value
|
|
123
|
+
|
|
124
|
+
def __str__(self):
|
|
125
|
+
"""Formats all fields into a simple key: value table."""
|
|
126
|
+
|
|
127
|
+
has_api = hasattr(self, "api")
|
|
128
|
+
|
|
129
|
+
if has_api:
|
|
130
|
+
self.fields["In Call To"] = f"atform.{self.api.__name__}"
|
|
131
|
+
|
|
132
|
+
self.fields["Line Number"] = self.call_frame.lineno
|
|
133
|
+
self.fields["File"] = self.call_frame.filename
|
|
134
|
+
|
|
135
|
+
# Compute the indentation required to right-align all field names.
|
|
136
|
+
indent = max([len(s) for s in self.fields.keys()])
|
|
137
|
+
|
|
138
|
+
lines = ["The following error was encountered:"]
|
|
139
|
+
lines.append("")
|
|
140
|
+
|
|
141
|
+
# Fields are added from most specific to most general as the
|
|
142
|
+
# exception propagates up from its origin, so they are listed here
|
|
143
|
+
# in reverse order to render top to bottom in increasing specificity.
|
|
144
|
+
for field in reversed(self.fields):
|
|
145
|
+
value = str(self.fields[field])
|
|
146
|
+
|
|
147
|
+
# Wrap multiline fields.
|
|
148
|
+
if field in self.MULTILINE_FIELDS:
|
|
149
|
+
collapsed = " ".join(value.split()) # Collapse whitespace.
|
|
150
|
+
line = textwrap.fill(
|
|
151
|
+
self.FIELD_SEP.join((field, collapsed)),
|
|
152
|
+
|
|
153
|
+
# Indent first line so the field name is right-aligned
|
|
154
|
+
# with other field names.
|
|
155
|
+
initial_indent=" " * (indent - len(field)),
|
|
156
|
+
|
|
157
|
+
# Remaining lines are indented to align with other
|
|
158
|
+
# field values.
|
|
159
|
+
subsequent_indent=" " * (indent + len(self.FIELD_SEP)),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Single line field.
|
|
163
|
+
else:
|
|
164
|
+
line = self.FIELD_SEP.join((field.rjust(indent), value))
|
|
165
|
+
|
|
166
|
+
lines.append(line)
|
|
167
|
+
|
|
168
|
+
# Add API docstring.
|
|
169
|
+
if has_api:
|
|
170
|
+
lines.append("")
|
|
171
|
+
lines.append(
|
|
172
|
+
f"atform.{self.api.__name__} help: {self.api.__doc__}"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
return "\n".join(lines)
|