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,776 @@
1
+ #!/usr/bin/env python3
2
+
3
+ """@file psdi_data_conversion/main.py
4
+
5
+ Created 2025-01-14 by Bryan Gillis.
6
+
7
+ Entry-point file for the command-line interface for data conversion.
8
+ """
9
+
10
+ import logging
11
+ from argparse import ArgumentParser
12
+ import os
13
+ import sys
14
+ import textwrap
15
+
16
+ from psdi_data_conversion import constants as const
17
+ from psdi_data_conversion.constants import CL_SCRIPT_NAME, CONVERTER_DEFAULT, TERM_WIDTH
18
+ from psdi_data_conversion.converter import (D_CONVERTER_ARGS, D_SUPPORTED_CONVERTERS, L_REGISTERED_CONVERTERS,
19
+ L_SUPPORTED_CONVERTERS, run_converter)
20
+ from psdi_data_conversion.converters.base import (FileConverterAbortException, FileConverterInputException,
21
+ FileConverterHelpException)
22
+ from psdi_data_conversion.database import (get_conversion_quality, get_converter_info, get_format_info,
23
+ get_in_format_args, get_out_format_args, get_possible_converters,
24
+ get_possible_formats)
25
+ from psdi_data_conversion.file_io import split_archive_ext
26
+ from psdi_data_conversion.log_utility import get_log_level_from_str
27
+
28
+
29
+ def print_wrap(s: str, newline=False, err=False, **kwargs):
30
+ """Print a string wrapped to the terminal width
31
+ """
32
+ if err:
33
+ file = sys.stderr
34
+ else:
35
+ file = sys.stdout
36
+ for line in s.split("\n"):
37
+ print(textwrap.fill(line, width=TERM_WIDTH, **kwargs), file=file)
38
+ if newline:
39
+ print("")
40
+
41
+
42
+ class ConvertArgs:
43
+ """Class storing arguments for data conversion, processed and determined from the input arguments.
44
+ """
45
+
46
+ def __init__(self, args):
47
+
48
+ # Start by copying over arguments. Some share names with reserved words, so we have to use `getattr` for them
49
+
50
+ # Positional arguments
51
+ self.l_args: list[str] = args.l_args
52
+
53
+ # Keyword arguments for standard conversion
54
+ self.from_format: str | None = getattr(args, "from")
55
+ self._input_dir: str | None = getattr(args, "in")
56
+ self.to_format: str | None = args.to
57
+ self._output_dir: str | None = args.out
58
+ converter_name = getattr(args, "with")
59
+ if isinstance(converter_name, str):
60
+ self.name = converter_name
61
+ elif converter_name:
62
+ self.name: str = " ".join(converter_name)
63
+ else:
64
+ self.name = None
65
+ self.delete_input = args.delete_input
66
+ self.from_flags: str = args.from_flags.replace(r"\-", "-")
67
+ self.to_flags: str = args.to_flags.replace(r"\-", "-")
68
+ self.from_options: str = args.from_options.replace(r"\-", "-")
69
+ self.to_options: str = args.to_options.replace(r"\-", "-")
70
+ self.no_check: bool = args.nc
71
+ self.strict: bool = args.strict
72
+
73
+ # Keyword arguments for alternative functionality
74
+ self.list: bool = args.list
75
+
76
+ # Logging/stdout arguments
77
+ self.log_mode: bool = args.log_mode
78
+ self.quiet = args.quiet
79
+ self._log_file: str | None = args.log_file
80
+
81
+ if not args.log_level:
82
+ self.log_level = None
83
+ else:
84
+ try:
85
+ self.log_level = get_log_level_from_str(args.log_level)
86
+ except ValueError as e:
87
+ # A ValueError indicates an unrecognised logging level, so we reraise this as a help exception to
88
+ # indicate we want to provide this as feedback to the user so they can correct their command
89
+ raise FileConverterHelpException(str(e))
90
+
91
+ # Special handling for listing converters
92
+ if self.list:
93
+ # Force log mode to stdout and turn off quiet
94
+ self.log_mode = const.LOG_STDOUT
95
+ self.quiet = False
96
+
97
+ # Get the converter name from the arguments if it wasn't provided by -w/--with
98
+ if not self.name:
99
+ self.name = " ".join(self.l_args)
100
+
101
+ # For this operation, any other arguments can be ignored
102
+ return
103
+
104
+ # If not listing and a converter name wasn't supplied, use the default converter
105
+ if not self.name:
106
+ self.name = CONVERTER_DEFAULT
107
+
108
+ # Quiet mode is equivalent to logging mode == LOGGING_NONE, so normalize them if either is set
109
+ if self.quiet:
110
+ self.log_mode = const.LOG_NONE
111
+ elif self.log_mode == const.LOG_NONE:
112
+ self.quiet = True
113
+
114
+ # Check validity of input
115
+
116
+ if len(self.l_args) == 0:
117
+ raise FileConverterHelpException("One or more names of files to convert must be provided")
118
+
119
+ if self._input_dir is not None and not os.path.isdir(self._input_dir):
120
+ raise FileConverterHelpException(f"The provided input directory '{self._input_dir}' does not exist as a "
121
+ "directory")
122
+
123
+ if self.to_format is None:
124
+ msg = textwrap.fill("ERROR Output format (-t or --to) must be provided. For information on supported "
125
+ "formats and converters, call:\n")
126
+ msg += f"{CL_SCRIPT_NAME} -l"
127
+ raise FileConverterHelpException(msg, msg_preformatted=True)
128
+
129
+ # If the output directory doesn't exist, silently create it
130
+ if self._output_dir is not None and not os.path.isdir(self._output_dir):
131
+ if os.path.exists(self._output_dir):
132
+ raise FileConverterHelpException(f"Output directory '{self._output_dir}' exists but is not a "
133
+ "directory")
134
+ os.makedirs(self._output_dir, exist_ok=True)
135
+
136
+ # Check the converter is recognized
137
+ if self.name not in L_SUPPORTED_CONVERTERS:
138
+ msg = textwrap.fill(f"ERROR: Converter '{self.name}' not recognised", width=TERM_WIDTH)
139
+ msg += f"\n\n{get_supported_converters()}"
140
+ raise FileConverterHelpException(msg, msg_preformatted=True)
141
+ elif self.name not in L_REGISTERED_CONVERTERS:
142
+ msg = textwrap.fill(f"ERROR: Converter '{self.name}' is not registered. It may be possible to register "
143
+ "it by installing an appropriate binary for your platform.", width=TERM_WIDTH)
144
+ msg += f"\n\n{get_supported_converters()}"
145
+ raise FileConverterHelpException(msg, msg_preformatted=True)
146
+
147
+ # Logging mode is valid
148
+ if self.log_mode not in const.L_ALLOWED_LOG_MODES:
149
+ raise FileConverterHelpException(f"Unrecognised logging mode: {self.log_mode}. Allowed "
150
+ f"modes are: {const.L_ALLOWED_LOG_MODES}")
151
+
152
+ # Arguments specific to this converter
153
+ self.d_converter_args = {}
154
+ l_converter_args = D_CONVERTER_ARGS[self.name]
155
+ if not l_converter_args:
156
+ l_converter_args = []
157
+ for arg_name, _, get_data in l_converter_args:
158
+ # Convert the argument name to how it will be represented in the parsed_args object
159
+ while arg_name.startswith("-"):
160
+ arg_name = arg_name[1:]
161
+ arg_name = arg_name.replace("-", "_")
162
+ self.d_converter_args.update(get_data(getattr(args, arg_name)))
163
+
164
+ @property
165
+ def input_dir(self):
166
+ """If the input directory isn't provided, use the current directory.
167
+ """
168
+ if self._input_dir is None:
169
+ self._input_dir = os.getcwd()
170
+ return self._input_dir
171
+
172
+ @property
173
+ def output_dir(self):
174
+ """If the output directory isn't provided, use the input directory.
175
+ """
176
+ if self._output_dir is None:
177
+ self._output_dir = self.input_dir
178
+ return self._output_dir
179
+
180
+ @property
181
+ def log_file(self):
182
+ """Determine a name for the log file if one is not provided.
183
+ """
184
+ if self._log_file is None:
185
+ if self.list:
186
+ self._log_file = const.DEFAULT_LISTING_LOG_FILE
187
+ else:
188
+ first_filename = os.path.join(self.input_dir, self.l_args[0])
189
+
190
+ # Find the path to this file
191
+ if not os.path.isfile(first_filename):
192
+ if self.from_format:
193
+ test_filename = first_filename + f".{self.from_format}"
194
+ if os.path.isfile(test_filename):
195
+ first_filename = test_filename
196
+ else:
197
+ raise FileConverterHelpException(f"Input file {first_filename} cannot be found. Also "
198
+ f"checked for {test_filename}.")
199
+ else:
200
+ raise FileConverterHelpException(f"Input file {first_filename} cannot be found.")
201
+
202
+ filename_base = os.path.split(split_archive_ext(first_filename)[0])[1]
203
+ if self.log_mode == const.LOG_FULL:
204
+ # For server-style logging, other files will be created and used for logs
205
+ self._log_file = None
206
+ else:
207
+ self._log_file = os.path.join(self.output_dir, filename_base + const.LOG_EXT)
208
+ return self._log_file
209
+
210
+
211
+ def get_argument_parser():
212
+ """Get an argument parser for this script.
213
+
214
+ Returns
215
+ -------
216
+ parser : ArgumentParser
217
+ An argument parser set up with the allowed command-line arguments for this script.
218
+ """
219
+
220
+ parser = ArgumentParser()
221
+
222
+ # Positional arguments
223
+ parser.add_argument("l_args", type=str, nargs="*",
224
+ help="Normally, file(s) to be converted or zip/tar archives thereof. If an archive or archives "
225
+ "are provided, the output will be packed into an archive of the same type. Filenames should be "
226
+ "provided as either relative to the input directory (default current directory) or absolute. "
227
+ "If the '-l' or '--list' flag is set, instead the name of a converter can be used here to get "
228
+ "information on it.")
229
+
230
+ # Keyword arguments for standard conversion
231
+ parser.add_argument("-f", "--from", type=str, default=None,
232
+ help="The input (convert from) file extension (e.g., smi). If not provided, will attempt to "
233
+ "auto-detect format.")
234
+ parser.add_argument("-i", "--in", type=str, default=None,
235
+ help="The directory containing the input file(s), default current directory.")
236
+ parser.add_argument("-t", "--to", type=str, default=None,
237
+ help="The output (convert to) file extension (e.g., cmi).")
238
+ parser.add_argument("-o", "--out", type=str, default=None,
239
+ help="The directory where output files should be created. If not provided, output files will "
240
+ "be created in -i/--in directory if that was provided, or else in the directory containing the "
241
+ "first input file.")
242
+ parser.add_argument("-w", "--with", type=str, nargs="+",
243
+ help="The converter to be used (default 'Open Babel').")
244
+ parser.add_argument("--delete-input", action="store_true",
245
+ help="If set, input files will be deleted after conversion, default they will be kept")
246
+ parser.add_argument("--from-flags", type=str, default="",
247
+ help="Any command-line flags to be provided to the converter for reading in the input file(s). "
248
+ "For information on the flags accepted by a converter and its required format for them, "
249
+ "call this script with '-l <converter name>'. If the set of flags includes any spaces, it "
250
+ "must be quoted, and if hyphens are used, the first preceding hyphen for each flag must "
251
+ "be backslash-escaped, e.g. '--from-flags \"\\-a \\-bc \\--example\"'")
252
+ parser.add_argument("--to-flags", type=str, default="",
253
+ help="Any command-line flags to be provided to the converter for writing the output file(s). "
254
+ "For information on the flags accepted by a converter and its required format for them, "
255
+ "call this script with '-l <converter name>'. If the set of flags includes any spaces, it "
256
+ "must be quoted, and if hyphens are used, the first preceding hyphen for each flag must "
257
+ "be backslash-escaped, e.g. '--to-flags \"\\-a \\-bc \\--example\"'")
258
+ parser.add_argument("--from-options", type=str, default="",
259
+ help="Any command-line options to be provided to the converter for reading in the input "
260
+ "file(s). For information on the options accepted by a converter and its required format "
261
+ "for them, call this script with '-l <converter name>'. If the set of options includes "
262
+ "any spaces, it must be quoted, and the first preceding hyphen for each option must be "
263
+ "backslash-escaped, e.g. '--from-options \"\\-x xval --opt optval\"'")
264
+ parser.add_argument("--to-options", type=str, default="",
265
+ help="Any command-line options to be provided to the converter for writing the output "
266
+ "file(s). For information on the options accepted by a converter and its required format "
267
+ "for them, call this script with '-l <converter name>'. If the set of options includes "
268
+ "any spaces, it must be quoted, and the first preceding hyphen for each option must be "
269
+ "backslash-escaped, e.g. '--to-options \"\\-x xval --opt optval\"'")
270
+ parser.add_argument("-s", "--strict", action="store_true",
271
+ help="If set, will fail if one of the input files has the wrong extension (including those "
272
+ "contained in archives, but not the archive files themselves). Otherwise, will only print a "
273
+ "warning in this case.")
274
+ parser.add_argument("--nc", "--no-check", action="store_true",
275
+ help="If set, will not perform a pre-check in the database on the validity of a conversion. "
276
+ "Setting this will result in a less human-friendly error message (or may even falsely indicate "
277
+ "success) if the conversion is not supported, but will save some execution time. Recommended "
278
+ "only for automated execution after the user has confirmed a conversion is supported")
279
+
280
+ # Keyword arguments specific to converters
281
+ for converter_name in L_REGISTERED_CONVERTERS:
282
+ l_converter_args = D_CONVERTER_ARGS[converter_name]
283
+ if l_converter_args:
284
+ for arg_name, kwargs, _ in l_converter_args:
285
+ parser.add_argument(arg_name, **kwargs)
286
+
287
+ # Keyword arguments for alternative functionality
288
+ parser.add_argument("-l", "--list", action="store_true",
289
+ help="If provided alone, lists all available converters. If the name of a converter is "
290
+ "provided, gives information on the converter and any command-line flags it accepts.")
291
+
292
+ # Logging/stdout arguments
293
+ parser.add_argument("-g", "--log-file", type=str, default=None,
294
+ help="The name of the file to log to. This can be provided relative to the current directory "
295
+ "(e.g. '-g ../logs/log-file.txt') or fully qualified (e.g. /path/to/log-file.txt). "
296
+ "If not provided, the log file will be named after the =first input file (+'.log') and placed "
297
+ "in the output directory (specified with -o/--out).\n"
298
+ "In 'full' logging mode (not recommended with this interface), this will apply only to logs "
299
+ "from the outermost level of the script if explicitly specified. If not explicitly specified, "
300
+ "those logs will be sent to stderr.")
301
+ parser.add_argument("--log-mode", type=str, default=const.LOG_SIMPLE,
302
+ help="How logs should be stored. Allowed values are: \n"
303
+ "- 'full' - Multi-file logging, not recommended for the CLI, but allowed for a compatible "
304
+ "interface with the public web app"
305
+ "- 'simple' - Logs saved to one file"
306
+ "- 'stdout' - Output logs and errors only to stdout"
307
+ "- 'none' - Output only errors to stdout")
308
+ parser.add_argument("-q", "--quiet", action="store_true",
309
+ help="If set, all terminal output aside from errors will be suppressed and no log file will be "
310
+ "generated.")
311
+ parser.add_argument("--log-level", type=str, default=None,
312
+ help="The desired level to log at. Allowed values are: 'DEBUG', 'INFO', 'WARNING', 'ERROR, "
313
+ "'CRITICAL'. Default: 'INFO' for logging to file, 'WARNING' for logging to stdout")
314
+
315
+ return parser
316
+
317
+
318
+ def parse_args():
319
+ """Parses arguments for this executable.
320
+
321
+ Returns
322
+ -------
323
+ args : Namespace
324
+ The parsed arguments.
325
+ """
326
+
327
+ parser = get_argument_parser()
328
+
329
+ args = ConvertArgs(parser.parse_args())
330
+
331
+ return args
332
+
333
+
334
+ def detail_converter_use(args: ConvertArgs):
335
+ """Prints output providing information on a specific converter, including the flags and options it allows
336
+ """
337
+
338
+ converter_info = get_converter_info(args.name)
339
+ converter_class = D_SUPPORTED_CONVERTERS[args.name]
340
+
341
+ print_wrap(f"{converter_info.name}: {converter_info.description} ({converter_info.url})", break_long_words=False,
342
+ break_on_hyphens=False, newline=True)
343
+
344
+ if converter_class.info:
345
+ print_wrap(converter_class.info, break_long_words=False, break_on_hyphens=False, newline=True)
346
+
347
+ # If both an input and output format are specified, provide the degree of success for this conversion. Otherwise
348
+ # list possible input output formats
349
+ if args.from_format is not None and args.to_format is not None:
350
+ qual = get_conversion_quality(args.name, args.from_format, args.to_format)
351
+ if qual is None:
352
+ print_wrap(f"Conversion from '{args.from_format}' to '{args.to_format}' with {args.name} is not "
353
+ "supported.", newline=True)
354
+ else:
355
+ print_wrap(f"Conversion from '{args.from_format}' to '{args.to_format}' with {args.name} is "
356
+ f"possible with {qual.qual_str} conversion quality", newline=True)
357
+ # If there are any potential issues with the conversion, print them out
358
+ if qual.details:
359
+ print_wrap("WARNING: Potential data loss or extrapolation issues with this conversion:")
360
+ for detail_line in qual.details.split("\n"):
361
+ print_wrap(f"- {detail_line}")
362
+ print("")
363
+ else:
364
+ l_input_formats, l_output_formats = get_possible_formats(args.name)
365
+
366
+ # If one format was supplied, check if it's supported
367
+ for (format_name, l_formats, to_or_from) in ((args.from_format, l_input_formats, "from"),
368
+ (args.to_format, l_output_formats, "to")):
369
+ if format_name is None:
370
+ continue
371
+ if format_name in l_formats:
372
+ optional_not: str = ""
373
+ else:
374
+ optional_not: str = " not"
375
+ print_wrap(f"Conversion {to_or_from} {format_name} is {optional_not}supported by {args.name}.\n")
376
+
377
+ # List all possible formats, and which can be used for input and which for output
378
+ s_all_formats: set[str] = set(l_input_formats)
379
+ s_all_formats.update(l_output_formats)
380
+ l_all_formats: list[str] = list(s_all_formats)
381
+ l_all_formats.sort(key=lambda s: s.lower())
382
+
383
+ print_wrap(f"File formats supported by {args.name}:", newline=True)
384
+ max_format_length = max([len(x) for x in l_all_formats])
385
+ print(" "*(max_format_length+4) + " INPUT OUTPUT")
386
+ print(" "*(max_format_length+4) + " ----- ------")
387
+ for file_format in l_all_formats:
388
+ in_yes_or_no = "yes" if file_format in l_input_formats else "no"
389
+ out_yes_or_no = "yes" if file_format in l_output_formats else "no"
390
+ print(f" {file_format:>{max_format_length}}{in_yes_or_no:>8}{out_yes_or_no:>8}")
391
+ print("")
392
+
393
+ if converter_class.allowed_flags is None:
394
+ print_wrap("Information has not been provided about general flags accepted by this converter.", newline=True)
395
+ elif len(converter_class.allowed_flags) > 0:
396
+ print_wrap("Allowed general flags:")
397
+ for flag, d_data, _ in converter_class.allowed_flags:
398
+ help = d_data.get("help", "(No information provided)")
399
+ print(f" {flag}")
400
+ print_wrap(help, width=TERM_WIDTH, initial_indent=" "*4, subsequent_indent=" "*4)
401
+ print("")
402
+
403
+ if converter_class.allowed_options is None:
404
+ print_wrap("Information has not been provided about general options accepted by this converter.", newline=True)
405
+ elif len(converter_class.allowed_options) > 0:
406
+ print_wrap("Allowed general options:")
407
+ for option, d_data, _ in converter_class.allowed_options:
408
+ help = d_data.get("help", "(No information provided)")
409
+ print(f" {option} <val(s)>")
410
+ print(textwrap.fill(help, initial_indent=" "*4, subsequent_indent=" "*4))
411
+ print("")
412
+
413
+ # If input/output-format specific flags or options are available for the converter but a format isn't available,
414
+ # we'll want to take note of that and mention that at the end of the output
415
+ mention_input_format = False
416
+ mention_output_format = False
417
+
418
+ if args.from_format is not None:
419
+ from_format = args.from_format
420
+ in_flags, in_options = get_in_format_args(args.name, from_format)
421
+ else:
422
+ in_flags, in_options = [], []
423
+ from_format = "N/A"
424
+ if converter_class.has_in_format_flags_or_options:
425
+ mention_input_format = True
426
+
427
+ if args.to_format is not None:
428
+ to_format = args.to_format
429
+ out_flags, out_options = get_out_format_args(args.name, to_format)
430
+ else:
431
+ out_flags, out_options = [], []
432
+ to_format = "N/A"
433
+ if converter_class.has_out_format_flags_or_options:
434
+ mention_output_format = True
435
+
436
+ # Number of character spaces allocated for flags/options when printing them out
437
+ ARG_LEN = 20
438
+
439
+ for l_args, flag_or_option, input_or_output, format_name in ((in_flags, "flag", "input", from_format),
440
+ (in_options, "option", "input", from_format),
441
+ (out_flags, "flag", "output", to_format),
442
+ (out_options, "option", "output", to_format)):
443
+ if len(l_args) == 0:
444
+ continue
445
+ print_wrap(f"Allowed {input_or_output} {flag_or_option}s for format '{format_name}':")
446
+ for arg_info in l_args:
447
+ if flag_or_option == "flag":
448
+ optional_brief = ""
449
+ else:
450
+ optional_brief = f" <{arg_info.brief}>"
451
+ print_wrap(f"{arg_info.flag+optional_brief:>{ARG_LEN}} {arg_info.description}",
452
+ subsequent_indent=" "*(ARG_LEN+2))
453
+ if arg_info.info and arg_info.info != "N/A":
454
+ print_wrap(arg_info.info,
455
+ initial_indent=" "*(ARG_LEN+2),
456
+ subsequent_indent=" "*(ARG_LEN+2))
457
+ print("")
458
+
459
+ # Now at the end, bring up input/output-format-specific flags and options
460
+ if mention_input_format and mention_output_format:
461
+ print_wrap("For details on input/output flags and options allowed for specific formats, call:\n"
462
+ f"{CL_SCRIPT_NAME} -l {args.name} -f <input_format> -t <output_format>")
463
+ elif mention_input_format:
464
+ print_wrap("For details on input flags and options allowed for a specific format, call:\n"
465
+ f"{CL_SCRIPT_NAME} -l {args.name} -f <input_format> [-t <output_format>]")
466
+ elif mention_output_format:
467
+ print_wrap("For details on output flags and options allowed for a specific format, call:\n"
468
+ f"{CL_SCRIPT_NAME} -l {args.name} -t <output_format> [-f <input_format>]")
469
+
470
+
471
+ def list_supported_formats(err=False):
472
+ """Prints a list of all formats recognised by at least one registered converter
473
+ """
474
+ # Make a list of all formats recognised by at least one registered converter
475
+ s_all_formats: set[str] = set()
476
+ s_registered_formats: set[str] = set()
477
+ for converter_name in L_SUPPORTED_CONVERTERS:
478
+ l_in_formats, l_out_formats = get_possible_formats(converter_name)
479
+ s_all_formats.update(l_in_formats)
480
+ s_all_formats.update(l_out_formats)
481
+ if converter_name in L_REGISTERED_CONVERTERS:
482
+ s_registered_formats.update(l_in_formats)
483
+ s_registered_formats.update(l_out_formats)
484
+
485
+ s_unregistered_formats = s_all_formats.difference(s_registered_formats)
486
+
487
+ # Convert the sets to lists and alphabetise them
488
+ l_registered_formats = list(s_registered_formats)
489
+ l_registered_formats.sort(key=lambda s: s.lower())
490
+ l_unregistered_formats = list(s_unregistered_formats)
491
+ l_unregistered_formats.sort(key=lambda s: s.lower())
492
+
493
+ # Pad the format strings to all be the same length. To keep columns aligned, all padding is done with non-
494
+ # breaking spaces (\xa0), and each format is followed by a single normal space
495
+ longest_format_len = max([len(x) for x in l_registered_formats])
496
+ l_padded_formats = [f"{x:\xa0<{longest_format_len}} " for x in l_registered_formats]
497
+
498
+ print_wrap("Formats supported by registered converters: ", err=err, newline=True)
499
+ print_wrap("".join(l_padded_formats), err=err, initial_indent=" ", subsequent_indent=" ", newline=True)
500
+
501
+ if l_unregistered_formats:
502
+ longest_unregistered_format_len = max([len(x) for x in l_unregistered_formats])
503
+ l_padded_unregistered_formats = [f"{x:\xa0<{longest_unregistered_format_len}} "
504
+ for x in l_unregistered_formats]
505
+ print_wrap("Formats supported by unregistered converters which are supported by this package: ", err=err,
506
+ newline=True)
507
+ print_wrap("".join(l_padded_unregistered_formats), err=err,
508
+ initial_indent=" ", subsequent_indent=" ", newline=True)
509
+
510
+ print_wrap("Note that not all formats are supported with all converters, or both as input and as output.")
511
+
512
+
513
+ def detail_possible_converters(from_format: str, to_format: str):
514
+ """Prints details on converters that can perform a conversion from one format to another
515
+ """
516
+
517
+ # Check that both formats are valid, and print an error if not
518
+ either_format_failed = False
519
+
520
+ try:
521
+ get_format_info(from_format)
522
+ except KeyError:
523
+ either_format_failed = True
524
+ print_wrap(f"ERROR: Input format '{from_format}' not recognised", newline=True, err=True)
525
+
526
+ try:
527
+ get_format_info(to_format)
528
+ except KeyError:
529
+ either_format_failed = True
530
+ print_wrap(f"ERROR: Output format '{from_format}' not recognised", newline=True, err=True)
531
+
532
+ if either_format_failed:
533
+ # Let the user know about formats which are allowed
534
+ list_supported_formats(err=True)
535
+ exit(1)
536
+
537
+ l_possible_converters = get_possible_converters(from_format, to_format)
538
+
539
+ l_possible_registered_converters = [x for x in l_possible_converters if x in L_REGISTERED_CONVERTERS]
540
+ l_possible_unregistered_converters = [x for x in l_possible_converters if
541
+ x in L_SUPPORTED_CONVERTERS and x not in L_REGISTERED_CONVERTERS]
542
+
543
+ if len(l_possible_registered_converters)+len(l_possible_unregistered_converters) == 0:
544
+ print_wrap(f"No converters are available which can perform a conversion from {from_format} to {to_format}")
545
+ return
546
+ elif len(l_possible_registered_converters) == 0:
547
+ print_wrap(f"No registered converters can perform a conversion from {from_format} to {to_format}, however "
548
+ "the following converters are supported by this package on other platforms and can perform this "
549
+ "conversion:", newline=True)
550
+ print("\n ".join(l_possible_unregistered_converters))
551
+ return
552
+
553
+ print_wrap(f"The following registered converters can convert from {from_format} to {to_format}:", newline=True)
554
+ print("\n ".join(l_possible_registered_converters))
555
+ if l_possible_unregistered_converters:
556
+ print("")
557
+ print_wrap("Additionally, the following converters are supported by this package on other platforms and can "
558
+ "perform this conversion:", newline=True)
559
+ print("\n ".join(l_possible_registered_converters))
560
+
561
+ print_wrap("For details on input/output flags and options allowed by a converter for this conversion, call:")
562
+ print(f"{CL_SCRIPT_NAME} -l <converter name> -f {from_format} -t {to_format}")
563
+
564
+
565
+ def get_supported_converters():
566
+ """Gets a string containing a list of supported converters
567
+ """
568
+
569
+ MSG_NOT_REGISTERED = "(supported but not registered)"
570
+
571
+ l_converters: list[str] = []
572
+ any_not_registered = False
573
+ for converter_name in L_SUPPORTED_CONVERTERS:
574
+ converter_text = converter_name
575
+ if converter_name not in L_REGISTERED_CONVERTERS:
576
+ converter_text += f" {MSG_NOT_REGISTERED}"
577
+ any_not_registered = True
578
+ l_converters.append(converter_text)
579
+
580
+ output_str = "Available converters: \n\n " + "\n ".join(l_converters)
581
+
582
+ if any_not_registered:
583
+ output_str += (f"\n\nConverters marked as \"{MSG_NOT_REGISTERED}\" are supported by this package, but no "
584
+ "appropriate binary for your platform was either distributed with this package or "
585
+ "found on your system")
586
+
587
+ return output_str
588
+
589
+
590
+ def list_supported_converters(err=False):
591
+ """Prints a list of supported converters for the user
592
+ """
593
+ if err:
594
+ file = sys.stderr
595
+ else:
596
+ file = sys.stdout
597
+ print(get_supported_converters() + "\n", file=file)
598
+
599
+
600
+ def detail_converters_and_formats(args: ConvertArgs):
601
+ """Prints details on available converters and formats for the user.
602
+ """
603
+ if args.name in L_SUPPORTED_CONVERTERS:
604
+ detail_converter_use(args)
605
+ if args.name not in L_REGISTERED_CONVERTERS:
606
+ print_wrap("WARNING: This converter is supported by this package but is not registered. It may be possible "
607
+ "to register it by installing an appropriate binary on your system.", err=True)
608
+ return
609
+
610
+ elif args.name != "":
611
+ print_wrap(f"ERROR: Converter '{args.name}' not recognized.", err=True, newline=True)
612
+ list_supported_converters(err=True)
613
+ exit(1)
614
+ elif args.from_format and args.to_format:
615
+ detail_possible_converters(args.from_format, args.to_format)
616
+ return
617
+
618
+ list_supported_converters()
619
+ list_supported_formats()
620
+
621
+ print("")
622
+
623
+ print_wrap("For more details on a converter, call:")
624
+ print(f"{CL_SCRIPT_NAME} -l <converter name>\n")
625
+
626
+ print_wrap("For a list of converters that can perform a desired conversion, call:")
627
+ print(f"{CL_SCRIPT_NAME} -l -f <input format> -t <output format>\n")
628
+
629
+ print_wrap("For a list of options provided by a converter for a desired conversion, call:")
630
+ print(f"{CL_SCRIPT_NAME} -l <converter name> -f <input format> -t <output format>")
631
+
632
+
633
+ def run_from_args(args: ConvertArgs):
634
+ """Workhorse function to perform primary execution of this script, using the provided parsed arguments.
635
+
636
+ Parameters
637
+ ----------
638
+ args : ConvertArgs
639
+ The parsed arguments for this script.
640
+ """
641
+
642
+ # Check if we've been asked to list options
643
+ if args.list:
644
+ return detail_converters_and_formats(args)
645
+
646
+ data = {'success': 'unknown',
647
+ 'from_flags': args.from_flags,
648
+ 'to_flags': args.to_flags,
649
+ 'from_options': args.from_options,
650
+ 'to_options': args.to_options,
651
+ 'from_arg_flags': '',
652
+ 'from_args': '',
653
+ 'to_arg_flags': '',
654
+ 'to_args': '',
655
+ 'upload_file': 'true'}
656
+ data.update(args.d_converter_args)
657
+
658
+ success = True
659
+
660
+ for filename in args.l_args:
661
+
662
+ # Search for the file in the input directory
663
+ qualified_filename = os.path.join(args.input_dir, filename)
664
+ if not os.path.isfile(qualified_filename):
665
+ # Check if we can add the format to it as an extension to find it
666
+ ex_extension = f".{args.from_format}"
667
+ if not qualified_filename.endswith(ex_extension):
668
+ qualified_filename += ex_extension
669
+ if not os.path.isfile(qualified_filename):
670
+ print_wrap(f"ERROR: Cannot find file {filename+ex_extension} in directory {args.input_dir}",
671
+ err=True)
672
+ continue
673
+ else:
674
+ print_wrap(f"ERROR: Cannot find file {filename} in directory {args.input_dir}", err=True)
675
+ continue
676
+
677
+ if not args.quiet:
678
+ print_wrap(f"Converting {filename} to {args.to_format}...", newline=True)
679
+
680
+ try:
681
+ conversion_result = run_converter(filename=qualified_filename,
682
+ to_format=args.to_format,
683
+ from_format=args.from_format,
684
+ name=args.name,
685
+ data=data,
686
+ use_envvars=False,
687
+ upload_dir=args.input_dir,
688
+ download_dir=args.output_dir,
689
+ no_check=args.no_check,
690
+ strict=args.strict,
691
+ log_file=args.log_file,
692
+ log_mode=args.log_mode,
693
+ log_level=args.log_level,
694
+ delete_input=args.delete_input,
695
+ refresh_local_log=False)
696
+ except FileConverterHelpException as e:
697
+ print_wrap(f"ERROR: {e}", err=True)
698
+ success = False
699
+ continue
700
+ except FileConverterAbortException as e:
701
+ print_wrap(f"ERROR: Attempt to convert file {filename} aborted with status code {e.status_code} and "
702
+ f"message:\n{e}\n", err=True)
703
+ success = False
704
+ continue
705
+ except FileConverterInputException as e:
706
+ if "Conversion from" in str(e) and "is not supported" in str(e):
707
+ print_wrap(f"ERROR: {e}", err=True, newline=True)
708
+ detail_possible_converters(args.from_format, args.to_format)
709
+ else:
710
+ print_wrap(f"ERROR: Attempt to convert file {filename} failed at converter initialization with "
711
+ f"exception type {type(e)} and message: \n{e}\n", err=True)
712
+ success = False
713
+ continue
714
+ except Exception as e:
715
+ print_wrap(f"ERROR: Attempt to convert file {filename} failed with exception type {type(e)} and message: " +
716
+ f"\n{e}\n", err=True)
717
+ success = False
718
+ continue
719
+
720
+ if not args.quiet:
721
+ print_wrap("Success! The converted file can be found at:",)
722
+ print(f" {conversion_result.output_filename}")
723
+ print_wrap("The log can be found at:")
724
+ print(f" {conversion_result.log_filename}")
725
+
726
+ if not success:
727
+ exit(1)
728
+
729
+
730
+ def main():
731
+ """Standard entry-point function for this script.
732
+ """
733
+
734
+ # If no inputs were provided, print a message about usage
735
+ if len(sys.argv) == 1:
736
+ print_wrap("See the README.md file for information on using this utility and examples of basic usage, or for "
737
+ "detailed explanation of arguments call:")
738
+ print(f"{CL_SCRIPT_NAME} -h")
739
+ exit(1)
740
+
741
+ try:
742
+ args = parse_args()
743
+ except FileConverterHelpException as e:
744
+ # If we get a Help exception, it's likely due to user error, so don't bother them with a traceback and simply
745
+ # print the message to stderr
746
+ if e.msg_preformatted:
747
+ print(e, file=sys.stderr)
748
+ else:
749
+ print_wrap(f"ERROR: {e}", err=True)
750
+ exit(1)
751
+
752
+ if (args.log_mode == const.LOG_SIMPLE or args.log_mode == const.LOG_FULL) and args.log_file:
753
+ # Delete any previous local log if it exists
754
+ try:
755
+ os.remove(args.log_file)
756
+ except FileNotFoundError:
757
+ pass
758
+ logging.basicConfig(filename=args.log_file, level=args.log_level,
759
+ format=const.LOG_FORMAT, datefmt=const.TIMESTAMP_FORMAT)
760
+ else:
761
+ logging.basicConfig(level=args.log_level, format=const.LOG_FORMAT)
762
+
763
+ logging.debug("#")
764
+ logging.debug("# Beginning execution of script `%s`", __file__)
765
+ logging.debug("#")
766
+
767
+ run_from_args(args)
768
+
769
+ logging.debug("#")
770
+ logging.debug("# Finished execution of script `%s`", __file__)
771
+ logging.debug("#")
772
+
773
+
774
+ if __name__ == "__main__":
775
+
776
+ main()