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,87 @@
1
+ """@file psdi_data_conversion/dist.py
2
+
3
+ Created 2025-02-25 by Bryan Gillis.
4
+
5
+ Functions and utilities related to handling multiple user OSes and distributions
6
+ """
7
+
8
+ import os
9
+ import shutil
10
+ import psdi_data_conversion
11
+ import sys
12
+
13
+ # Labels for each platform (which we use for the folder in this project), and the head of the name each platform will
14
+ # have in `sys.platform`
15
+
16
+ LINUX_LABEL = "linux"
17
+ LINUX_NAME_HEAD = "linux"
18
+
19
+ WINDOWS_LABEL = "windows"
20
+ WINDOWS_NAME_HEAD = "win"
21
+
22
+ MAC_LABEL = "mac"
23
+ MAC_NAME_HEAD = "darwin"
24
+
25
+ D_DIST_NAME_HEADS = {LINUX_LABEL: LINUX_NAME_HEAD,
26
+ WINDOWS_LABEL: WINDOWS_NAME_HEAD,
27
+ MAC_LABEL: MAC_NAME_HEAD, }
28
+
29
+
30
+ # Determine the fully-qualified binary directory when this module is first imported
31
+ BIN_DIR: str = os.path.join(psdi_data_conversion.__path__[0], "bin")
32
+
33
+
34
+ def get_dist():
35
+ """Determine the current platform
36
+ """
37
+ dist: str | None = None
38
+ for label, name_head in D_DIST_NAME_HEADS.items():
39
+ if sys.platform.startswith(name_head):
40
+ dist = label
41
+ break
42
+ return dist
43
+
44
+
45
+ def _get_local_bin(bin_name: str) -> str | None:
46
+ """Searches for a binary in the user's path
47
+ """
48
+ bin_path = shutil.which(bin_name)
49
+ if bin_path:
50
+ return bin_path
51
+ return None
52
+
53
+
54
+ def get_bin_path(bin_name: str) -> str | None:
55
+ """Gets the path to an appropriate binary for the user's platform, if one exists. Will first search in this
56
+ package, then in the user's $PATH
57
+
58
+ Parameters
59
+ ----------
60
+ bin_name : str
61
+ The unqualified name of the binary
62
+
63
+ Returns
64
+ -------
65
+ str | None
66
+ If an appropriate binary exists for the user's platform, a fully-qualified path to it. Otherwise, None
67
+ """
68
+
69
+ # If DIST is None, then the user's OS/distribution is unsupported
70
+ dist = get_dist()
71
+ if not dist:
72
+ return _get_local_bin(bin_name)
73
+
74
+ bin_path = os.path.join(BIN_DIR, dist, bin_name)
75
+
76
+ # Check if the binary exists in the path for the user's OS/distribution
77
+ if not os.path.isfile(bin_path):
78
+ return _get_local_bin(bin_name)
79
+
80
+ return bin_path
81
+
82
+
83
+ def bin_exists(bin_name: str) -> bool:
84
+ """Gets whether or not a binary of the given name exists for the user's platform
85
+ """
86
+
87
+ return get_bin_path(bin_name) is not None
@@ -0,0 +1,216 @@
1
+ """@file psdi_data_conversion/file_io.py
2
+
3
+ Created 2025-02-11 by Bryan Gillis.
4
+
5
+ Functions and classes related to general filesystem input/output
6
+ """
7
+
8
+ import glob
9
+ import os
10
+ from shutil import copyfile, make_archive, unpack_archive
11
+ from tempfile import TemporaryDirectory
12
+
13
+ from psdi_data_conversion import constants as const
14
+
15
+
16
+ def is_archive(filename: str) -> bool:
17
+ """Uses a file's extension to check if it's an archive or not
18
+ """
19
+ return any([filename.endswith(x) for x in const.L_ALL_ARCHIVE_EXTENSIONS])
20
+
21
+
22
+ def is_supported_archive(filename: str) -> bool:
23
+ """Uses a file's extension to check if it's an archive of a supported type or not
24
+ """
25
+ return any([filename.endswith(x) for x in const.D_SUPPORTED_ARCHIVE_FORMATS])
26
+
27
+
28
+ def split_archive_ext(filename: str) -> tuple[str, str]:
29
+ """Splits a file into a base and an extension, with handling for compound .tar.* extensions
30
+ """
31
+ base, ext = os.path.splitext(filename)
32
+ if base.endswith(const.TAR_EXTENSION):
33
+ base, pre_ext = os.path.splitext(base)
34
+ ext = pre_ext+ext
35
+ return base, ext
36
+
37
+
38
+ def unpack_zip_or_tar(archive_filename: str,
39
+ extract_dir: str = ".") -> list[str]:
40
+ """Unpack a zip or tar archive into a temporary directory and return a list of the extracted files
41
+
42
+ Parameters
43
+ ----------
44
+ archive_filename : str
45
+ Filename of the archive to unpack, either relative or fully-qualified
46
+ extract_dir : str
47
+ The directory to extract the contents of the archive to. By default, the current working directory will
48
+ be used
49
+
50
+ Returns
51
+ -------
52
+ list[str]
53
+ List of the fully-qualified paths to the extracted files. This is determined by checking the directory contents
54
+ before and after extraction, so is NOT thread-safe, unless it is otherwise ensured e.g. by using a unique
55
+ temporary directory for each thread
56
+ """
57
+
58
+ qual_archive_filename = os.path.realpath(archive_filename)
59
+
60
+ # Determine if the file is of a known (un)supported archive type, and if it is, whether it's a zip or tar, and
61
+ # set up arguments appropriately to ensure security
62
+ unpack_kwargs: dict[str, str] = {}
63
+
64
+ if any([qual_archive_filename.endswith(x) for x in const.L_UNSUPPORTED_ARCHIVE_EXTENSIONS]):
65
+
66
+ raise ValueError(f"The archive file '{qual_archive_filename}' is of an unsupported archive type")
67
+
68
+ elif any([qual_archive_filename.endswith(x) for x in const.D_ZIP_FORMATS]):
69
+
70
+ # Zip types don't support the "filter" kwarg, but use similar security measures by default. This may prompt
71
+ # a warning, which can be ignored
72
+ pass
73
+
74
+ elif any([qual_archive_filename.endswith(x) for x in const.D_TAR_FORMATS]):
75
+
76
+ # Tar types need to set up the "filter" argument to ensure no files are unpacked outside the base directory
77
+ unpack_kwargs["filter"] = "data"
78
+
79
+ else:
80
+
81
+ raise ValueError(f"The archive file '{qual_archive_filename}' is not recognised as a valid archive type")
82
+
83
+ # To determine the names of extracted files, we call `os.listdir` before and after unpacking and look for the new
84
+ # elements
85
+
86
+ s_dir_before = set(os.listdir(extract_dir))
87
+ unpack_archive(qual_archive_filename, extract_dir=extract_dir, **unpack_kwargs)
88
+ s_dir_after = set(os.listdir(extract_dir))
89
+
90
+ # Get the new files, and in case they're in a directory, use glob to get their contents
91
+ s_new_files = s_dir_after.difference(s_dir_before)
92
+ l_qual_new_files = [os.path.join(extract_dir, x) for x in s_new_files]
93
+ l_new_globs = [glob.glob(x) if os.path.isfile(x)
94
+ else glob.glob(os.path.join(x, "**"))
95
+ for x in l_qual_new_files]
96
+
97
+ # This gives us a list of globs (individual files are set up as globs for consistency), so we unpack to a single
98
+ # list with nested list comprehension
99
+ l_new_files = [x for l_glob_files in l_new_globs for x in l_glob_files]
100
+
101
+ # Sort the file list for consistency in output
102
+ l_new_files.sort(key=lambda s: s.lower())
103
+
104
+ return l_new_files
105
+
106
+
107
+ def pack_zip_or_tar(archive_filename: str,
108
+ l_filenames: list[str],
109
+ archive_format: str | None = None,
110
+ source_dir: str = ".",
111
+ cleanup=False) -> str:
112
+ """_summary_
113
+
114
+ Parameters
115
+ ----------
116
+ archive_filename : str
117
+ The desired name of the output archive to create, either fully-qualified or relative to the current directory
118
+ l_filenames : list[str]
119
+ List of files to be archived, either fully-qualified or relative to `source_dir`. If provided fully-qualified,
120
+ they will be placed in the root directory of the archive
121
+ source_dir : str, optional
122
+ Path to directory containing the files to be archived (default current directory). If filenames are provided
123
+ fully-qualified, this is ignored
124
+ cleanup : bool, optional
125
+ If True, source files will be deleted after the archive is successfully created
126
+
127
+ Returns
128
+ -------
129
+ str
130
+ The name of the created archive file
131
+
132
+ Raises
133
+ ------
134
+ ValueError
135
+ If `archive_filename` is not of a valid format
136
+ FileNotFoundError
137
+ If one of the listed files does not exist
138
+ """
139
+
140
+ if not archive_format and not is_supported_archive(archive_filename):
141
+ raise ValueError(f"Desired archive filename '{archive_filename}' is not of a supported type. Supported types "
142
+ f"are: {const.D_SUPPORTED_ARCHIVE_FORMATS.keys()}")
143
+
144
+ # It's supported, so determine the specific format, and provide it and the base of the filename in the forms that
145
+ # `make_archive` wants
146
+ if archive_format is None:
147
+ for _ext, _format in const.D_SUPPORTED_ARCHIVE_FORMATS.items():
148
+ if archive_filename.endswith(_ext):
149
+ archive_format = _format
150
+ archive_root_filename = split_archive_ext(archive_filename)[0]
151
+ break
152
+ # Check that the format was found
153
+ if archive_format is None:
154
+ raise AssertionError("Invalid execution path entered - filename wasn't found with a valid archive "
155
+ "extension, but it did pass the `is_supported_archive` check")
156
+ else:
157
+ archive_root_filename = archive_filename
158
+
159
+ # Check that the provided archive format is valid, and add the appropriate extension to the filename
160
+ archive_extension: str | None = None
161
+ for _ext, _format in const.D_SUPPORTED_ARCHIVE_FORMATS.items():
162
+ if archive_format == _ext:
163
+ # Extension was provided instead of the format; we can work with that
164
+ archive_extension = archive_format
165
+ archive_format = _format
166
+ break
167
+ elif archive_format == _format:
168
+ archive_extension = _ext
169
+ break
170
+ if archive_extension is None:
171
+ raise ValueError(f"Invalid archive format '{archive_format}'. Valid formats are: "
172
+ f"{const.D_SUPPORTED_ARCHIVE_FORMATS.keys()}")
173
+
174
+ # Check if the root filename already contained the extension so we don't add it again, and strip it from the
175
+ # root
176
+ if archive_root_filename.endswith(archive_extension):
177
+ archive_filename = archive_root_filename
178
+ archive_root_filename = archive_root_filename[:-len(archive_extension)]
179
+ else:
180
+ archive_filename = archive_root_filename+archive_extension
181
+
182
+ with TemporaryDirectory() as root_dir:
183
+
184
+ # Copy all files from the source dir to the root dir, which is what will be packed
185
+
186
+ l_files_to_cleanup: list[str] = []
187
+
188
+ for filename in l_filenames:
189
+
190
+ # Check if the filename is fully-qualified, and copy it from wherever it's found
191
+ if os.path.isfile(filename):
192
+ copyfile(filename, os.path.join(root_dir, os.path.basename(filename)))
193
+ l_files_to_cleanup.append(filename)
194
+ continue
195
+
196
+ qualified_filename = os.path.join(source_dir, filename)
197
+ if os.path.isfile(qualified_filename):
198
+ copyfile(qualified_filename, os.path.join(root_dir, os.path.basename(filename)))
199
+ l_files_to_cleanup.append(qualified_filename)
200
+ else:
201
+ raise FileNotFoundError(f"File '{filename}' could not be found, either fully-qualified or relative to "
202
+ f"{source_dir}")
203
+
204
+ make_archive(archive_root_filename,
205
+ format=archive_format,
206
+ root_dir=root_dir)
207
+
208
+ if cleanup:
209
+ for filename in l_files_to_cleanup:
210
+ try:
211
+ os.remove(filename)
212
+ except Exception:
213
+ pass
214
+
215
+ # Return the name of the created file
216
+ return archive_filename
@@ -0,0 +1,241 @@
1
+ """@file psdi-data-conversion/psdi_data_conversion/logging.py
2
+
3
+ Created 2024-12-09 by Bryan Gillis.
4
+
5
+ Functions and classes related to logging and other messaging for the user
6
+ """
7
+
8
+ from datetime import datetime
9
+ import logging
10
+ import os
11
+ import re
12
+ import sys
13
+
14
+ from psdi_data_conversion import constants as const
15
+
16
+ D_LOG_LEVELS = {"notset": logging.NOTSET,
17
+ "debug": logging.DEBUG,
18
+ "info": logging.INFO,
19
+ "warn": logging.WARNING,
20
+ "warning": logging.WARNING,
21
+ "error": logging.ERROR,
22
+ "critical": logging.CRITICAL,
23
+ "fatal": logging.CRITICAL}
24
+
25
+
26
+ def get_log_level_from_str(log_level_str: str | None) -> int:
27
+ """Gets a log level, as one of the literal ints defined in the `logging` module, from the string representation
28
+ of it.
29
+ """
30
+
31
+ if not log_level_str:
32
+ return logging.NOTSET
33
+ try:
34
+ return D_LOG_LEVELS[log_level_str.lower()]
35
+ except KeyError:
36
+ raise ValueError(f"Unrecognised logging level: '{log_level_str}'. Allowed levels are (case-insensitive): "
37
+ f"{list(D_LOG_LEVELS.keys())}")
38
+
39
+
40
+ def set_up_data_conversion_logger(name=const.LOCAL_LOGGER_NAME,
41
+ local_log_file=None,
42
+ local_logger_level=const.DEFAULT_LOCAL_LOGGER_LEVEL,
43
+ local_logger_raw_output=False,
44
+ extra_loggers=None,
45
+ suppress_global_handler=False,
46
+ stdout_output_level=None,
47
+ mode="a"):
48
+ """Registers a logger with the provided name and sets it up with the desired options
49
+
50
+ Parameters
51
+ ----------
52
+ name : str | None
53
+ The desired logging channel for this logger. Should be a period-separated string such as "input.files" etc.
54
+ By default "data-conversion"
55
+ local_log_file : str | None
56
+ The file to log to for local logs. If None, will not set up local logging
57
+ local_logger_level : int
58
+ The logging level to set up for the local logger, using one of the levels defined in the base Python `logging`
59
+ module, by default `logging.INFO`
60
+ local_logger_raw_output : bool
61
+ If set to True, output to the local logger will be logged with no formatting, exactly as input. Otherwise
62
+ (default) it will include a timestamp and indicate the logging level
63
+ extra_loggers : Iterable[Tuple[str, int, bool, str]]
64
+ A list of one or more tuples of the format (`filename`, `level`, `raw_output`, `mode`) specifying these
65
+ options (defined the same as for the local logger) for one or more additional logging channels.
66
+ suppress_global_handler : bool
67
+ If set to True, will not add the handler which sends all logs to the global log file, default False
68
+ stdout_output_level : int | None
69
+ The logging level (using one of the levels defined in the base Python `logging` module) at and above which to
70
+ log output to stdout. If None (default), nothing will be sent to stdout
71
+ mode : str
72
+ Either "a" for append to existing log or "w" to overwrite existing log, default "a"
73
+
74
+ Returns
75
+ -------
76
+ Logger
77
+ """
78
+
79
+ # Get a logger using the inherited method before setting up any file handling for it
80
+ logger = logging.Logger(name)
81
+
82
+ if extra_loggers is None:
83
+ extra_loggers = []
84
+
85
+ # Set up filehandlers for the global and local logging
86
+ for (filename, level,
87
+ raw_output, write_mode) in ((const.GLOBAL_LOG_FILENAME, const.GLOBAL_LOGGER_LEVEL, False, "a"),
88
+ (local_log_file, local_logger_level, local_logger_raw_output, mode),
89
+ *extra_loggers):
90
+ if level is None or (suppress_global_handler and filename == const.GLOBAL_LOG_FILENAME):
91
+ continue
92
+ _add_filehandler_to_logger(logger, filename, level, raw_output, write_mode)
93
+
94
+ # Set up stdout output if desired
95
+ if stdout_output_level is not None:
96
+
97
+ stream_handler = logging.StreamHandler(sys.stdout)
98
+
99
+ # Check if stdout output is already handled, and update that handler if so
100
+ handler_already_present = False
101
+ for handler in logger.handlers:
102
+ if (isinstance(handler, logging.StreamHandler) and handler.stream == sys.stdout):
103
+ handler_already_present = True
104
+ stream_handler = handler
105
+ break
106
+
107
+ if not handler_already_present:
108
+ logger.addHandler(stream_handler)
109
+
110
+ stream_handler.setLevel(stdout_output_level)
111
+ if stdout_output_level < logger.level or logger.level == logging.NOTSET:
112
+ logger.setLevel(stdout_output_level)
113
+
114
+ stream_handler.setFormatter(logging.Formatter(const.LOG_FORMAT, datefmt=const.TIMESTAMP_FORMAT))
115
+
116
+ return logger
117
+
118
+
119
+ def _add_filehandler_to_logger(logger, filename, level, raw_output, mode):
120
+ """Private function to add a file handler to a logger only if the logger doesn't already have a handler for that
121
+ file, and set the logging level for the handler
122
+ """
123
+ # Skip if filename is None
124
+ if filename is None:
125
+ return
126
+
127
+ file_handler = logging.FileHandler(filename, mode)
128
+
129
+ # Check if the file to log to is already in the logger's filehandlers
130
+ handler_already_present = False
131
+ for handler in logger.handlers:
132
+ if (isinstance(handler, logging.FileHandler) and
133
+ handler.baseFilename == os.path.abspath(filename)):
134
+ handler_already_present = True
135
+ file_handler = handler
136
+ break
137
+
138
+ # Add a FileHandler for the file if it's not already present, make sure the path to the log file exists,
139
+ # and set the logging level
140
+ if not handler_already_present:
141
+ filename_loc = os.path.split(filename)[0]
142
+ if filename_loc != "":
143
+ os.makedirs(filename_loc, exist_ok=True)
144
+
145
+ file_handler = logging.FileHandler(filename)
146
+
147
+ logger.addHandler(file_handler)
148
+
149
+ # Set or update the logging level and formatter for the handler
150
+ if level is not None:
151
+ file_handler.setLevel(level)
152
+ if level < logger.level or logger.level == logging.NOTSET:
153
+ logger.setLevel(level)
154
+ if not raw_output:
155
+ file_handler.setFormatter(logging.Formatter(const.LOG_FORMAT, datefmt=const.TIMESTAMP_FORMAT))
156
+
157
+ return
158
+
159
+
160
+ def get_date():
161
+ """Retrieve current date as a string
162
+
163
+ Returns
164
+ -------
165
+ str
166
+ Current date in the format YYYY-MM-DD
167
+ """
168
+ today = datetime.today()
169
+ return str(today.year) + '-' + format(today.month) + '-' + format(today.day)
170
+
171
+
172
+ def get_time():
173
+ """Retrieve current time as a string
174
+
175
+ Returns
176
+ -------
177
+ str
178
+ Current time in the format HH:MM:SS
179
+ """
180
+ today = datetime.today()
181
+ return format(today.hour) + ':' + format(today.minute) + ':' + format(today.second)
182
+
183
+
184
+ def get_date_time():
185
+ """Retrieve current date and time as a string
186
+
187
+ Returns
188
+ -------
189
+ str
190
+ Current date and time in the format YYYY-MM-DD HH:MM:SS
191
+ """
192
+ return get_date() + ' ' + get_time()
193
+
194
+
195
+ def format(time):
196
+ """Ensure that an element of date or time (month, day, hours, minutes or seconds) always has two digits.
197
+
198
+ Parameters
199
+ ----------
200
+ time : str or int
201
+ Digit(s) indicating date or month
202
+
203
+ Returns
204
+ -------
205
+ str
206
+ 2-digit value indicating date or month
207
+ """
208
+ num = str(time)
209
+
210
+ if len(num) == 1:
211
+ return '0' + num
212
+ else:
213
+ return num
214
+
215
+
216
+ def string_with_placeholders_matches(test_pattern: str, parent_str: str) -> bool:
217
+ """An advanced version of "`test_pattern` in `parent_str`" for checking if a substring is in a containing string,
218
+ which allows for `test_pattern` to contain placeholders (e.g. a string like "The file name is: {file}".).
219
+
220
+ The test here splits `test_pattern` up into segments which exclude the placeholders, and checks that all segments
221
+ are in `parent_string`. As such, it has the potential to return false positives in the segments are all in the
222
+ parent string but split far apart or out of order, so this method should not be used if it is critical that false
223
+ positives be avoided.
224
+
225
+ Parameters
226
+ ----------
227
+ test_pattern : str
228
+ The pattern to check if it's contained in `parent_str`
229
+ parent_str : str
230
+ The string to search in for `test_pattern`
231
+
232
+ Returns
233
+ -------
234
+ bool
235
+ True if `test_pattern` appears to be in `parent_str` with some placeholders filled in, False otherwise
236
+ """
237
+
238
+ l_test_segments = re.split(r"\{.*?\}", test_pattern)
239
+ all_segments_in_parent = all([s in parent_str for s in l_test_segments])
240
+
241
+ return all_segments_in_parent