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/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
+ )