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.
Files changed (81) hide show
  1. psdi_data_conversion/__init__.py +11 -0
  2. psdi_data_conversion/app.py +242 -0
  3. psdi_data_conversion/bin/linux/atomsk +0 -0
  4. psdi_data_conversion/bin/linux/c2x +0 -0
  5. psdi_data_conversion/bin/mac/atomsk +0 -0
  6. psdi_data_conversion/bin/mac/c2x +0 -0
  7. psdi_data_conversion/constants.py +185 -0
  8. psdi_data_conversion/converter.py +459 -0
  9. psdi_data_conversion/converters/__init__.py +6 -0
  10. psdi_data_conversion/converters/atomsk.py +32 -0
  11. psdi_data_conversion/converters/base.py +702 -0
  12. psdi_data_conversion/converters/c2x.py +32 -0
  13. psdi_data_conversion/converters/openbabel.py +239 -0
  14. psdi_data_conversion/database.py +1064 -0
  15. psdi_data_conversion/dist.py +87 -0
  16. psdi_data_conversion/file_io.py +216 -0
  17. psdi_data_conversion/log_utility.py +241 -0
  18. psdi_data_conversion/main.py +776 -0
  19. psdi_data_conversion/scripts/atomsk.sh +32 -0
  20. psdi_data_conversion/scripts/c2x.sh +26 -0
  21. psdi_data_conversion/security.py +38 -0
  22. psdi_data_conversion/static/content/accessibility.htm +254 -0
  23. psdi_data_conversion/static/content/convert.htm +121 -0
  24. psdi_data_conversion/static/content/convertato.htm +65 -0
  25. psdi_data_conversion/static/content/convertc2x.htm +65 -0
  26. psdi_data_conversion/static/content/documentation.htm +94 -0
  27. psdi_data_conversion/static/content/feedback.htm +53 -0
  28. psdi_data_conversion/static/content/header-links.html +8 -0
  29. psdi_data_conversion/static/content/index-versions/header-links.html +8 -0
  30. psdi_data_conversion/static/content/index-versions/psdi-common-footer.html +99 -0
  31. psdi_data_conversion/static/content/index-versions/psdi-common-header.html +28 -0
  32. psdi_data_conversion/static/content/psdi-common-footer.html +99 -0
  33. psdi_data_conversion/static/content/psdi-common-header.html +28 -0
  34. psdi_data_conversion/static/content/report.htm +103 -0
  35. psdi_data_conversion/static/data/data.json +143940 -0
  36. psdi_data_conversion/static/img/colormode-toggle-dm.svg +3 -0
  37. psdi_data_conversion/static/img/colormode-toggle-lm.svg +3 -0
  38. psdi_data_conversion/static/img/psdi-icon-dark.svg +136 -0
  39. psdi_data_conversion/static/img/psdi-icon-light.svg +208 -0
  40. psdi_data_conversion/static/img/psdi-logo-darktext.png +0 -0
  41. psdi_data_conversion/static/img/psdi-logo-lighttext.png +0 -0
  42. psdi_data_conversion/static/img/social-logo-bluesky-black.svg +4 -0
  43. psdi_data_conversion/static/img/social-logo-bluesky-white.svg +4 -0
  44. psdi_data_conversion/static/img/social-logo-instagram-black.svg +1 -0
  45. psdi_data_conversion/static/img/social-logo-instagram-white.svg +1 -0
  46. psdi_data_conversion/static/img/social-logo-linkedin-black.png +0 -0
  47. psdi_data_conversion/static/img/social-logo-linkedin-white.png +0 -0
  48. psdi_data_conversion/static/img/social-logo-mastodon-black.svg +4 -0
  49. psdi_data_conversion/static/img/social-logo-mastodon-white.svg +4 -0
  50. psdi_data_conversion/static/img/social-logo-x-black.svg +3 -0
  51. psdi_data_conversion/static/img/social-logo-x-white.svg +3 -0
  52. psdi_data_conversion/static/img/social-logo-youtube-black.png +0 -0
  53. psdi_data_conversion/static/img/social-logo-youtube-white.png +0 -0
  54. psdi_data_conversion/static/img/ukri-epsr-logo-darktext.png +0 -0
  55. psdi_data_conversion/static/img/ukri-epsr-logo-lighttext.png +0 -0
  56. psdi_data_conversion/static/img/ukri-logo-darktext.png +0 -0
  57. psdi_data_conversion/static/img/ukri-logo-lighttext.png +0 -0
  58. psdi_data_conversion/static/javascript/accessibility.js +196 -0
  59. psdi_data_conversion/static/javascript/common.js +42 -0
  60. psdi_data_conversion/static/javascript/convert.js +296 -0
  61. psdi_data_conversion/static/javascript/convert_common.js +252 -0
  62. psdi_data_conversion/static/javascript/convertato.js +107 -0
  63. psdi_data_conversion/static/javascript/convertc2x.js +107 -0
  64. psdi_data_conversion/static/javascript/data.js +176 -0
  65. psdi_data_conversion/static/javascript/format.js +611 -0
  66. psdi_data_conversion/static/javascript/load_accessibility.js +89 -0
  67. psdi_data_conversion/static/javascript/psdi-common.js +177 -0
  68. psdi_data_conversion/static/javascript/report.js +381 -0
  69. psdi_data_conversion/static/styles/format.css +147 -0
  70. psdi_data_conversion/static/styles/psdi-common.css +705 -0
  71. psdi_data_conversion/templates/index.htm +114 -0
  72. psdi_data_conversion/testing/__init__.py +5 -0
  73. psdi_data_conversion/testing/constants.py +12 -0
  74. psdi_data_conversion/testing/conversion_callbacks.py +394 -0
  75. psdi_data_conversion/testing/conversion_test_specs.py +208 -0
  76. psdi_data_conversion/testing/utils.py +522 -0
  77. psdi_data_conversion-0.0.23.dist-info/METADATA +663 -0
  78. psdi_data_conversion-0.0.23.dist-info/RECORD +81 -0
  79. psdi_data_conversion-0.0.23.dist-info/WHEEL +4 -0
  80. psdi_data_conversion-0.0.23.dist-info/entry_points.txt +2 -0
  81. 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,5 @@
1
+ """
2
+ # Testing package
3
+
4
+ This package contains functions, classes, and information used solely for the purpose of running unit tests.
5
+ """
@@ -0,0 +1,12 @@
1
+ """
2
+ # constants.py
3
+
4
+ Constants related to unit testing
5
+ """
6
+
7
+ import os
8
+
9
+ TEST_DATA_LOC = os.path.abspath("./test_data")
10
+
11
+ INPUT_TEST_DATA_LOC = TEST_DATA_LOC
12
+ OUTPUT_TEST_DATA_LOC = os.path.join(TEST_DATA_LOC, "output")
@@ -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)