psdi-data-conversion 0.0.23__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.
- psdi_data_conversion/__init__.py +11 -0
- psdi_data_conversion/app.py +242 -0
- psdi_data_conversion/bin/linux/atomsk +0 -0
- psdi_data_conversion/bin/linux/c2x +0 -0
- psdi_data_conversion/bin/mac/atomsk +0 -0
- psdi_data_conversion/bin/mac/c2x +0 -0
- psdi_data_conversion/constants.py +185 -0
- psdi_data_conversion/converter.py +459 -0
- psdi_data_conversion/converters/__init__.py +6 -0
- psdi_data_conversion/converters/atomsk.py +32 -0
- psdi_data_conversion/converters/base.py +702 -0
- psdi_data_conversion/converters/c2x.py +32 -0
- psdi_data_conversion/converters/openbabel.py +239 -0
- psdi_data_conversion/database.py +1064 -0
- psdi_data_conversion/dist.py +87 -0
- psdi_data_conversion/file_io.py +216 -0
- psdi_data_conversion/log_utility.py +241 -0
- psdi_data_conversion/main.py +776 -0
- psdi_data_conversion/scripts/atomsk.sh +32 -0
- psdi_data_conversion/scripts/c2x.sh +26 -0
- psdi_data_conversion/security.py +38 -0
- psdi_data_conversion/static/content/accessibility.htm +254 -0
- psdi_data_conversion/static/content/convert.htm +121 -0
- psdi_data_conversion/static/content/convertato.htm +65 -0
- psdi_data_conversion/static/content/convertc2x.htm +65 -0
- psdi_data_conversion/static/content/documentation.htm +94 -0
- psdi_data_conversion/static/content/feedback.htm +53 -0
- psdi_data_conversion/static/content/header-links.html +8 -0
- psdi_data_conversion/static/content/index-versions/header-links.html +8 -0
- psdi_data_conversion/static/content/index-versions/psdi-common-footer.html +99 -0
- psdi_data_conversion/static/content/index-versions/psdi-common-header.html +28 -0
- psdi_data_conversion/static/content/psdi-common-footer.html +99 -0
- psdi_data_conversion/static/content/psdi-common-header.html +28 -0
- psdi_data_conversion/static/content/report.htm +103 -0
- psdi_data_conversion/static/data/data.json +143940 -0
- psdi_data_conversion/static/img/colormode-toggle-dm.svg +3 -0
- psdi_data_conversion/static/img/colormode-toggle-lm.svg +3 -0
- psdi_data_conversion/static/img/psdi-icon-dark.svg +136 -0
- psdi_data_conversion/static/img/psdi-icon-light.svg +208 -0
- psdi_data_conversion/static/img/psdi-logo-darktext.png +0 -0
- psdi_data_conversion/static/img/psdi-logo-lighttext.png +0 -0
- psdi_data_conversion/static/img/social-logo-bluesky-black.svg +4 -0
- psdi_data_conversion/static/img/social-logo-bluesky-white.svg +4 -0
- psdi_data_conversion/static/img/social-logo-instagram-black.svg +1 -0
- psdi_data_conversion/static/img/social-logo-instagram-white.svg +1 -0
- psdi_data_conversion/static/img/social-logo-linkedin-black.png +0 -0
- psdi_data_conversion/static/img/social-logo-linkedin-white.png +0 -0
- psdi_data_conversion/static/img/social-logo-mastodon-black.svg +4 -0
- psdi_data_conversion/static/img/social-logo-mastodon-white.svg +4 -0
- psdi_data_conversion/static/img/social-logo-x-black.svg +3 -0
- psdi_data_conversion/static/img/social-logo-x-white.svg +3 -0
- psdi_data_conversion/static/img/social-logo-youtube-black.png +0 -0
- psdi_data_conversion/static/img/social-logo-youtube-white.png +0 -0
- psdi_data_conversion/static/img/ukri-epsr-logo-darktext.png +0 -0
- psdi_data_conversion/static/img/ukri-epsr-logo-lighttext.png +0 -0
- psdi_data_conversion/static/img/ukri-logo-darktext.png +0 -0
- psdi_data_conversion/static/img/ukri-logo-lighttext.png +0 -0
- psdi_data_conversion/static/javascript/accessibility.js +196 -0
- psdi_data_conversion/static/javascript/common.js +42 -0
- psdi_data_conversion/static/javascript/convert.js +296 -0
- psdi_data_conversion/static/javascript/convert_common.js +252 -0
- psdi_data_conversion/static/javascript/convertato.js +107 -0
- psdi_data_conversion/static/javascript/convertc2x.js +107 -0
- psdi_data_conversion/static/javascript/data.js +176 -0
- psdi_data_conversion/static/javascript/format.js +611 -0
- psdi_data_conversion/static/javascript/load_accessibility.js +89 -0
- psdi_data_conversion/static/javascript/psdi-common.js +177 -0
- psdi_data_conversion/static/javascript/report.js +381 -0
- psdi_data_conversion/static/styles/format.css +147 -0
- psdi_data_conversion/static/styles/psdi-common.css +705 -0
- psdi_data_conversion/templates/index.htm +114 -0
- psdi_data_conversion/testing/__init__.py +5 -0
- psdi_data_conversion/testing/constants.py +12 -0
- psdi_data_conversion/testing/conversion_callbacks.py +394 -0
- psdi_data_conversion/testing/conversion_test_specs.py +208 -0
- psdi_data_conversion/testing/utils.py +522 -0
- psdi_data_conversion-0.0.23.dist-info/METADATA +663 -0
- psdi_data_conversion-0.0.23.dist-info/RECORD +81 -0
- psdi_data_conversion-0.0.23.dist-info/WHEEL +4 -0
- psdi_data_conversion-0.0.23.dist-info/entry_points.txt +2 -0
- psdi_data_conversion-0.0.23.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,114 @@
|
|
1
|
+
<!--
|
2
|
+
index.htm
|
3
|
+
Version 1.0, 27th November 2024
|
4
|
+
-->
|
5
|
+
|
6
|
+
<!DOCTYPE html>
|
7
|
+
<html>
|
8
|
+
|
9
|
+
<head>
|
10
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
11
|
+
<title>PSDI Data Conversion Service</title>
|
12
|
+
<link rel="icon" type="image/x-icon" href="./static/img/psdi-icon-dark.svg">
|
13
|
+
<link href="https://fonts.googleapis.com/css?family=Lato:400" rel="stylesheet" type="text/css">
|
14
|
+
<link href="https://fonts.googleapis.com/css?family=Open+Sans+Condensed:700" rel="stylesheet" type="text/css">
|
15
|
+
<link href="https://fonts.googleapis.com/css?family=Open+Sans:400" rel="stylesheet" type="text/css">
|
16
|
+
<link href="https://fonts.googleapis.com/css?family=Lexend:400" rel="stylesheet" type="text/css">
|
17
|
+
<link rel="stylesheet" href="{{url_for('static', filename='styles/format.css')}}">
|
18
|
+
<script src="static/javascript/load_accessibility.js"></script>
|
19
|
+
</head>
|
20
|
+
|
21
|
+
<body marginwidth="0">
|
22
|
+
|
23
|
+
<!-- Cover to hide element loading and make page transitions seem smoother -->
|
24
|
+
<div id="cover"></div>
|
25
|
+
|
26
|
+
<header class="header" id="psdi-header"></header>
|
27
|
+
<script type="module">
|
28
|
+
import { setHeaderLinksSource, setHeaderSource, setFooterSource } from "./static/javascript/psdi-common.js";
|
29
|
+
// Due to the index being a template which will exist in a different directory once rendered, we need to set
|
30
|
+
// a non-default location of the header links file
|
31
|
+
setHeaderSource("{{url_for('static', filename='content/index-versions/psdi-common-header.html')}}");
|
32
|
+
setFooterSource("{{url_for('static', filename='content/index-versions/psdi-common-footer.html')}}");
|
33
|
+
setHeaderLinksSource("{{url_for('static', filename='content/index-versions/header-links.html')}}");
|
34
|
+
</script>
|
35
|
+
|
36
|
+
<div class="hero">
|
37
|
+
<div class="max-width-box">
|
38
|
+
<h1 class="hero__title marginless_header">Format and Converter Selection</h1>
|
39
|
+
</div>
|
40
|
+
</div>
|
41
|
+
|
42
|
+
<form name="gui">
|
43
|
+
<div class="max-width-box">
|
44
|
+
<p>Select 'from' and 'to' file formats in the 'Convert from/to' boxes, in either order. Typing where indicated
|
45
|
+
filters the options (case insensitive); for example, typing 'can' or 'NON' reduces the number of options to one:
|
46
|
+
'can: Canonical SMILES.' If you change your mind about a conversion, it is advisable to click on the 'Reset'
|
47
|
+
button. When both formats have been selected, converters able to carry out this conversion appear in the
|
48
|
+
'Conversion options' box along with an indication of conversion quality (many conversions have not yet been
|
49
|
+
tested) and whether or not the converter is supported on this site.</p>
|
50
|
+
<p>
|
51
|
+
<label for="searchFrom" id="fromLabel">Convert from:</label><br>
|
52
|
+
<input type="text" placeholder="-- type here to filter options --" size="12" id="searchFrom"
|
53
|
+
class="large-width"><br>
|
54
|
+
<select size="4" id="fromList" class="large-width"></select>
|
55
|
+
</p>
|
56
|
+
<div class="smallGap"></div>
|
57
|
+
<p>
|
58
|
+
<label for="searchTo" id="toLabel">Convert to:</label><br>
|
59
|
+
<input type="text" placeholder="-- type here to filter options --" size="12" id="searchTo"
|
60
|
+
class="large-width"><br>
|
61
|
+
<select size="4" id="toList" class="large-width"></select>
|
62
|
+
</p>
|
63
|
+
<input type="button" class="button" value=" Reset " name="resetButton" id="resetButton">
|
64
|
+
<br><br>
|
65
|
+
<p>Selecting a converter displays information
|
66
|
+
about it, along with a link to a relevant website. Selecting a supported converter affords the opportunity to
|
67
|
+
carry out a conversion on this site. Clicking on the 'Yes' button takes us to the 'Conversion' page. If a
|
68
|
+
file format has not been found, or if there are no converters capable of carrying out a required conversion
|
69
|
+
please click on 'Report Missing Format/Conversion' in the navigation bar.</p>
|
70
|
+
<p>
|
71
|
+
<label for="success">Select from available conversion
|
72
|
+
options:</label><br>
|
73
|
+
<select size="4" id="success" class="large-width"></select>
|
74
|
+
</p>
|
75
|
+
<span class="normalText">Show how the conversion quality was determined for the selected converter.</span>
|
76
|
+
<input type="button" class="button" value=" Show " name="showButton" id="showButton">
|
77
|
+
<br>
|
78
|
+
<h6>Converter details:</h6>
|
79
|
+
<p id="converter" , class="init-hidden">
|
80
|
+
<span id="name"></span>
|
81
|
+
<br><span id="description"></span>
|
82
|
+
<br><span id="url"></span>
|
83
|
+
<br><span id="info"></span>
|
84
|
+
<a id="visit" target="_blank">this website.</a>
|
85
|
+
<div id="formatWarning" class="init-hidden"></div>
|
86
|
+
<div id="offer" class="init-hidden">
|
87
|
+
<span id="question"></span>
|
88
|
+
<input type="button" class="button" value=" Yes " name="yesButton" id="yesButton">
|
89
|
+
</div>
|
90
|
+
</p>
|
91
|
+
<div class="medGap"></div>
|
92
|
+
</div>
|
93
|
+
</form>
|
94
|
+
|
95
|
+
<script src="https://code.jquery.com/jquery-3.7.1.js" integrity="sha256-eKhayi8LEQwp4NKxN+CfCh+3qOVUtJn3QNZ0TciWLP4="
|
96
|
+
crossorigin="anonymous"></script>
|
97
|
+
{% for item in data %}
|
98
|
+
<script>
|
99
|
+
const token = "{{item.token}}";
|
100
|
+
const max_file_size = "{{item.max_file_size}}";
|
101
|
+
const service_mode = "{{item.service_mode}}";
|
102
|
+
const production_mode = "{{item.production_mode}}";
|
103
|
+
</script>
|
104
|
+
<div class="secondary prod-only">
|
105
|
+
<div class="max-width-box">SHA: {{item.sha}}</div>
|
106
|
+
</div>
|
107
|
+
{% endfor %}
|
108
|
+
<script src="{{url_for('static', filename='/javascript/format.js')}}" type="module" language="JavaScript"></script>
|
109
|
+
|
110
|
+
<footer class="footer" id="psdi-footer"></footer>
|
111
|
+
|
112
|
+
</body>
|
113
|
+
|
114
|
+
</html>
|
@@ -0,0 +1,394 @@
|
|
1
|
+
"""
|
2
|
+
# conversion_callbacks.py
|
3
|
+
|
4
|
+
This module provides functions and callable classes which can be used as callbacks for to check the results of a
|
5
|
+
conversion test, run with the functions and classes defined in the `utils.py` module.
|
6
|
+
"""
|
7
|
+
|
8
|
+
import abc
|
9
|
+
from collections.abc import Callable, Iterable
|
10
|
+
from dataclasses import dataclass, field
|
11
|
+
import os
|
12
|
+
import re
|
13
|
+
from tempfile import TemporaryDirectory
|
14
|
+
|
15
|
+
from psdi_data_conversion.constants import DATETIME_RE_RAW
|
16
|
+
from psdi_data_conversion.file_io import unpack_zip_or_tar
|
17
|
+
from psdi_data_conversion.log_utility import string_with_placeholders_matches
|
18
|
+
from psdi_data_conversion.testing.constants import OUTPUT_TEST_DATA_LOC
|
19
|
+
from psdi_data_conversion.testing.utils import ConversionTestInfo, LibraryConversionTestInfo, check_file_match
|
20
|
+
|
21
|
+
|
22
|
+
class MultiCallback:
|
23
|
+
"""Callable class which stores a list of other callbacks to call on the input, and runs them all in sequence. All
|
24
|
+
callbacks will be run, even if some fail, and the results will be joined to an output string.
|
25
|
+
"""
|
26
|
+
|
27
|
+
def __init__(self, *l_callbacks: Callable[[ConversionTestInfo], str]):
|
28
|
+
|
29
|
+
self.l_callbacks: Iterable[Callable[[ConversionTestInfo], str]] = l_callbacks
|
30
|
+
"""A list of callbacks to be called in turn"""
|
31
|
+
|
32
|
+
def __call__(self, test_info: ConversionTestInfo) -> str:
|
33
|
+
"""When called and passed test info, run each stored callback on the test info in turn and join the results
|
34
|
+
"""
|
35
|
+
|
36
|
+
l_results = [callback(test_info) for callback in self.l_callbacks]
|
37
|
+
|
38
|
+
# Only join non-empty results so we don't end up with excess newlines
|
39
|
+
res = "\n".join([x for x in l_results if x]).strip()
|
40
|
+
|
41
|
+
return res
|
42
|
+
|
43
|
+
|
44
|
+
@dataclass
|
45
|
+
class CheckFileStatus:
|
46
|
+
"""Callable class which checks the presence or absence of standard input and output files for a conversion"""
|
47
|
+
|
48
|
+
expect_input_exists: bool | None = True
|
49
|
+
"""Whether to expect that the input file of the conversion exists (with non-zero size) or not. If None, will
|
50
|
+
not check either way."""
|
51
|
+
|
52
|
+
expect_output_exists: bool | None = True
|
53
|
+
"""Whether to expect that the output file of the conversion exists (with non-zero size) or not. If None, will
|
54
|
+
not check either way."""
|
55
|
+
|
56
|
+
expect_log_exists: bool | None = True
|
57
|
+
"""Whether to expect that the log exists (with non-zero size) or not. If None, will not check either way."""
|
58
|
+
|
59
|
+
expect_global_log_exists: bool | None = None
|
60
|
+
"""Whether to expect that the global log exists (zero-size allowed) or not. If None, will not check either way."""
|
61
|
+
|
62
|
+
def __call__(self, test_info: ConversionTestInfo) -> str:
|
63
|
+
"""Perform the check on output file and log status"""
|
64
|
+
|
65
|
+
l_errors: list[str] = []
|
66
|
+
|
67
|
+
# Check the status of the input file
|
68
|
+
qualified_in_filename = test_info.qualified_in_filename
|
69
|
+
if self.expect_input_exists:
|
70
|
+
if not os.path.isfile(qualified_in_filename):
|
71
|
+
l_errors.append(f"ERROR: Expected input file for conversion '{qualified_in_filename}' does not "
|
72
|
+
"exist")
|
73
|
+
elif os.path.getsize(qualified_in_filename) == 0:
|
74
|
+
l_errors.append(f"ERROR: Expected input file for conversion '{qualified_in_filename}' exists but "
|
75
|
+
"is unexpectedly empty")
|
76
|
+
elif self.expect_output_exists is False and os.path.isfile(qualified_in_filename):
|
77
|
+
l_errors.append(f"ERROR: Input file from conversion '{qualified_in_filename}' exists, but was expected "
|
78
|
+
"to not exist")
|
79
|
+
|
80
|
+
# Check the status of the output file
|
81
|
+
qualified_out_filename = test_info.qualified_out_filename
|
82
|
+
if self.expect_output_exists:
|
83
|
+
if not os.path.isfile(qualified_out_filename):
|
84
|
+
l_errors.append(f"ERROR: Expected output file from conversion '{qualified_out_filename}' does not "
|
85
|
+
"exist")
|
86
|
+
elif os.path.getsize(qualified_out_filename) == 0:
|
87
|
+
l_errors.append(f"ERROR: Expected output file from conversion '{qualified_out_filename}' exists but "
|
88
|
+
"is unexpectedly empty")
|
89
|
+
elif self.expect_output_exists is False and os.path.isfile(qualified_out_filename):
|
90
|
+
l_errors.append(f"ERROR: Output file from conversion '{qualified_out_filename}' exists, but was expected "
|
91
|
+
"to not exist")
|
92
|
+
|
93
|
+
qualified_log_filename = test_info.qualified_log_filename
|
94
|
+
if self.expect_log_exists:
|
95
|
+
if not os.path.isfile(qualified_log_filename):
|
96
|
+
l_errors.append(f"ERROR: Expected log file from conversion '{qualified_log_filename}' does not "
|
97
|
+
"exist")
|
98
|
+
elif os.path.getsize(qualified_log_filename) == 0:
|
99
|
+
l_errors.append(f"ERROR: Expected log file from conversion '{qualified_log_filename}' exists but "
|
100
|
+
"is unexpectedly empty")
|
101
|
+
elif self.expect_log_exists is False and os.path.isfile(qualified_log_filename):
|
102
|
+
l_errors.append(f"ERROR: Log file from conversion '{qualified_log_filename}' exists, but was expected "
|
103
|
+
"to not exist")
|
104
|
+
|
105
|
+
qualified_global_log_filename = test_info.qualified_global_log_filename
|
106
|
+
if self.expect_global_log_exists:
|
107
|
+
if not os.path.isfile(qualified_global_log_filename):
|
108
|
+
l_errors.append(f"ERROR: Expected global log file from conversion '{qualified_global_log_filename}' "
|
109
|
+
"does not exist")
|
110
|
+
elif self.expect_global_log_exists is False and os.path.isfile(qualified_global_log_filename):
|
111
|
+
l_errors.append(f"ERROR: Global log file from conversion '{qualified_global_log_filename}' exists, but was "
|
112
|
+
"expected to not exist")
|
113
|
+
|
114
|
+
# Join any errors for output
|
115
|
+
res = "\n".join(l_errors)
|
116
|
+
return res
|
117
|
+
|
118
|
+
|
119
|
+
@dataclass
|
120
|
+
class CheckArchiveContents:
|
121
|
+
"""Callable class which checks that an archive file created as the result of a conversion contains files with the
|
122
|
+
expected names and all of the expected type"""
|
123
|
+
|
124
|
+
l_filename_bases: Iterable[str]
|
125
|
+
"""List of unqualified filenames without extensions, representing the files that should be found in the archive"""
|
126
|
+
|
127
|
+
to_format: str
|
128
|
+
"""Format (extension) that all files in the archive should have"""
|
129
|
+
|
130
|
+
def __call__(self, test_info: ConversionTestInfo):
|
131
|
+
"""Run the check on archive contents"""
|
132
|
+
|
133
|
+
# First, check that the archive file exists
|
134
|
+
qualified_out_filename = test_info.qualified_out_filename
|
135
|
+
if not os.path.isfile(qualified_out_filename):
|
136
|
+
raise FileNotFoundError(f"ERROR: Expected output file from conversion '{qualified_out_filename}' does not "
|
137
|
+
"exist")
|
138
|
+
|
139
|
+
l_errors = []
|
140
|
+
|
141
|
+
# Use a temporary directory to unpack the archive, then check for each file in it
|
142
|
+
with TemporaryDirectory() as extract_dir:
|
143
|
+
unpack_zip_or_tar(qualified_out_filename, extract_dir=extract_dir)
|
144
|
+
|
145
|
+
for filename_base in self.l_filename_bases:
|
146
|
+
filename = f"{filename_base}.{self.to_format}"
|
147
|
+
if not os.path.isfile(os.path.join(extract_dir, filename)):
|
148
|
+
l_errors.append(f"ERROR: Expected file '{filename}' was not found in archive "
|
149
|
+
f"{qualified_out_filename}")
|
150
|
+
|
151
|
+
return "\n".join(l_errors)
|
152
|
+
|
153
|
+
|
154
|
+
@dataclass
|
155
|
+
class CheckTextContents(abc.ABC):
|
156
|
+
"""Callable class which checks the contents of some text (e.g. stdout or a log file) from a conversion"""
|
157
|
+
|
158
|
+
l_strings_to_find: str | Iterable[str] = field(default_factory=list)
|
159
|
+
"""One or more strings which must be found in the text. These may optionally include formatting placeholders,
|
160
|
+
e.g. "The filename is: {file}", in which case any text in the place of the placeholder will be considered valid for
|
161
|
+
a match.
|
162
|
+
"""
|
163
|
+
|
164
|
+
l_strings_to_exclude: str | Iterable[str] = field(default_factory=list)
|
165
|
+
"""One or more strings which must NOT be found in the text. These may optionally include formatting
|
166
|
+
placeholders, e.g. "The filename is: {file}", in which case any text in the place of the placeholder will be
|
167
|
+
considered valid for a match.
|
168
|
+
"""
|
169
|
+
|
170
|
+
l_regex_to_find: str | Iterable[str] = field(default_factory=list)
|
171
|
+
"""One or more uncompiled regular expressions which must be matched somewhere in the text"""
|
172
|
+
|
173
|
+
l_regex_to_exclude: str | Iterable[str] = field(default_factory=list)
|
174
|
+
"""One or more uncompiled regular expressions which must NOT be matched anywhere in the text"""
|
175
|
+
|
176
|
+
def __post_init__(self):
|
177
|
+
"""If any input was provided as just a single string, coerce it to a list"""
|
178
|
+
if isinstance(self.l_strings_to_find, str):
|
179
|
+
self.l_strings_to_find = [self.l_strings_to_find]
|
180
|
+
if isinstance(self.l_strings_to_exclude, str):
|
181
|
+
self.l_strings_to_exclude = [self.l_strings_to_exclude]
|
182
|
+
if isinstance(self.l_regex_to_find, str):
|
183
|
+
self.l_regex_to_find = [self.l_regex_to_find]
|
184
|
+
if isinstance(self.l_regex_to_exclude, str):
|
185
|
+
self.l_regex_to_exclude = [self.l_regex_to_exclude]
|
186
|
+
|
187
|
+
def get_default_strings_to_find(self, test_info: ConversionTestInfo) -> list[str]:
|
188
|
+
"""Get a default list of strings to find in the text; can be overridden by child classes to implement
|
189
|
+
defaults"""
|
190
|
+
return []
|
191
|
+
|
192
|
+
def get_default_strings_to_exclude(self, test_info: ConversionTestInfo) -> list[str]:
|
193
|
+
"""Get a default list of strings to NOT find in the text; can be overridden by child classes to implement
|
194
|
+
defaults"""
|
195
|
+
return []
|
196
|
+
|
197
|
+
def get_default_regex_to_find(self, test_info: ConversionTestInfo) -> list[str]:
|
198
|
+
"""Get a default list of uncompiled regular expressions to match in the text; can be overridden by child
|
199
|
+
classes to implement defaults"""
|
200
|
+
return []
|
201
|
+
|
202
|
+
def get_default_regex_to_exclude(self, test_info: ConversionTestInfo) -> list[str]:
|
203
|
+
"""Get a default list of uncompiled regular expressions to NOT match in the text; can be overridden by
|
204
|
+
child classes to implement defaults"""
|
205
|
+
return []
|
206
|
+
|
207
|
+
@abc.abstractmethod
|
208
|
+
def _get_text(self, test_info: ConversionTestInfo) -> str:
|
209
|
+
"""Abstract method which must be overridden to get the text to be checked"""
|
210
|
+
pass
|
211
|
+
|
212
|
+
@abc.abstractmethod
|
213
|
+
def _get_text_source_label(self, test_info: ConversionTestInfo) -> str:
|
214
|
+
"""Abstract method which must be overridden to label the source of the text to be checked"""
|
215
|
+
pass
|
216
|
+
|
217
|
+
def __call__(self, test_info: ConversionTestInfo) -> str:
|
218
|
+
"""Perform the check on text contents"""
|
219
|
+
|
220
|
+
test_text = self._get_text(test_info)
|
221
|
+
|
222
|
+
l_errors: list[str] = []
|
223
|
+
|
224
|
+
# Add the default checks to the lists of strings/regexes to check
|
225
|
+
l_strings_to_find: list[str] = list(self.l_strings_to_find) + self.get_default_strings_to_find(test_info)
|
226
|
+
l_strings_to_exclude: list[str] = (list(self.l_strings_to_exclude) +
|
227
|
+
self.get_default_strings_to_exclude(test_info))
|
228
|
+
l_regex_to_find: list[str] = list(self.l_regex_to_find) + self.get_default_regex_to_find(test_info)
|
229
|
+
l_regex_to_exclude: list[str] = list(self.l_regex_to_exclude) + self.get_default_regex_to_exclude(test_info)
|
230
|
+
|
231
|
+
# Check that all expected strings are present
|
232
|
+
for string_to_find in l_strings_to_find:
|
233
|
+
if not string_with_placeholders_matches(string_to_find, test_text):
|
234
|
+
l_errors.append(f"ERROR: String \"{string_to_find}\" was expected in "
|
235
|
+
f"{self._get_text_source_label(test_info)} but was not found. Text:\n {test_text}")
|
236
|
+
|
237
|
+
# Check that all excluded strings are not present
|
238
|
+
for l_strings_to_exclude in l_strings_to_exclude:
|
239
|
+
if string_with_placeholders_matches(l_strings_to_exclude, test_text):
|
240
|
+
l_errors.append(f"ERROR: String \"{l_strings_to_exclude}\" was not expected in "
|
241
|
+
f"{self._get_text_source_label(test_info)} but was found. Text:\n {test_text}")
|
242
|
+
|
243
|
+
# Check that all expected regexes are present
|
244
|
+
for regex_to_find in l_regex_to_find:
|
245
|
+
compiled_regex = re.compile(regex_to_find)
|
246
|
+
if not compiled_regex.search(test_text):
|
247
|
+
l_errors.append(f"ERROR: Regex /{regex_to_find}/ was expected in "
|
248
|
+
f"{self._get_text_source_label(test_info)} but was not found. Text:\n {test_text}")
|
249
|
+
|
250
|
+
# Check that all excluded regexes are not present
|
251
|
+
for regex_to_exclude in l_regex_to_exclude:
|
252
|
+
compiled_regex = re.compile(regex_to_exclude)
|
253
|
+
if compiled_regex.search(test_text):
|
254
|
+
l_errors.append(f"ERROR: Regex /{regex_to_exclude}/ was not expected in "
|
255
|
+
f"{self._get_text_source_label(test_info)} but was found. Text:\n {test_text}")
|
256
|
+
|
257
|
+
# Join any errors for output
|
258
|
+
res = "\n".join(l_errors)
|
259
|
+
return res
|
260
|
+
|
261
|
+
|
262
|
+
@dataclass
|
263
|
+
class CheckLogContents(CheckTextContents):
|
264
|
+
"""Implementation of `CheckTextContents` which checks the contents of the output log file of a conversion"""
|
265
|
+
|
266
|
+
def _get_text(self, test_info: ConversionTestInfo) -> str:
|
267
|
+
"""Get the text from the output log file"""
|
268
|
+
|
269
|
+
# First, check that the log exists
|
270
|
+
qualified_log_filename = test_info.qualified_log_filename
|
271
|
+
if not os.path.isfile(qualified_log_filename):
|
272
|
+
raise FileNotFoundError(f"ERROR: Expected log file from conversion '{qualified_log_filename}' does not "
|
273
|
+
"exist")
|
274
|
+
|
275
|
+
return open(qualified_log_filename, "r").read()
|
276
|
+
|
277
|
+
def _get_text_source_label(self, test_info: ConversionTestInfo) -> str:
|
278
|
+
"""Label as coming from the appropriate log file"""
|
279
|
+
return f"log file '{test_info.qualified_log_filename}'"
|
280
|
+
|
281
|
+
|
282
|
+
@dataclass
|
283
|
+
class CheckLogContentsSuccess(CheckLogContents):
|
284
|
+
"""Specialized callback to check log contents for a successful conversion"""
|
285
|
+
|
286
|
+
def get_default_strings_to_exclude(self, test_info: ConversionTestInfo) -> Iterable[str]:
|
287
|
+
"""Exclude strings which indicate something likely went wrong"""
|
288
|
+
return ["ERROR", "exception", "Exception"]
|
289
|
+
|
290
|
+
def get_default_regex_to_find(self, test_info: ConversionTestInfo) -> Iterable[str]:
|
291
|
+
"""Check for the filename and date and time in the log"""
|
292
|
+
return [r"File name:\s*"+os.path.splitext(test_info.test_spec.filename)[0],
|
293
|
+
DATETIME_RE_RAW]
|
294
|
+
|
295
|
+
|
296
|
+
@dataclass
|
297
|
+
class CheckStdoutContents(CheckTextContents):
|
298
|
+
"""Implementation of `CheckTextContents` which checks the output to stdout"""
|
299
|
+
|
300
|
+
def _get_text(self, test_info: ConversionTestInfo) -> str:
|
301
|
+
"""Get the text from the captured stdout"""
|
302
|
+
|
303
|
+
return test_info.captured_stdout
|
304
|
+
|
305
|
+
def _get_text_source_label(self, test_info: ConversionTestInfo) -> str:
|
306
|
+
"""Label as coming from stdout"""
|
307
|
+
return "stdout"
|
308
|
+
|
309
|
+
|
310
|
+
@dataclass
|
311
|
+
class CheckStderrContents(CheckTextContents):
|
312
|
+
"""Implementation of `CheckTextContents` which checks the output to stderr"""
|
313
|
+
|
314
|
+
def _get_text(self, test_info: ConversionTestInfo) -> str:
|
315
|
+
"""Get the text from the captured stderr"""
|
316
|
+
|
317
|
+
return test_info.captured_stderr
|
318
|
+
|
319
|
+
def _get_text_source_label(self, test_info: ConversionTestInfo) -> str:
|
320
|
+
"""Label as coming from stderr"""
|
321
|
+
return "stderr"
|
322
|
+
|
323
|
+
|
324
|
+
@dataclass
|
325
|
+
class CheckException:
|
326
|
+
"""Callable class which checks an exception raised for its type, status code, and message. Tests will only be
|
327
|
+
run on tests with the python library, as that's the only route that provides exceptions."""
|
328
|
+
|
329
|
+
ex_type: type[Exception]
|
330
|
+
"""The expected type of the raised exception (subclasses of it will also be allowed)"""
|
331
|
+
|
332
|
+
ex_message: str | None = None
|
333
|
+
"""A string pattern (with placeholders allowed) which must be found in the exceptions message"""
|
334
|
+
|
335
|
+
ex_status_code: int | None = None
|
336
|
+
"""The required status code of the exception - will not pass if this is provided and the exception doesn't come
|
337
|
+
with a status code"""
|
338
|
+
|
339
|
+
def __call__(self, test_info: ConversionTestInfo) -> str:
|
340
|
+
"""Perform the check on the exception"""
|
341
|
+
|
342
|
+
if not isinstance(test_info, LibraryConversionTestInfo):
|
343
|
+
return ""
|
344
|
+
|
345
|
+
# Confirm that an exception was indeed raised
|
346
|
+
exc_info = test_info.exc_info
|
347
|
+
if not exc_info:
|
348
|
+
return f"ERROR: No exception was raised - expected an exception of type {self.ex_type}"
|
349
|
+
|
350
|
+
l_errors: list[str] = []
|
351
|
+
|
352
|
+
# Check the exception type
|
353
|
+
if not issubclass(exc_info.type, self.ex_type):
|
354
|
+
l_errors.append(f"ERROR: Raised exception is of type '{exc_info.type}', but expected '{self.ex_type}'")
|
355
|
+
|
356
|
+
exc = exc_info.value
|
357
|
+
|
358
|
+
# Check the exception message, if applicable
|
359
|
+
if self.ex_message:
|
360
|
+
if len(exc.args) == 0:
|
361
|
+
l_errors.append(f"ERROR: Expected string \"{self.ex_message}\" in exception of type '{type(exc)}''s "
|
362
|
+
"message, but exception does not have any message")
|
363
|
+
elif not string_with_placeholders_matches(self.ex_message, str(exc.args[0])):
|
364
|
+
l_errors.append(f"ERROR: Expected string \"{self.ex_message}\" not found in exception message: "
|
365
|
+
f"\"{exc.args[0]}\"")
|
366
|
+
|
367
|
+
# Check the exception status code, if applicable
|
368
|
+
if self.ex_status_code:
|
369
|
+
if not hasattr(exc, "status_code"):
|
370
|
+
l_errors.append(f"ERROR: Expected status code {self.ex_status_code} for exception of type {type(exc)}, "
|
371
|
+
"but this exception type does not have a status code attribute")
|
372
|
+
elif self.ex_status_code != exc.status_code:
|
373
|
+
l_errors.append(f"ERROR: Expected status code {self.ex_status_code} does not match that of {exc}: "
|
374
|
+
f"{exc.status_code}")
|
375
|
+
|
376
|
+
# Join any errors for output
|
377
|
+
res = "\n".join(l_errors)
|
378
|
+
return res
|
379
|
+
|
380
|
+
|
381
|
+
@dataclass
|
382
|
+
class MatchOutputFile:
|
383
|
+
"""Callable class which checks that the output file of a conversion numerically matches an expected output file"""
|
384
|
+
|
385
|
+
ex_output_filename: str
|
386
|
+
"""The name of the file which the output of the conversion should match, relative to the test_data/output directory
|
387
|
+
"""
|
388
|
+
|
389
|
+
def __call__(self, test_info: ConversionTestInfo):
|
390
|
+
"""Run the check comparing the two files"""
|
391
|
+
|
392
|
+
qualified_ex_output_filename = os.path.join(OUTPUT_TEST_DATA_LOC, self.ex_output_filename)
|
393
|
+
|
394
|
+
return check_file_match(test_info.qualified_out_filename, qualified_ex_output_filename)
|