atform 0.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 +492 -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 +1153 -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.2.dist-info/METADATA +62 -0
- atform-0.2.dist-info/RECORD +19 -0
- atform-0.2.dist-info/WHEEL +4 -0
- atform-0.2.dist-info/licenses/LICENSE.txt +28 -0
atform/field.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# This module maintains user-defined form field configuration.
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
from . import error
|
|
5
|
+
from . import misc
|
|
6
|
+
import collections
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# Object used to store the definition of each field.
|
|
10
|
+
Field = collections.namedtuple(
|
|
11
|
+
"Field",
|
|
12
|
+
["title", "length"],
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# All defined fields, keyed by name, and ordered as added by add_field().
|
|
17
|
+
fields = collections.OrderedDict()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Names identifying which fields will be applied to the next test.
|
|
21
|
+
active_names = set()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def validate_name_list(title, lst):
|
|
25
|
+
"""Verifies a list to confirm it contains only valid field names."""
|
|
26
|
+
if not isinstance(lst, list):
|
|
27
|
+
raise error.UserScriptError(
|
|
28
|
+
f"Invalid {title} data type: {type(lst).__name__}",
|
|
29
|
+
f"{title} must be a list of field names.",
|
|
30
|
+
)
|
|
31
|
+
names = set()
|
|
32
|
+
for raw in lst:
|
|
33
|
+
name = misc.nonempty_string("field name", raw)
|
|
34
|
+
try:
|
|
35
|
+
fields[name]
|
|
36
|
+
except KeyError:
|
|
37
|
+
raise error.UserScriptError(
|
|
38
|
+
f"Undefined name in {title} list: {name}",
|
|
39
|
+
"Use a name defined with atform.add_field().",
|
|
40
|
+
)
|
|
41
|
+
names.add(name)
|
|
42
|
+
return names
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_active_names(include, exclude, active):
|
|
46
|
+
"""Computes the resulting active names after applying filters."""
|
|
47
|
+
include = validate_name_list("include fields", include)
|
|
48
|
+
exclude = validate_name_list("exclude fields", exclude)
|
|
49
|
+
if active is not None:
|
|
50
|
+
return validate_name_list("active fields", active)
|
|
51
|
+
return active_names.union(include).difference(exclude)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_active_fields(include, exclude, active):
|
|
55
|
+
"""
|
|
56
|
+
Generates the list of field tuples to be applied to the next test
|
|
57
|
+
after applying filters.
|
|
58
|
+
"""
|
|
59
|
+
names = list(get_active_names(include, exclude, active))
|
|
60
|
+
|
|
61
|
+
# Sort according to order defined by add_field().
|
|
62
|
+
names.sort(key=lambda n: list(fields.keys()).index(n))
|
|
63
|
+
|
|
64
|
+
return [fields[name] for name in names]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
################################################################################
|
|
68
|
+
# Public API
|
|
69
|
+
#
|
|
70
|
+
# Items in this area are documented and exported for use by end users.
|
|
71
|
+
################################################################################
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@error.exit_on_script_error
|
|
75
|
+
@misc.setup_only
|
|
76
|
+
def add_field(title, length, name, active=True):
|
|
77
|
+
"""Adds a user entry field to capture test execution information.
|
|
78
|
+
|
|
79
|
+
Form fields are suitable for entering a single line of text at the beginning
|
|
80
|
+
of each test; fields will be listed in the order they were added.
|
|
81
|
+
This function can only be used in the setup area of the script,
|
|
82
|
+
before any tests or sections are created.
|
|
83
|
+
|
|
84
|
+
.. seealso:: :ref:`env_fields`
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
title (str): Text to serve as the field's prompt; must not be blank.
|
|
88
|
+
length (int): Maximum number of characters the field should be sized
|
|
89
|
+
to accommodate; must be greater than zero.
|
|
90
|
+
name (str): A tag used to identify this field; must be unique and
|
|
91
|
+
not blank.
|
|
92
|
+
active (bool, optional): Set to ``False`` if this field should not
|
|
93
|
+
be included in tests until explicitly activated at a later time,
|
|
94
|
+
such as with :py:func:`atform.set_active_fields`.
|
|
95
|
+
"""
|
|
96
|
+
field = Field(
|
|
97
|
+
misc.nonempty_string("field title", title),
|
|
98
|
+
misc.validate_field_length(length),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
name = misc.nonempty_string("field name", name)
|
|
102
|
+
try:
|
|
103
|
+
fields[name]
|
|
104
|
+
except KeyError:
|
|
105
|
+
fields[name] = field
|
|
106
|
+
else:
|
|
107
|
+
raise error.UserScriptError(
|
|
108
|
+
f"Duplicate field name: {name}",
|
|
109
|
+
"Select a unique field name."
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if not isinstance(active, bool):
|
|
113
|
+
raise error.UserScriptError(
|
|
114
|
+
f"Invalid active selection: {active}",
|
|
115
|
+
"Set active to False, or omit the argument.",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if active:
|
|
119
|
+
active_names.add(name)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@error.exit_on_script_error
|
|
123
|
+
def set_active_fields(include=[], exclude=[], active=None):
|
|
124
|
+
"""Alters the fields applied to each test created after this function.
|
|
125
|
+
|
|
126
|
+
May be called repeatedly to modify the fields applied to different
|
|
127
|
+
tests. The fields set by this function can also be overridden by
|
|
128
|
+
the ``include_fields``, ``exclude_fields``, and ``active_fields``
|
|
129
|
+
arguments of :py:class:`atform.Test`.
|
|
130
|
+
|
|
131
|
+
.. seealso:: :ref:`env_fields`
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
include (list[str], optional): Names of fields to add to later
|
|
135
|
+
tests.
|
|
136
|
+
exclude (list[str], optional): Names of fields to remove from
|
|
137
|
+
later tests.
|
|
138
|
+
active (list[str], optional): If provided, updates the fields
|
|
139
|
+
in later tests with this list.
|
|
140
|
+
"""
|
|
141
|
+
global active_names
|
|
142
|
+
active_names = get_active_names(include, exclude, active)
|
atform/format.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# This module contains the text formatting API.
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
from . import error
|
|
5
|
+
import xml.etree.ElementTree as ElementTree
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# Mapping between typeface and font parameters of format_text() to
|
|
9
|
+
# a (font, size) tuple used to populate intra-paragraph XML element
|
|
10
|
+
# attributes. The size value is optional to adjust height relative to
|
|
11
|
+
# normal text.
|
|
12
|
+
FONTS = {
|
|
13
|
+
("normal", "normal"): ("Times-Roman",),
|
|
14
|
+
("normal", "bold"): ("Times-Bold",),
|
|
15
|
+
("normal", "italic"): ("Times-Italic",),
|
|
16
|
+
("monospace", "normal"): ("Courier", 14),
|
|
17
|
+
("monospace", "bold"): ("Courier-Bold", 14),
|
|
18
|
+
("monospace", "italic"): ("Courier-Oblique", 14),
|
|
19
|
+
("sansserif", "normal"): ("Helvetica", 11),
|
|
20
|
+
("sansserif", "bold"): ("Helvetica-Bold", 11),
|
|
21
|
+
("sansserif", "italic"): ("Helvetica-Oblique", 11),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def allowed_format(i):
|
|
26
|
+
"""
|
|
27
|
+
Formats a list of allowable format selectors into a string for use in
|
|
28
|
+
error messages.
|
|
29
|
+
"""
|
|
30
|
+
uniq = set([f[i] for f in FONTS])
|
|
31
|
+
quoted = [f"'{s}'" for s in uniq]
|
|
32
|
+
quoted.sort()
|
|
33
|
+
return ", ".join(quoted[:-1]) + f", or {quoted[-1]}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
################################################################################
|
|
37
|
+
# Public API
|
|
38
|
+
#
|
|
39
|
+
# Items in this area are documented and exported for use by end users.
|
|
40
|
+
################################################################################
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@error.exit_on_script_error
|
|
44
|
+
def bullet_list(*items):
|
|
45
|
+
"""Creates a list of items.
|
|
46
|
+
|
|
47
|
+
Items will be presented as an unnumbered list, in the same order they
|
|
48
|
+
appear in the function's parameters. This function may not be nested to
|
|
49
|
+
create nested lists.
|
|
50
|
+
|
|
51
|
+
.. seealso:: :ref:`format`
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
*items (str): The bullet list items. This is *not* a Python list of
|
|
55
|
+
items, but rather each item passed as a separate parameter.
|
|
56
|
+
E.g., ``"Item 1", "Item 2"``, not ``["Item 1", "Item 2"]``.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
str: The entire list with embedded formatting that can be incorporated
|
|
60
|
+
into strings passed to the ``objective``, ``equipment``,
|
|
61
|
+
``preconditions``, and ``procedure`` parameters of
|
|
62
|
+
:py:class:`atform.Test`.
|
|
63
|
+
"""
|
|
64
|
+
indent = 12 # Horizontal indentation in points applied to each item.
|
|
65
|
+
|
|
66
|
+
# The character used at the beginning of each item. Chosen to be distinct
|
|
67
|
+
# from the bullet used by ReportLab ListItem().
|
|
68
|
+
symbol = "♦"
|
|
69
|
+
|
|
70
|
+
stripped = []
|
|
71
|
+
for i in items:
|
|
72
|
+
try:
|
|
73
|
+
item = i.strip()
|
|
74
|
+
except AttributeError:
|
|
75
|
+
raise error.UserScriptError(
|
|
76
|
+
f"Invalid bullet list item type: {type(i).__name__}",
|
|
77
|
+
"Bullet list items must be strings.",
|
|
78
|
+
)
|
|
79
|
+
else:
|
|
80
|
+
stripped.append(item)
|
|
81
|
+
|
|
82
|
+
bullet_items = ["<bullet indent='{0}'>{1}</bullet>{2}".format(
|
|
83
|
+
indent,
|
|
84
|
+
symbol,
|
|
85
|
+
i,
|
|
86
|
+
) for i in stripped]
|
|
87
|
+
|
|
88
|
+
# Add empty leading and trailing strings so items get surrounded by double
|
|
89
|
+
# newlines by the final join(), ensuring the list is separated from
|
|
90
|
+
# adjacent paragraphs.
|
|
91
|
+
bullet_items.insert(0, "")
|
|
92
|
+
bullet_items.append("")
|
|
93
|
+
|
|
94
|
+
return "\n\n".join(bullet_items)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@error.exit_on_script_error
|
|
98
|
+
def format_text(text, typeface="normal", font="normal"):
|
|
99
|
+
"""Applies special formatting attributes to text.
|
|
100
|
+
|
|
101
|
+
The returned string can be incorporated into strings passed to the
|
|
102
|
+
``objective``, ``equipment``, ``preconditions``, and ``procedure``
|
|
103
|
+
parameters of :py:class:`atform.Test`.
|
|
104
|
+
|
|
105
|
+
.. seealso:: :ref:`format`
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
text (str): The content to format.
|
|
109
|
+
typeface (str, optional): Typeface name: ``"monospace"`` or
|
|
110
|
+
``"sansserif"``.
|
|
111
|
+
font (str, optional): Font style: ``"bold"`` or ``"italic"``.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
str: The original text with embedded formatting information.
|
|
115
|
+
"""
|
|
116
|
+
if not isinstance(text, str):
|
|
117
|
+
raise error.UserScriptError(
|
|
118
|
+
f"Invalid formatted text type: {text}",
|
|
119
|
+
"Text to be formatted must be a string.",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
typefaces = set([k[0] for k in FONTS.keys()])
|
|
123
|
+
if not typeface in typefaces:
|
|
124
|
+
raise error.UserScriptError(
|
|
125
|
+
f"Invalid text format typeface: {typeface}",
|
|
126
|
+
f"Select {allowed_format(0)} as a typeface.",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
fonts = set([k[1] for k in FONTS.keys()])
|
|
130
|
+
if not font in fonts:
|
|
131
|
+
raise error.UserScriptError(
|
|
132
|
+
f"Invalid text format font: {font}",
|
|
133
|
+
f"Select {allowed_format(1)} as a font.",
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
font_values = FONTS[(typeface, font)]
|
|
137
|
+
attrib = {"face":font_values[0]}
|
|
138
|
+
try:
|
|
139
|
+
attrib["size"] = str(font_values[1])
|
|
140
|
+
except IndexError:
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
# Enclose the string in a intra-paragraph XML markup element.
|
|
144
|
+
e = ElementTree.Element("font", attrib=attrib)
|
|
145
|
+
e.text = text
|
|
146
|
+
return ElementTree.tostring(e, encoding="unicode")
|
atform/id.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# This module manages the numeric identifiers assigned to each test.
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
from . import error
|
|
5
|
+
from . import misc
|
|
6
|
+
import pathlib
|
|
7
|
+
import tempfile
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# Fields assigned to the most recent test.
|
|
11
|
+
current_id = [0]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Section titles, keyed by ID tuple.
|
|
15
|
+
section_titles = {}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_id():
|
|
19
|
+
"""Returns the identifier to be used for the next test."""
|
|
20
|
+
global current_id
|
|
21
|
+
current_id[-1] = current_id[-1] + 1 # Increment last ID level for each test.
|
|
22
|
+
|
|
23
|
+
# Initialize section levels that have been reset(0) to one.
|
|
24
|
+
for i in range(0, len(current_id)):
|
|
25
|
+
if current_id[i] == 0:
|
|
26
|
+
current_id[i] = 1
|
|
27
|
+
|
|
28
|
+
return tuple(current_id)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def to_string(id):
|
|
32
|
+
"""Generates a presentation string for a given ID tuple."""
|
|
33
|
+
return ".".join([str(x) for x in id])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def validate_section_title(title):
|
|
37
|
+
"""Confirms a section title is valid.
|
|
38
|
+
|
|
39
|
+
Validation is implemented by attempting to create a folder named with
|
|
40
|
+
the title in a temporary directory.
|
|
41
|
+
"""
|
|
42
|
+
if not isinstance(title, str):
|
|
43
|
+
raise error.UserScriptError(
|
|
44
|
+
f"Invalid section title data type: {type(title).__name__}",
|
|
45
|
+
"Section title must be a string.",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
with tempfile.TemporaryDirectory() as tdir:
|
|
49
|
+
path = pathlib.Path(tdir, title)
|
|
50
|
+
try:
|
|
51
|
+
path.mkdir()
|
|
52
|
+
except OSError:
|
|
53
|
+
raise error.UserScriptError(
|
|
54
|
+
f"Invalid section title: '{title}'",
|
|
55
|
+
"""
|
|
56
|
+
Use a section title that is also a valid file system
|
|
57
|
+
folder name.
|
|
58
|
+
""",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
################################################################################
|
|
63
|
+
# Public API
|
|
64
|
+
#
|
|
65
|
+
# Items in this area are documented and exported for use by end users.
|
|
66
|
+
################################################################################
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@error.exit_on_script_error
|
|
70
|
+
def section(level, id=None, title=None):
|
|
71
|
+
"""Creates a new section or subsection.
|
|
72
|
+
|
|
73
|
+
The target section level is incremented, and the new section can be given
|
|
74
|
+
an optional title. All subsections after the target level and individual
|
|
75
|
+
test numbers will be reset back to one.
|
|
76
|
+
|
|
77
|
+
.. seealso:: :ref:`section`
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
level (int): Target identifier level in which to start a new section;
|
|
81
|
+
must be greater than zero and less than the number of levels
|
|
82
|
+
configured with :py:func:`atform.set_id_depth` because the
|
|
83
|
+
highest level represents individual tests, not sections.
|
|
84
|
+
id (int, optional): Target section value; target section is incremented
|
|
85
|
+
by one if omitted. If specified, it must result in a jump
|
|
86
|
+
forward relative to the current section, i.e., jumping backwards,
|
|
87
|
+
or even to the current section, is not permitted.
|
|
88
|
+
title (str, optional): Section title; this information is only used
|
|
89
|
+
to name the folder where output PDFs for tests in
|
|
90
|
+
this section are generated, and consequently may only contain
|
|
91
|
+
characters allowed in file system folder names. If not provided,
|
|
92
|
+
the section folder name will just be the section number.
|
|
93
|
+
"""
|
|
94
|
+
global current_id
|
|
95
|
+
global section_titles
|
|
96
|
+
|
|
97
|
+
if len(current_id) == 1:
|
|
98
|
+
raise error.UserScriptError(
|
|
99
|
+
"No section levels available",
|
|
100
|
+
"""This function cannot be used unless the test ID depth is
|
|
101
|
+
first increased with atform.set_id_depth to allow tests to be
|
|
102
|
+
divided into sections."""
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
if not isinstance(level, int):
|
|
106
|
+
raise error.UserScriptError(
|
|
107
|
+
f"Invalid section level data type: {type(level).__name__}",
|
|
108
|
+
"Section level must be an integer.",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
section_levels = range(1, len(current_id))
|
|
112
|
+
if not level in section_levels:
|
|
113
|
+
raise error.UserScriptError(
|
|
114
|
+
f"Invalid section level: {level}",
|
|
115
|
+
f"Use a section level between 1 and {section_levels[-1]}, "
|
|
116
|
+
"inclusive."
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Convert the one-based level to a zero-based index suitable for
|
|
120
|
+
# indexing current_id[].
|
|
121
|
+
id_index = level - 1
|
|
122
|
+
|
|
123
|
+
# Increment the target ID level.
|
|
124
|
+
if id is None:
|
|
125
|
+
current_id[id_index] = current_id[id_index] + 1
|
|
126
|
+
|
|
127
|
+
# Jump to a specific number.
|
|
128
|
+
else:
|
|
129
|
+
if not isinstance(id, int):
|
|
130
|
+
raise error.UserScriptError(
|
|
131
|
+
f"Invalid id data type: {type(id).__name__}",
|
|
132
|
+
"id must be an integer.",
|
|
133
|
+
)
|
|
134
|
+
elif id <= current_id[id_index]:
|
|
135
|
+
raise error.UserScriptError(
|
|
136
|
+
f"Invalid id value.",
|
|
137
|
+
f"Level {level} id must be greater than {current_id[id_index]}."
|
|
138
|
+
)
|
|
139
|
+
current_id[id_index] = id
|
|
140
|
+
|
|
141
|
+
# Reset higher ID levels.
|
|
142
|
+
for i in range(id_index + 1, len(current_id)):
|
|
143
|
+
current_id[i] = 0
|
|
144
|
+
|
|
145
|
+
if title is not None:
|
|
146
|
+
validate_section_title(title)
|
|
147
|
+
section = tuple(current_id[:id_index + 1])
|
|
148
|
+
stripped = title.strip()
|
|
149
|
+
if stripped:
|
|
150
|
+
section_titles[section] = stripped
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@error.exit_on_script_error
|
|
154
|
+
@misc.setup_only
|
|
155
|
+
def set_id_depth(levels):
|
|
156
|
+
"""Configures the number of fields in test numeric identifiers.
|
|
157
|
+
|
|
158
|
+
For example, setting the depth to three will generate identifiers with
|
|
159
|
+
three numeric fields, like 2.1.1 or 4.2.3. This should be called once
|
|
160
|
+
before any tests or sections are created.
|
|
161
|
+
|
|
162
|
+
.. seealso:: :ref:`section`
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
levels (int): Number of identifier levels.
|
|
166
|
+
"""
|
|
167
|
+
global current_id
|
|
168
|
+
if not isinstance(levels, int):
|
|
169
|
+
raise error.UserScriptError(
|
|
170
|
+
f"Invalid ID levels data type: {type(levels).__name__}",
|
|
171
|
+
"The number of ID levels must be an integer.",
|
|
172
|
+
)
|
|
173
|
+
if levels < 1:
|
|
174
|
+
raise error.UserScriptError(
|
|
175
|
+
f"Invalid identifier depth value: {levels}",
|
|
176
|
+
"Select an identifier depth greater than zero."
|
|
177
|
+
)
|
|
178
|
+
current_id = [0] * levels
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@error.exit_on_script_error
|
|
182
|
+
def skip_test(id=None):
|
|
183
|
+
"""Omits one or more tests.
|
|
184
|
+
|
|
185
|
+
This function can only skip tests within the current section, i.e.,
|
|
186
|
+
it will only affect the last identifier field. Typical usage is to
|
|
187
|
+
reserve a range of IDs or maintain numbering if a test is removed.
|
|
188
|
+
|
|
189
|
+
.. seealso:: :ref:`skip`
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
id (int, optional): ID of the next test. If omitted, one test will
|
|
193
|
+
be skipped.
|
|
194
|
+
"""
|
|
195
|
+
global current_id
|
|
196
|
+
|
|
197
|
+
# Advance the test number normally without creating a test. This call
|
|
198
|
+
# also supports the skip-forward validation below by initializing
|
|
199
|
+
# any zero IDs to one.
|
|
200
|
+
get_id()
|
|
201
|
+
|
|
202
|
+
if id is not None:
|
|
203
|
+
if not isinstance(id, int):
|
|
204
|
+
raise error.UserScriptError(
|
|
205
|
+
f"Invalid id data type: {type(id).__name__}",
|
|
206
|
+
"id must be an integer.",
|
|
207
|
+
)
|
|
208
|
+
if id <= current_id[-1]:
|
|
209
|
+
raise error.UserScriptError(
|
|
210
|
+
f"Invalid id value: {id}",
|
|
211
|
+
f"Select an id greater than {current_id[-1]}."
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
# The current ID is set to one previous because the get_id() call
|
|
215
|
+
# above already increments the ID. The next test will then be assigned
|
|
216
|
+
# the given id value because get_id() increments before returning
|
|
217
|
+
# the assigned ID.
|
|
218
|
+
current_id[-1] = id - 1
|
atform/image.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# This module handles processing user-provided images.
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
from . import error
|
|
5
|
+
from . import misc
|
|
6
|
+
import collections
|
|
7
|
+
import io
|
|
8
|
+
import PIL
|
|
9
|
+
from reportlab.lib.units import inch
|
|
10
|
+
from reportlab.platypus import Image
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# Data type for storing two-dimensional sizes.
|
|
14
|
+
ImageSize = collections.namedtuple('ImageSize', ['width', 'height'])
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Largest allowable logo image size, in inches.
|
|
18
|
+
MAX_LOGO_SIZE = ImageSize(2.0, 1.5)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ReportLab Image object containing the user-specified logo.
|
|
22
|
+
logo = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
################################################################################
|
|
26
|
+
# Public API
|
|
27
|
+
#
|
|
28
|
+
# Items in this area are documented and exported for use by end users.
|
|
29
|
+
################################################################################
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@error.exit_on_script_error
|
|
33
|
+
@misc.setup_only
|
|
34
|
+
def add_logo(path):
|
|
35
|
+
"""Selects an image file to be used as the logo.
|
|
36
|
+
|
|
37
|
+
The logo will appear on the first page of every test in the
|
|
38
|
+
upper-left corner.
|
|
39
|
+
|
|
40
|
+
The image must be a JPEG file with embedded metadata specifying DPI,
|
|
41
|
+
and may not exceed |max_logo_width| inches wide by |max_logo_height|
|
|
42
|
+
inches high. |project_name| will not scale or crop the image; the onus is
|
|
43
|
+
on the user to construct a logo suitable for presentation within
|
|
44
|
+
the allowable area.
|
|
45
|
+
|
|
46
|
+
.. seealso:: :ref:`setup`
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
path (str): Path to the image file.
|
|
50
|
+
"""
|
|
51
|
+
global logo
|
|
52
|
+
|
|
53
|
+
if logo:
|
|
54
|
+
raise error.UserScriptError(
|
|
55
|
+
"Duplicate logo definition.",
|
|
56
|
+
"""
|
|
57
|
+
atform.add_logo() can only be called once to define a single
|
|
58
|
+
logo image.
|
|
59
|
+
""",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# BytesIO are allowed to support unit testing.
|
|
63
|
+
if not isinstance(path, str) and not isinstance(path, io.BytesIO):
|
|
64
|
+
raise error.UserScriptError(
|
|
65
|
+
f"Invalid path data type: {type(path).__name__}",
|
|
66
|
+
"Path to the logo image file must be a string.",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
image = PIL.Image.open(path, formats=["JPEG"])
|
|
71
|
+
except FileNotFoundError:
|
|
72
|
+
raise error.UserScriptError(
|
|
73
|
+
f"Logo image file not found: {path}",
|
|
74
|
+
)
|
|
75
|
+
except PIL.UnidentifiedImageError:
|
|
76
|
+
raise error.UserScriptError(
|
|
77
|
+
f"Unsupported logo image format: {path}",
|
|
78
|
+
"Logo image file must be a JPEG.",
|
|
79
|
+
)
|
|
80
|
+
try:
|
|
81
|
+
dpi_raw = image.info["dpi"]
|
|
82
|
+
except KeyError:
|
|
83
|
+
raise error.UserScriptError(
|
|
84
|
+
"No DPI information found in logo image file.",
|
|
85
|
+
"Ensure the logo image file has embedded DPI metadata."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Ensure DPI values are floats.
|
|
89
|
+
dpi = ImageSize(*[float(i) for i in dpi_raw])
|
|
90
|
+
|
|
91
|
+
size = ImageSize(
|
|
92
|
+
image.width / dpi.width,
|
|
93
|
+
image.height / dpi.height
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
if ((size.width > MAX_LOGO_SIZE.width)
|
|
97
|
+
or (size.height > MAX_LOGO_SIZE.height)):
|
|
98
|
+
raise error.UserScriptError(
|
|
99
|
+
f"""
|
|
100
|
+
Logo image is too large:
|
|
101
|
+
{size.width:.3} x {size.height:.3} (inch)""",
|
|
102
|
+
f"""
|
|
103
|
+
Reduce the logo image size to within {MAX_LOGO_SIZE.width} inch
|
|
104
|
+
wide and {MAX_LOGO_SIZE.height} inch high.
|
|
105
|
+
""",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Dump the JPEG data to an in-memory buffer and convert to a Reportlab
|
|
109
|
+
# Image object.
|
|
110
|
+
buf = io.BytesIO()
|
|
111
|
+
image.save(
|
|
112
|
+
buf,
|
|
113
|
+
format="JPEG",
|
|
114
|
+
quality="keep",
|
|
115
|
+
dpi=dpi,
|
|
116
|
+
)
|
|
117
|
+
logo = Image(
|
|
118
|
+
buf,
|
|
119
|
+
width=size.width*inch,
|
|
120
|
+
height=size.height*inch,
|
|
121
|
+
)
|
atform/label.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# This module implements storage of IDs, such as a test number, referenced by
|
|
2
|
+
# a user-provided label.
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
from . import error
|
|
6
|
+
import re
|
|
7
|
+
import string
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# Target ids keyed by label.
|
|
11
|
+
labels = {}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Regular expression pattern to match a valid label, which is based on
|
|
15
|
+
# allowable identifiers for template strings.
|
|
16
|
+
valid_label_pattern = re.compile(r"\w+$")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def add(label, id):
|
|
20
|
+
"""Assigns an identifier to a label.
|
|
21
|
+
|
|
22
|
+
This function is not exposed in the public API, however, the label
|
|
23
|
+
argument is passed directly from public API arguments, so it is
|
|
24
|
+
validated here. The id is generated internally by atform, e.g., a test
|
|
25
|
+
number, and can be assumed appropriate.
|
|
26
|
+
"""
|
|
27
|
+
global labels
|
|
28
|
+
|
|
29
|
+
if not isinstance(label, str):
|
|
30
|
+
raise error.UserScriptError(
|
|
31
|
+
f"Invalid label data type: {label}",
|
|
32
|
+
"Label must be a string.",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
if not valid_label_pattern.match(label):
|
|
36
|
+
raise error.UserScriptError(
|
|
37
|
+
f"Invalid label: {label}",
|
|
38
|
+
f"Labels may contain only letters, numbers, and underscore."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if label in labels:
|
|
42
|
+
raise error.UserScriptError(
|
|
43
|
+
f"Duplicate label: {label}",
|
|
44
|
+
"Select a label that has not yet been used.",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
labels[label] = id
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def resolve(orig):
|
|
51
|
+
"""Replaces label placeholders with the target IDs.
|
|
52
|
+
|
|
53
|
+
The public API already validates the original string to ensure it is
|
|
54
|
+
in fact a string, so only substitution needs to be checked.
|
|
55
|
+
"""
|
|
56
|
+
tpl = string.Template(orig)
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
return tpl.substitute(labels)
|
|
60
|
+
|
|
61
|
+
except KeyError as e:
|
|
62
|
+
raise error.UserScriptError(
|
|
63
|
+
f"Undefined label: {e}",
|
|
64
|
+
"Select a label that has been defined.",
|
|
65
|
+
)
|
|
66
|
+
except ValueError as e:
|
|
67
|
+
raise error.UserScriptError(
|
|
68
|
+
f"Invalid label replacement syntax.",
|
|
69
|
+
"Labels are formatted as $<name>, where <name> begins with a "
|
|
70
|
+
"letter or underscore, followed by zero or more letters, "
|
|
71
|
+
"numbers, or underscore.",
|
|
72
|
+
)
|