PythiaLabelGenerator 1.0.0__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.
@@ -0,0 +1,15 @@
1
+ import importlib.metadata
2
+
3
+ try:
4
+ __version__ = importlib.metadata.distribution(__name__).version
5
+ except Exception:
6
+ __version__ = "unknown"
7
+
8
+ # Required if the package was installed via PyPi...
9
+ if __version__ == "unknown":
10
+ try:
11
+ from importlib.metadata import version
12
+
13
+ __version__ = version("PythiaLabelGenerator")
14
+ except Exception:
15
+ pass
@@ -0,0 +1,142 @@
1
+ import pathlib
2
+ import subprocess
3
+ from typing import Optional
4
+
5
+ from pypythia.custom_types import DataType
6
+
7
+ from labelgenerator.iqtree_parser import get_iqtree_results
8
+
9
+
10
+ def _iqtree_results_exist_and_done(prefix: pathlib.Path) -> bool:
11
+ iqtree_file = pathlib.Path(f"{prefix}.iqtree")
12
+ logfile = pathlib.Path(f"{prefix}.log")
13
+
14
+ if not iqtree_file.exists() or not logfile.exists():
15
+ return False
16
+
17
+ return (
18
+ "Total wall-clock time used" in iqtree_file.read_text()
19
+ and "Date and Time:" in logfile.read_text()
20
+ )
21
+
22
+
23
+ def get_iqtree_model(data_type: DataType) -> str:
24
+ """
25
+ Get the IQ-TREE model for the given data type.
26
+
27
+ Args:
28
+ data_type (DataType): The data type to get the model for.
29
+
30
+ Returns:
31
+ str: The IQ-TREE model for the given data type.
32
+ For DNA data: GTR+G4+FO
33
+ For AA data: LG+G4+FO
34
+ For morphological data: MK
35
+
36
+ """
37
+ return {
38
+ DataType.DNA: "GTR+G4+FO",
39
+ DataType.AA: "LG+G4+FO",
40
+ DataType.MORPH: "MK",
41
+ }[data_type]
42
+
43
+
44
+ def run_statstests(
45
+ msa: pathlib.Path,
46
+ ml_trees: pathlib.Path,
47
+ best_tree: pathlib.Path,
48
+ iqtree: pathlib.Path,
49
+ model: str,
50
+ prefix: pathlib.Path,
51
+ seed: int = 0,
52
+ threads: Optional[int] = None,
53
+ is_morph: bool = False,
54
+ redo: bool = False,
55
+ ) -> None:
56
+ """
57
+ Run IQ-TREE statistical tests on the given set of ML trees. Will run all available tests (bp-RELL, KH (+weighted), SH (+weighted), ELW, AU) using 10,000 RELL bootstrap replicates.
58
+
59
+ Args:
60
+ msa (pathlib.Path): Path to the MSA file for which the ML trees were inferred.
61
+ ml_trees (pathlib.Path): Path to the file containing the ML trees.
62
+ best_tree (pathlib.Path): Path to the best ML tree.
63
+ iqtree (pathlib.Path): Path to the IQ-TREE executable.
64
+ model (str): The model to use for IQ-TREE.
65
+ prefix (pathlib.Path): Prefix for the output files.
66
+ seed (int): Seed for the random number generator. Defaults to 0.
67
+ threads (Optional[int]): Number of threads to use for IQ-TREE. Defaults to None. In this case, uses the IQ-TREE autoconfiguration.
68
+ is_morph (bool): Whether the data type is morphological. Defaults to False.
69
+ redo (bool): Whether to redo the analysis even if the results already exist. Defaults to False.
70
+ """
71
+ if not redo and _iqtree_results_exist_and_done(prefix):
72
+ return
73
+
74
+ cmd = [
75
+ iqtree,
76
+ "-s",
77
+ msa,
78
+ "-m",
79
+ model,
80
+ "-pre",
81
+ prefix,
82
+ "-z",
83
+ ml_trees,
84
+ "-treediff",
85
+ "-te",
86
+ best_tree,
87
+ "-n",
88
+ 0,
89
+ "-zb",
90
+ 10000,
91
+ "-zw",
92
+ "-au",
93
+ "-seed",
94
+ seed,
95
+ ]
96
+
97
+ if is_morph:
98
+ cmd.extend(["-st", "MORPH"])
99
+
100
+ if threads is not None:
101
+ cmd.extend(["-nt", threads])
102
+
103
+ if redo:
104
+ cmd.append("-redo")
105
+
106
+ try:
107
+ subprocess.check_output(list(map(str, cmd)), encoding="utf-8")
108
+ except subprocess.CalledProcessError as e:
109
+ raise RuntimeError(f"Running IQ-TREE command failed: {e.stdout}")
110
+ except Exception as e:
111
+ raise RuntimeError("Running IQ-TREE command failed.") from e
112
+
113
+
114
+ def filter_plausible_trees(
115
+ ml_trees: pathlib.Path,
116
+ iqtree_results: pathlib.Path,
117
+ plausible_ml_trees: pathlib.Path,
118
+ ) -> None:
119
+ """
120
+ Filter the plausible ML trees based on the IQ-TREE results. A tree is plausible if all statistical tests (bp-RELL, KH (+weighted), SH (+weighted), ELW, AU) are significant.
121
+
122
+ Args:
123
+ ml_trees (pathlib.Path): Path to the file containing the ML trees the IQ-TREE were performed for.
124
+ iqtree_results (pathlib.Path): Path to the IQ-TREE results file.
125
+ plausible_ml_trees (pathlib.Path): Path to the file to write the plausible ML trees to.
126
+
127
+ Raises:
128
+ ValueError: If the number of IQ-TREE results does not match the number of ML trees.
129
+
130
+ """
131
+ iqtree_results = get_iqtree_results(iqtree_results)
132
+ newick_trees = [t.strip() for t in ml_trees.read_text().splitlines()]
133
+
134
+ if not len(iqtree_results) == len(newick_trees):
135
+ raise ValueError(
136
+ "Number of IQ-TREE results does not match the number of ML trees."
137
+ )
138
+
139
+ plausible_indices = [i for i, x in enumerate(iqtree_results) if x["plausible"]]
140
+ plausible_trees = [newick_trees[i] for i in plausible_indices]
141
+
142
+ plausible_ml_trees.write_text("\n".join(plausible_trees))
@@ -0,0 +1,183 @@
1
+ import pathlib
2
+
3
+ import regex
4
+
5
+ # define some regex stuff
6
+ blanks = r"\s+" # matches >=1 subsequent whitespace characters
7
+ sign = r"[-+]?" # contains either a '-' or a '+' symbol or none of both
8
+ # matches ints or floats of forms '1.105' or '1.105e-5' or '1.105e5' or '1.105e+5'
9
+ float_re = r"\d+(?:\.\d+)?(?:[e][-+]?\d+)?"
10
+
11
+ tree_id_re = r"\d+" # tree ID is an int
12
+ llh_re = rf"{sign}{float_re}" # likelihood is a signed floating point
13
+ deltaL_re = rf"{sign}{float_re}" # deltaL is a signed floating point
14
+ # test result entry is of form '0.123 +'
15
+ test_result_re = rf"{float_re}{blanks}{sign}"
16
+
17
+ stat_test_name = r"[a-zA-Z-]+"
18
+
19
+ # table header is of form:
20
+ # Tree logL deltaL bp-RELL p-KH p-SH p-WKH p-WSH c-ELW p-AU
21
+ table_header = rf"Tree{blanks}logL{blanks}deltaL{blanks}(?:({stat_test_name})\s*)*"
22
+ table_header_re = regex.compile(table_header)
23
+
24
+ # a table entry in the .iqtree file looks for example like this:
25
+ # 5 -5708.931281 1.7785e-06 0.0051 - 0.498 + 0.987 + 0.498 + 0.987 + 0.05 + 0.453 +
26
+ table_entry = rf"({tree_id_re}){blanks}({llh_re}){blanks}({deltaL_re}){blanks}(?:({test_result_re})\s*)*"
27
+ table_entry_re = regex.compile(table_entry)
28
+
29
+ # if there is only a single plausible tree
30
+ # the line will look like this:
31
+ # 1 -88.9544627 0
32
+ table_entry_single_plausible_tree = (
33
+ rf"({tree_id_re}){blanks}({llh_re}){blanks}({deltaL_re})\s*"
34
+ )
35
+ table_entry_single_plausible_tree_re = regex.compile(table_entry_single_plausible_tree)
36
+
37
+ START_STRING = "USER TREES"
38
+ END_STRING = "TIME STAMP"
39
+
40
+ TEST_NAMES = ["bp-RELL", "p-KH", "p-SH", "p-WKH", "p-WSH", "c-ELW", "p-AU"]
41
+
42
+
43
+ def get_relevant_section(input_file: pathlib.Path) -> list[str]:
44
+ """
45
+ Returns the section between the START_STRING and END_STRING in the given file.
46
+
47
+ Args:
48
+ input_file (pathlib.Path): Path to the .iqtree file to extract the section from.
49
+
50
+ Returns:
51
+ A list of strings, each representing a line of the relevant section.
52
+
53
+ Raises:
54
+ ValueError: If the section between START_STRING and END_STRING is empty.
55
+ """
56
+ content = input_file.read_text().splitlines()
57
+
58
+ # now let's find the relevant lines
59
+ # the relevant lines are only between the start and end string
60
+ start = 0
61
+ end = 0
62
+
63
+ for i, line in enumerate(content):
64
+ if START_STRING in line:
65
+ start = i
66
+ if END_STRING in line:
67
+ end = i
68
+
69
+ if start == end:
70
+ raise ValueError(
71
+ f"The section between START_STRING {START_STRING} and END_STRING {END_STRING} is empty. Please check the input file {input_file}."
72
+ )
73
+
74
+ return content[start:end]
75
+
76
+
77
+ def _get_default_entry() -> dict:
78
+ """
79
+ Returns a default entry for a single plausible tree.
80
+
81
+ Returns:
82
+ A dict containing the default entry for a single plausible tree.
83
+ """
84
+ return {
85
+ "plausible": 1,
86
+ "tests": {
87
+ "bp-RELL": {"score": 1, "significant": True},
88
+ "p-KH": {"score": 1, "significant": True},
89
+ "p-SH": {"score": 1, "significant": True},
90
+ "p-WKH": {"score": 1, "significant": True},
91
+ "p-WSH": {"score": 1, "significant": True},
92
+ "c-ELW": {"score": 1, "significant": True},
93
+ "p-AU": {"score": 1, "significant": True},
94
+ },
95
+ }
96
+
97
+
98
+ def _regex_group_to_test_results(raw_results: list[str]) -> dict:
99
+ assert len(TEST_NAMES) == len(raw_results)
100
+
101
+ data = {"tests": {}}
102
+ num_passed = 0
103
+
104
+ for i, test in enumerate(TEST_NAMES):
105
+ test_result = raw_results[i]
106
+ score, significant = test_result.split(" ")
107
+ score = score.strip()
108
+ significant = significant.strip()
109
+ data["tests"][test] = {}
110
+ data["tests"][test]["score"] = float(score)
111
+ data["tests"][test]["significant"] = True if significant == "+" else False
112
+
113
+ if data["tests"][test]["significant"]:
114
+ num_passed += 1
115
+
116
+ data["plausible"] = num_passed == len(data["tests"].keys())
117
+ return data
118
+
119
+
120
+ def get_cleaned_table_entries(table_section: list[str]) -> list[dict]:
121
+ """
122
+ Returns a list of dicts, each dict contains the iqtree test results for the respective tree.
123
+
124
+ Args:
125
+ table_section (list[str]): A list of strings, each representing a line of the relevant section.
126
+
127
+ Returns:
128
+ A list of dicts. Each dict contains the tree_id, llh, deltaL and all results of the performed
129
+ iqtree tests.
130
+
131
+ """
132
+ entries = []
133
+ for line in table_section:
134
+ line = line.strip()
135
+ # match the line against the regex defined above for a table entry
136
+ m = regex.match(table_entry_re, line)
137
+
138
+ # and match the line against the regex for a table entry in case of a single plausible tree
139
+ m_single_tree = regex.match(table_entry_single_plausible_tree_re, line)
140
+
141
+ if m:
142
+ # transform the raw results to a python dict
143
+ entry = _regex_group_to_test_results(m.captures(4))
144
+ entries.append(entry)
145
+ elif m_single_tree:
146
+ # if a match for a truncated table entry was found: we only have a single plausible tree
147
+ # => add the entry manually
148
+ entry = _get_default_entry()
149
+ entries.append(entry)
150
+ elif "= tree" in line:
151
+ # indicates that a tree is identical to one seen before
152
+ # => duplicate the results of this tree
153
+ _, id_of_identical_tree = line.rsplit(" ", 1)
154
+ id_of_identical_tree = int(id_of_identical_tree)
155
+
156
+ # IQ-Tree reports the results 1-indexed
157
+ # => to get the correct results we need to subtract one and access the entries
158
+ entry = entries[id_of_identical_tree - 1].copy()
159
+ entries.append(entry)
160
+
161
+ if not entries:
162
+ raise ValueError(
163
+ "No line in the given section matches the regex. Compare the regex and the given section. "
164
+ "Maybe the format has changed."
165
+ )
166
+
167
+ return entries
168
+
169
+
170
+ def get_iqtree_results(iqtree_file: pathlib.Path) -> list[dict]:
171
+ """
172
+ Returns a list of dicts, each dict contains the iqtree test results for the respective tree.
173
+
174
+ Args:
175
+ iqtree_file (pathlib.Path): Path to the .iqtree file to extract the results from.
176
+
177
+ Returns:
178
+ A list of dicts. Each dict contains the tree_id, llh, deltaL and all results of the performed
179
+ iqtree tests.
180
+ """
181
+ section = get_relevant_section(iqtree_file)
182
+ entries = get_cleaned_table_entries(section)
183
+ return entries
@@ -0,0 +1,231 @@
1
+ import pathlib
2
+ from typing import Optional
3
+
4
+ from pypythia.msa import parse_msa
5
+
6
+ from labelgenerator.iqtree import (
7
+ filter_plausible_trees,
8
+ get_iqtree_model,
9
+ run_statstests,
10
+ )
11
+ from labelgenerator.logger import log_runtime_information
12
+ from labelgenerator.raxmlng import infer_ml_trees, rf_distance
13
+
14
+
15
+ def get_label(
16
+ n_all: int,
17
+ rf_all: float,
18
+ n_unique_all: int,
19
+ n_plausible: int,
20
+ rf_plausible: float,
21
+ n_unique_plausible: int,
22
+ ) -> float:
23
+ r"""
24
+ Compute the ground truth difficulty label for the given input values.
25
+
26
+ The ground-truth difficulty is computed according to our definition published in [Haag _et al._ (2022)](https://doi.org/10.1093/molbev/msac254):
27
+
28
+ Let $N_{\text{all}}$ be the number of inferred ML trees.
29
+ We first compute the average pairwise relative Robinson-Foulds (RF) distance between all trees ($RF_{\text{all}}$), as
30
+ well as the number of unique tree topologies among the inferred trees ($N^*_{\text{all}}$).
31
+ We filter the inferred trees using likelihood-based statistical tests to obtain the set of $N_{\text{pl}}$ _plausible
32
+ trees_.
33
+ We again compute the average pairwise RF distance between the plausible trees ($RF_{\text{pl}}$) and the number of
34
+ unique tree topologies among the plausible trees ($N^*_{\text{pl}}$).
35
+
36
+ The difficulty is then computed as follows:
37
+
38
+ $$
39
+ \text{difficulty} = \frac{1}{5} \cdot \bigg[ RF_{\text{all}} + RF_{\text{pl}}
40
+ + \frac{N^*_{\text{all}}}{N_{\text{all}}} + \frac{N^*_{\text{pl}}}{N_{\text{pl}}}
41
+ + \left( 1 - \frac{N_{\text{pl}}}{N_{\text{all}}} \right) \bigg]
42
+ $$
43
+
44
+ Args:
45
+ n_all (int): Number of inferred ML trees.
46
+ rf_all (float): Average pairwise RF distance between all trees.
47
+ n_unique_all (int): Number of unique tree topologies among the inferred trees.
48
+ n_plausible (int): Number of plausible trees.
49
+ rf_plausible (float): Average pairwise RF distance between the plausible trees.
50
+ n_unique_plausible (int): Number of unique tree topologies among the plausible trees.
51
+
52
+ Returns:
53
+ float: Ground truth difficulty label.
54
+
55
+ Raises:
56
+ ValueError: If the input values are not within the expected range.
57
+ - Number of unique trees is higher than the total number of trees.
58
+ - Number of unique plausible trees is higher than the number of plausible trees.
59
+ - Number of plausible trees is higher than the total number of trees.
60
+ - RF distance for all trees is not between 0 and 1.
61
+ - RF distance for plausible trees is not between 0 and 1.
62
+
63
+ """
64
+ if n_unique_all > n_all:
65
+ raise ValueError(
66
+ "Number of unique trees cannot be higher than the total number of trees."
67
+ )
68
+ if n_unique_plausible > n_plausible:
69
+ raise ValueError(
70
+ "Number of unique plausible trees cannot be higher than the number of plausible trees."
71
+ )
72
+ if n_plausible > n_all:
73
+ raise ValueError(
74
+ "Number of plausible trees cannot be higher than the total number of trees."
75
+ )
76
+ if not 0 <= rf_all <= 1:
77
+ raise ValueError("RF distance for all trees must be between 0 and 1.")
78
+ if not 0 <= rf_plausible <= 1:
79
+ raise ValueError("RF distance for plausible trees must be between 0 and 1.")
80
+
81
+ proportion_unique_all = n_unique_all / n_all
82
+ proportion_unique_plausible = n_unique_plausible / n_plausible
83
+ proportion_plausible = n_plausible / n_all
84
+
85
+ total = (
86
+ rf_all
87
+ + proportion_unique_all
88
+ + rf_plausible
89
+ + proportion_unique_plausible
90
+ + (1 - proportion_plausible)
91
+ )
92
+ label = total / 5
93
+
94
+ eps = 1e-9
95
+ assert -eps <= label <= 1 + eps, (
96
+ f"Label {label} is not between 0 and 1. Check the input values."
97
+ )
98
+
99
+ return label
100
+
101
+
102
+ def compute_label(
103
+ msa_file: pathlib.Path,
104
+ raxmlng: pathlib.Path,
105
+ iqtree: pathlib.Path,
106
+ prefix: pathlib.Path,
107
+ model: Optional[str] = None,
108
+ n_trees: int = 100,
109
+ seed: int = 0,
110
+ threads: Optional[int] = None,
111
+ redo: bool = False,
112
+ log_info: bool = True,
113
+ ) -> float:
114
+ """
115
+ Compute the ground truth difficulty label for the given input MSA by inferring ML trees and running statistical tests.
116
+ See `labelgenerator.label.get_label` for the definition of the ground truth difficulty.
117
+
118
+
119
+ Args:
120
+ msa_file (pathlib.Path): Path to the MSA file to compute the label for. Can be either in FASTA or PHYLIP format.
121
+ raxmlng (pathlib.Path): Path to the RAxML-NG executable.
122
+ iqtree (pathlib.Path): Path to the IQ-TREE executable.
123
+ prefix (pathlib.Path): Prefix for the output files.
124
+ model (str, optional): Substitution model to use for the ML tree inference. Defaults to None. In this case, the
125
+ model is inferred from the MSA based on the data type (`GTR+G` for DNA, `LG+G` for AA, and `MULTIx_GTR` for morphological data).
126
+ n_trees (int, optional): Number of ML trees to infer. Defaults to 100. Please note that the computed label is only comparable to
127
+ Pythia predictions if 100 trees are inferred.
128
+ seed (int, optional): Seed for the random number generator. Defaults to 0.
129
+ threads (int, optional): Number of threads to use for the ML tree inference. Defaults to None. In this case, the RAxML-NG and IQ-TREE autoconfigs are used.
130
+ redo (bool, optional): If True, the computations are redone even if the output files already exist. Defaults to False.
131
+ log_info (bool, optional): If True, runtime information is logged. Defaults to True.
132
+
133
+ Returns:
134
+ float: The ground truth difficulty label for the given MSA.
135
+
136
+ """
137
+ msa_obj = parse_msa(msa_file)
138
+ model = model or msa_obj.get_raxmlng_model()
139
+
140
+ # 1. Infer 100 ML trees for the given MSA using RAxML-NG
141
+ if log_info:
142
+ log_runtime_information(f"Inferring {n_trees} ML trees using RAxML-NG.")
143
+
144
+ infer_ml_trees(
145
+ msa=msa_file,
146
+ raxmlng=raxmlng,
147
+ model=model,
148
+ prefix=prefix,
149
+ n_trees=n_trees,
150
+ seed=seed,
151
+ threads=threads,
152
+ redo=redo,
153
+ )
154
+
155
+ # 2. RF-Distance ML trees
156
+ ml_trees = pathlib.Path(f"{prefix}.raxml.mlTrees")
157
+ rfdistance_prefix = pathlib.Path(f"{prefix}.rfdist")
158
+
159
+ if log_info:
160
+ log_runtime_information("Computing RF-Distance between ML trees.")
161
+
162
+ n_unique_all, rf_all = rf_distance(
163
+ ml_trees=ml_trees, prefix=rfdistance_prefix, raxmlng=raxmlng, redo=redo
164
+ )
165
+
166
+ if log_info:
167
+ log_runtime_information(f"> RF-Distance ML trees: {round(rf_all, 2)}")
168
+ log_runtime_information(f"> Unique topologies ML trees: {n_unique_all}")
169
+
170
+ # 3. IQ-TREE statistical tests
171
+ best_tree = pathlib.Path(f"{prefix}.raxml.bestTree")
172
+ iqtree_prefix = pathlib.Path(f"{prefix}.iqtree")
173
+ if log_info:
174
+ log_runtime_information("Running IQ-TREE statistical tests.")
175
+ run_statstests(
176
+ msa=msa_file,
177
+ ml_trees=ml_trees,
178
+ best_tree=best_tree,
179
+ iqtree=iqtree,
180
+ model=get_iqtree_model(msa_obj.data_type),
181
+ prefix=iqtree_prefix,
182
+ seed=seed,
183
+ threads=threads,
184
+ redo=redo,
185
+ )
186
+
187
+ # 4. Filter the plausible trees
188
+ iqtree_results = pathlib.Path(f"{iqtree_prefix}.iqtree")
189
+ plausible_ml_trees = pathlib.Path(f"{prefix}.raxml.plausibleTrees")
190
+ if log_info:
191
+ log_runtime_information("Filtering plausible ML trees.")
192
+ filter_plausible_trees(
193
+ ml_trees=ml_trees,
194
+ iqtree_results=iqtree_results,
195
+ plausible_ml_trees=plausible_ml_trees,
196
+ )
197
+
198
+ n_plausible_trees = sum(1 for _ in plausible_ml_trees.open())
199
+
200
+ if log_info:
201
+ log_runtime_information(f"> Found {n_plausible_trees} plausible trees.")
202
+
203
+ # 5. RF-Distance plausible trees
204
+ rfdistance_plausible_prefix = pathlib.Path(f"{prefix}.rfdist.plausible")
205
+ if log_info:
206
+ log_runtime_information("Computing RF-Distance between plausible ML trees.")
207
+ n_unique_plausible, rf_plausible = rf_distance(
208
+ ml_trees=plausible_ml_trees,
209
+ prefix=rfdistance_plausible_prefix,
210
+ raxmlng=raxmlng,
211
+ redo=redo,
212
+ )
213
+
214
+ if log_info:
215
+ log_runtime_information(
216
+ f"> RF-Distance plausible trees: {round(rf_plausible, 2)}"
217
+ )
218
+ log_runtime_information(
219
+ f"> Unique topologies plausible trees: {n_unique_plausible}"
220
+ )
221
+
222
+ # 6. Compute the ground truth difficulty
223
+ difficulty = get_label(
224
+ n_all=n_trees,
225
+ rf_all=rf_all,
226
+ n_unique_all=n_unique_all,
227
+ n_plausible=n_plausible_trees,
228
+ rf_plausible=rf_plausible,
229
+ n_unique_plausible=n_unique_plausible,
230
+ )
231
+ return difficulty
@@ -0,0 +1,32 @@
1
+ import sys
2
+ import textwrap
3
+ import time
4
+
5
+ import loguru
6
+ from labelgenerator import __version__
7
+
8
+ SCRIPT_START = time.perf_counter()
9
+
10
+
11
+ logger = loguru.logger
12
+ logger.remove()
13
+ logger.add(sys.stderr, format="{message}")
14
+
15
+
16
+ def get_header():
17
+ return textwrap.dedent(
18
+ f"Difficulty LabelGenerator version {__version__} released by The Exelixis Lab\n"
19
+ f"Developed by: Julia Haag\n"
20
+ f"Latest version: https://github.com/tschuelia/LabelGenerator\n"
21
+ f"Questions/problems/suggestions? Please open an issue on GitHub.\n",
22
+ )
23
+
24
+
25
+ def log_runtime_information(message, log_runtime=True):
26
+ if log_runtime:
27
+ seconds = time.perf_counter() - SCRIPT_START
28
+ fmt_time = time.strftime("%H:%M:%S", time.gmtime(seconds))
29
+ time_string = f"[{fmt_time}] "
30
+ else:
31
+ time_string = ""
32
+ logger.info(f"{time_string}{message}")