psdi-data-conversion 0.0.23__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- psdi_data_conversion/__init__.py +11 -0
- psdi_data_conversion/app.py +242 -0
- psdi_data_conversion/bin/linux/atomsk +0 -0
- psdi_data_conversion/bin/linux/c2x +0 -0
- psdi_data_conversion/bin/mac/atomsk +0 -0
- psdi_data_conversion/bin/mac/c2x +0 -0
- psdi_data_conversion/constants.py +185 -0
- psdi_data_conversion/converter.py +459 -0
- psdi_data_conversion/converters/__init__.py +6 -0
- psdi_data_conversion/converters/atomsk.py +32 -0
- psdi_data_conversion/converters/base.py +702 -0
- psdi_data_conversion/converters/c2x.py +32 -0
- psdi_data_conversion/converters/openbabel.py +239 -0
- psdi_data_conversion/database.py +1064 -0
- psdi_data_conversion/dist.py +87 -0
- psdi_data_conversion/file_io.py +216 -0
- psdi_data_conversion/log_utility.py +241 -0
- psdi_data_conversion/main.py +776 -0
- psdi_data_conversion/scripts/atomsk.sh +32 -0
- psdi_data_conversion/scripts/c2x.sh +26 -0
- psdi_data_conversion/security.py +38 -0
- psdi_data_conversion/static/content/accessibility.htm +254 -0
- psdi_data_conversion/static/content/convert.htm +121 -0
- psdi_data_conversion/static/content/convertato.htm +65 -0
- psdi_data_conversion/static/content/convertc2x.htm +65 -0
- psdi_data_conversion/static/content/documentation.htm +94 -0
- psdi_data_conversion/static/content/feedback.htm +53 -0
- psdi_data_conversion/static/content/header-links.html +8 -0
- psdi_data_conversion/static/content/index-versions/header-links.html +8 -0
- psdi_data_conversion/static/content/index-versions/psdi-common-footer.html +99 -0
- psdi_data_conversion/static/content/index-versions/psdi-common-header.html +28 -0
- psdi_data_conversion/static/content/psdi-common-footer.html +99 -0
- psdi_data_conversion/static/content/psdi-common-header.html +28 -0
- psdi_data_conversion/static/content/report.htm +103 -0
- psdi_data_conversion/static/data/data.json +143940 -0
- psdi_data_conversion/static/img/colormode-toggle-dm.svg +3 -0
- psdi_data_conversion/static/img/colormode-toggle-lm.svg +3 -0
- psdi_data_conversion/static/img/psdi-icon-dark.svg +136 -0
- psdi_data_conversion/static/img/psdi-icon-light.svg +208 -0
- psdi_data_conversion/static/img/psdi-logo-darktext.png +0 -0
- psdi_data_conversion/static/img/psdi-logo-lighttext.png +0 -0
- psdi_data_conversion/static/img/social-logo-bluesky-black.svg +4 -0
- psdi_data_conversion/static/img/social-logo-bluesky-white.svg +4 -0
- psdi_data_conversion/static/img/social-logo-instagram-black.svg +1 -0
- psdi_data_conversion/static/img/social-logo-instagram-white.svg +1 -0
- psdi_data_conversion/static/img/social-logo-linkedin-black.png +0 -0
- psdi_data_conversion/static/img/social-logo-linkedin-white.png +0 -0
- psdi_data_conversion/static/img/social-logo-mastodon-black.svg +4 -0
- psdi_data_conversion/static/img/social-logo-mastodon-white.svg +4 -0
- psdi_data_conversion/static/img/social-logo-x-black.svg +3 -0
- psdi_data_conversion/static/img/social-logo-x-white.svg +3 -0
- psdi_data_conversion/static/img/social-logo-youtube-black.png +0 -0
- psdi_data_conversion/static/img/social-logo-youtube-white.png +0 -0
- psdi_data_conversion/static/img/ukri-epsr-logo-darktext.png +0 -0
- psdi_data_conversion/static/img/ukri-epsr-logo-lighttext.png +0 -0
- psdi_data_conversion/static/img/ukri-logo-darktext.png +0 -0
- psdi_data_conversion/static/img/ukri-logo-lighttext.png +0 -0
- psdi_data_conversion/static/javascript/accessibility.js +196 -0
- psdi_data_conversion/static/javascript/common.js +42 -0
- psdi_data_conversion/static/javascript/convert.js +296 -0
- psdi_data_conversion/static/javascript/convert_common.js +252 -0
- psdi_data_conversion/static/javascript/convertato.js +107 -0
- psdi_data_conversion/static/javascript/convertc2x.js +107 -0
- psdi_data_conversion/static/javascript/data.js +176 -0
- psdi_data_conversion/static/javascript/format.js +611 -0
- psdi_data_conversion/static/javascript/load_accessibility.js +89 -0
- psdi_data_conversion/static/javascript/psdi-common.js +177 -0
- psdi_data_conversion/static/javascript/report.js +381 -0
- psdi_data_conversion/static/styles/format.css +147 -0
- psdi_data_conversion/static/styles/psdi-common.css +705 -0
- psdi_data_conversion/templates/index.htm +114 -0
- psdi_data_conversion/testing/__init__.py +5 -0
- psdi_data_conversion/testing/constants.py +12 -0
- psdi_data_conversion/testing/conversion_callbacks.py +394 -0
- psdi_data_conversion/testing/conversion_test_specs.py +208 -0
- psdi_data_conversion/testing/utils.py +522 -0
- psdi_data_conversion-0.0.23.dist-info/METADATA +663 -0
- psdi_data_conversion-0.0.23.dist-info/RECORD +81 -0
- psdi_data_conversion-0.0.23.dist-info/WHEEL +4 -0
- psdi_data_conversion-0.0.23.dist-info/entry_points.txt +2 -0
- psdi_data_conversion-0.0.23.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,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
|