audiolibrarian 0.16.2__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.
audiolibrarian/base.py ADDED
@@ -0,0 +1,433 @@
1
+ """AudioLibrarian base class.
2
+
3
+ Useful stuff: https://help.mp3tag.de/main_tags.html
4
+ """
5
+
6
+ #
7
+ # Copyright (c) 2020 Stephen Jibson
8
+ #
9
+ # This file is part of audiolibrarian.
10
+ #
11
+ # Audiolibrarian is free software: you can redistribute it and/or modify it under the terms of the
12
+ # GNU General Public License as published by the Free Software Foundation, either version 3 of the
13
+ # License, or (at your option) any later version.
14
+ #
15
+ # Audiolibrarian is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
16
+ # without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
17
+ # the GNU General Public License for more details.
18
+ #
19
+ # You should have received a copy of the GNU General Public License along with audiolibrarian.
20
+ # If not, see <https://www.gnu.org/licenses/>.
21
+ #
22
+ import argparse
23
+ import logging
24
+ import pathlib
25
+ import shutil
26
+ import subprocess
27
+ import sys
28
+ import warnings
29
+ from collections.abc import Iterable
30
+ from typing import Any
31
+
32
+ import colors
33
+ import filelock
34
+ import yaml
35
+
36
+ from audiolibrarian import audiofile, audiosource, musicbrainz, records, sh, text
37
+ from audiolibrarian.settings import SETTINGS
38
+
39
+ log = logging.getLogger(__name__)
40
+
41
+
42
+ class Base:
43
+ """AudioLibrarian base class.
44
+
45
+ This class should be sub-classed for various sub_commands.
46
+ """
47
+
48
+ command: str | None = None
49
+
50
+ def __init__(self, args: argparse.Namespace) -> None:
51
+ """Initialize the base."""
52
+ # Pull in stuff from args.
53
+ search_keys = ("album", "artist", "mb_artist_id", "mb_release_id")
54
+ self._provided_search_data = {k: v for k, v in vars(args).items() if k in search_keys}
55
+ if vars(args).get("disc"):
56
+ self._disc_number, self._disc_count = [int(x) for x in args.disc.split("/")]
57
+ else:
58
+ self._disc_number, self._disc_count = 1, 1
59
+
60
+ # Directories.
61
+ self._library_dir = SETTINGS.library_dir
62
+ self._work_dir = SETTINGS.work_dir
63
+ self._flac_dir = self._work_dir / "flac"
64
+ self._m4a_dir = self._work_dir / "m4a"
65
+ self._mp3_dir = self._work_dir / "mp3"
66
+ self._source_dir = self._work_dir / "source"
67
+ self._wav_dir = self._work_dir / "wav"
68
+
69
+ self._manifest_file = "Manifest.yaml"
70
+ self._lock = filelock.FileLock(str(self._work_dir) + ".lock")
71
+
72
+ # Initialize stuff that will be defined later.
73
+ self._audio_source: audiosource.AudioSource | None = None
74
+ self._release: records.Release | None = None
75
+ self._medium: records.Medium | None = None
76
+ self._source_is_cd: bool | None = None
77
+ self._source_example: records.OneTrack | None = None
78
+
79
+ @property
80
+ def _flac_filenames(self) -> list[pathlib.Path]:
81
+ """Return the current list of flac files in the work directory."""
82
+ return sorted(self._flac_dir.glob("*.flac"), key=text.alpha_numeric_key)
83
+
84
+ @property
85
+ def _m4a_filenames(self) -> list[pathlib.Path]:
86
+ """Return the current list of m4a files in the work directory."""
87
+ return sorted(self._m4a_dir.glob("*.m4a"), key=text.alpha_numeric_key)
88
+
89
+ @property
90
+ def _mp3_filenames(self) -> list[pathlib.Path]:
91
+ """Return the current list of mp3 files in the work directory."""
92
+ return sorted(self._mp3_dir.glob("*.mp3"), key=text.alpha_numeric_key)
93
+
94
+ @property
95
+ def _multi_disc(self) -> bool:
96
+ """Return True if this is part of a multi-disc set."""
97
+ return (self._disc_number, self._disc_count) != (1, 1)
98
+
99
+ @property
100
+ def _source_filenames(self) -> list[pathlib.Path]:
101
+ """Return the current list of source files in the work directory."""
102
+ return sorted(self._source_dir.glob("*.flac"), key=text.alpha_numeric_key)
103
+
104
+ @property
105
+ def _wav_filenames(self) -> list[pathlib.Path]:
106
+ """Return the current list of wav files in the work directory."""
107
+ return sorted(self._wav_dir.glob("*.wav"), key=text.alpha_numeric_key)
108
+
109
+ def _convert(self, *, make_source: bool = True) -> None:
110
+ """Perform all the steps of ripping, normalizing, converting and moving the files."""
111
+ if self._audio_source is None:
112
+ warnings.warn(
113
+ "Cannot convert; no audio_source is defined.", RuntimeWarning, stacklevel=2
114
+ )
115
+ return
116
+ self._audio_source.prepare_source()
117
+ with self._lock:
118
+ self._make_clean_workdirs()
119
+ self._audio_source.copy_wavs(self._wav_dir)
120
+ self._rename_wav()
121
+ if make_source:
122
+ self._make_source()
123
+ self._normalize()
124
+ self._make_flac()
125
+ self._make_m4a()
126
+ self._make_mp3()
127
+ self._move_files(move_source=make_source)
128
+
129
+ @staticmethod
130
+ def _find_audio_files(directories: list[str | pathlib.Path]) -> Iterable[audiofile.AudioFile]:
131
+ """Yield audiofile objects found in the given directories."""
132
+ paths: list[pathlib.Path] = []
133
+ # Grab all the paths first because thing may change as files are renamed.
134
+ for directory in directories:
135
+ path = pathlib.Path(directory)
136
+ for ext in audiofile.AudioFile.extensions():
137
+ paths.extend(path.rglob(f"*{ext}"))
138
+ paths = sorted(set(paths))
139
+ # Using yield rather than returning a list saves us from simultaneously storing
140
+ # potentially thousands of AudioFile objects in memory at the same time.
141
+ for path in paths:
142
+ try:
143
+ yield audiofile.AudioFile.open(path)
144
+ except FileNotFoundError:
145
+ continue
146
+
147
+ def _find_manifests(self, directories: list[str | pathlib.Path]) -> list[pathlib.Path]:
148
+ """Return a sorted, unique list of manifest files anywhere in the given directories."""
149
+ manifests = set()
150
+ for directory in directories:
151
+ path = pathlib.Path(directory)
152
+ for manifest in path.rglob(self._manifest_file):
153
+ manifests.add(manifest)
154
+ return sorted(manifests)
155
+
156
+ def _get_searcher(self) -> musicbrainz.Searcher:
157
+ """Return a Searcher object populated with data from the audio source and cli args."""
158
+ search_data: dict[str, str] = (
159
+ self._audio_source.get_search_data() if self._audio_source is not None else {}
160
+ )
161
+ searcher = musicbrainz.Searcher(**search_data) # type: ignore[arg-type]
162
+ searcher.disc_number = str(self._disc_number)
163
+ # Override with user-provided info.
164
+ if value := self._provided_search_data.get("artist"):
165
+ searcher.artist = value
166
+ if value := self._provided_search_data.get("album"):
167
+ searcher.album = value
168
+ if value := self._provided_search_data.get("mb_artist_id"):
169
+ searcher.mb_artist_id = value
170
+ if value := self._provided_search_data.get("mb_release_id"):
171
+ searcher.mb_release_id = value
172
+ log.info("SEARCHER: %s", searcher)
173
+ return searcher
174
+
175
+ def _get_tag_info(self) -> None:
176
+ """Gather search information from user-provided options and source files.
177
+
178
+ Set `self._release` and `self._medium` based on the search results.
179
+ If the search results don't have a front cover, use the front cover from the source files.
180
+ Print a summary of the results and prompt the user to confirm.
181
+ If the track count does not match the file count, print an error message.
182
+ If the user does not confirm, exit the program.
183
+ """
184
+ print("Gathering search information...")
185
+ searcher = self._get_searcher()
186
+ skip_confirm = bool(searcher.mb_artist_id and searcher.mb_release_id)
187
+ print("Finding MusicBrainz release information...")
188
+ self._release = searcher.find_music_brains_release()
189
+ self._medium = self._release.media[int(self._disc_number)]
190
+ if (
191
+ not self._release.front_cover
192
+ and self._audio_source
193
+ and (cover := self._audio_source.get_front_cover())
194
+ ):
195
+ log.info("Using front-cover image from source file")
196
+ self._release.front_cover = cover
197
+ summary, okay = self._summary()
198
+ print(summary)
199
+ if not okay:
200
+ print(colors.color("\n*** Track count does not match file count ***\n", fg="red"))
201
+ skip_confirm = False
202
+ if not skip_confirm and text.input_("Confirm [N,y]: ").lower() != "y": # pragma: no cover
203
+ sys.exit(1)
204
+
205
+ def _make_clean_workdirs(self) -> None:
206
+ """Erase everything from the workdir and create the empty directory structure."""
207
+ if self._work_dir.is_dir():
208
+ shutil.rmtree(self._work_dir)
209
+ for path in self._flac_dir, self._m4a_dir, self._mp3_dir, self._source_dir, self._wav_dir:
210
+ path.mkdir(parents=True)
211
+
212
+ def _make_flac(self, *, source: bool = False) -> None:
213
+ """Convert the wav files into flac files; tag them.
214
+
215
+ If source is True, it stores the flac files in the source directory,
216
+ otherwise, it stores them in the flac directory.
217
+ """
218
+ out_dir = self._source_dir if source else self._flac_dir
219
+ commands: list[tuple[str, ...]] = [
220
+ ("flac", "--silent", f"--output-prefix={out_dir}/", str(f))
221
+ for f in self._wav_filenames
222
+ ]
223
+ sh.parallel(f"Making {len(self._wav_filenames)} flac files...", commands)
224
+ filenames = self._source_filenames if source else self._flac_filenames
225
+ sh.touch(filenames)
226
+ self._tag_files(filenames)
227
+
228
+ def _make_m4a(self) -> None:
229
+ """Convert the wav files into m4a files; tag them."""
230
+ commands: list[tuple[str, ...]] = []
231
+ for filename in self._wav_filenames:
232
+ dst_file = self._m4a_dir / filename.name.replace(".wav", ".m4a")
233
+ commands.append(
234
+ ("fdkaac", "--silent", "--bitrate-mode=5", "-o", str(dst_file), str(filename))
235
+ )
236
+ sh.parallel(f"Making {len(commands)} m4a files...", commands)
237
+ sh.touch(self._m4a_filenames)
238
+ self._tag_files(self._m4a_filenames)
239
+
240
+ def _make_mp3(self) -> None:
241
+ """Convert the wav files into mp3 files; tag them."""
242
+ commands: list[tuple[str, ...]] = []
243
+ for filename in self._wav_filenames:
244
+ dst_file = self._mp3_dir / filename.name.replace(".wav", ".mp3")
245
+ commands.append(("lame", "--silent", "-h", "-b", "192", str(filename), str(dst_file)))
246
+ sh.parallel(f"Making {len(commands)} mp3 files...", commands)
247
+ sh.touch(self._mp3_filenames)
248
+ self._tag_files(self._mp3_filenames)
249
+
250
+ def _make_source(self) -> None:
251
+ """Convert the files into flac files; store them in the source dir; read their tags.
252
+
253
+ The files are defined by the audio source; they could be wav files from a CD
254
+ or another type of audio file.
255
+ """
256
+ self._make_flac(source=True)
257
+ self._source_example = audiofile.AudioFile.open(self._source_filenames[0]).read_tags()
258
+
259
+ def _move_files(self, *, move_source: bool = True) -> None:
260
+ """Move converted/tagged files from the work directory into the library directory."""
261
+ artist_album_dir = self._release.get_artist_album_path()
262
+ flac_dir = self._library_dir / "flac" / artist_album_dir
263
+ m4a_dir = self._library_dir / "m4a" / artist_album_dir
264
+ mp3_dir = self._library_dir / "mp3" / artist_album_dir
265
+ source_dir = self._library_dir / "source" / artist_album_dir
266
+ if self._multi_disc:
267
+ flac_dir /= f"disc{self._disc_number}"
268
+ m4a_dir /= f"disc{self._disc_number}"
269
+ mp3_dir /= f"disc{self._disc_number}"
270
+ source_dir /= f"disc{self._disc_number}"
271
+ for path in [flac_dir, m4a_dir, mp3_dir] + ([source_dir] if move_source else []):
272
+ if path.is_dir():
273
+ shutil.rmtree(path)
274
+ path.mkdir(parents=True)
275
+ for path in self._flac_filenames:
276
+ path.rename(flac_dir / path.name)
277
+ for path in self._m4a_filenames:
278
+ path.rename(m4a_dir / path.name)
279
+ for path in self._mp3_filenames:
280
+ path.rename(mp3_dir / path.name)
281
+ if move_source:
282
+ for path in self._source_filenames:
283
+ path.rename(source_dir / path.name)
284
+
285
+ def _normalize(self) -> None:
286
+ """Normalize the wav files using wavegain."""
287
+ print("Normalizing wav files...")
288
+ command = [
289
+ "wavegain",
290
+ f"--{SETTINGS.normalize_preset}",
291
+ f"--gain={SETTINGS.normalize_gain}",
292
+ "--apply",
293
+ ]
294
+ command.extend(str(f) for f in self._wav_filenames)
295
+ result = subprocess.run(command, capture_output=True, check=False) # noqa: S603
296
+ for line in str(result.stderr).split(r"\n"):
297
+ line_trunc = line[:137] + "..." if len(line) > 140 else line # noqa: PLR2004
298
+ log.info("WAVEGAIN: %s", line_trunc)
299
+ result.check_returncode()
300
+
301
+ @staticmethod
302
+ def _read_manifest(manifest_path: pathlib.Path) -> dict[Any, Any]:
303
+ with manifest_path.open(encoding="utf-8") as manifest_file:
304
+ return dict(yaml.safe_load(manifest_file))
305
+
306
+ def _rename_wav(self) -> None:
307
+ """Rename the wav files to a filename-sane representation of the track title."""
308
+ for old_path in self._wav_filenames:
309
+ track_number = text.get_track_number(str(old_path.name))
310
+ title_filename = self._medium.tracks[track_number].get_filename(".wav")
311
+ new_path = old_path.parent / title_filename
312
+ if new_path.resolve() != old_path.resolve():
313
+ log.info("RENAMING: %s --> %s", old_path.name, new_path.name)
314
+ old_path.rename(new_path)
315
+
316
+ def _summary(self) -> tuple[str, bool]:
317
+ """Return a summary of the conversion/tagging process and an "ok" flag indicating issues.
318
+
319
+ The summary is a nicely formatted table showing the album, artist and track info.
320
+ The "ok" flag indicating issues will be true if:
321
+ - the file count does not match the song count from the MusicBrainz database
322
+ (https://jrgraphix.net/r/Unicode/2500-257F)
323
+ """
324
+ if self._audio_source is None or self._release is None:
325
+ warnings.warn(
326
+ "Cannot provide summary; missing audio_source and/or release.",
327
+ RuntimeWarning,
328
+ stacklevel=1,
329
+ )
330
+ return "", False
331
+ lines = []
332
+ okay = True
333
+ no_match = "(no match)"
334
+ col1 = [f.stem if f else no_match for f in self._audio_source.source_list]
335
+ col2 = [t.get_filename() for _, t in sorted(self._medium.tracks.items())]
336
+ col3 = [f"{str(n).zfill(2)}: {t.title}" for n, t in sorted(self._medium.tracks.items())]
337
+ min_total_w = 74 # Make sure we've got enough width for MB Release URL.
338
+ width = 40
339
+ col1_w = min(width, max([len(x) for x in col1] + [len(no_match)]))
340
+ col2_w = min(width, max([len(x) for x in col2] + [len(no_match)]))
341
+ col3_w = min(width, max([len(x) for x in col3] + [len(no_match)]))
342
+ if col1_w + col2_w + col3_w < min_total_w:
343
+ col3_w = min_total_w - (col1_w + col2_w)
344
+ tab_w = col1_w + col2_w + col3_w + 6
345
+ c1_line = (col1_w + 2) * "\u2550"
346
+ c2_line = (col2_w + 2) * "\u2550"
347
+ c3_line = (col3_w + 2) * "\u2550"
348
+ alb = f"Album: {self._release.album}"
349
+ art = f"Artist(s): {', '.join(self._release.album_artists_sort)}"
350
+ med = f"Disc: {self._disc_number} of {self._disc_count}"
351
+ mbr = f"MB Release: https://musicbrainz.org/release/{self._release.musicbrainz_album_id}"
352
+ lines.append(f"\u2554{c1_line}\u2550{c2_line}\u2550{c3_line}\u2557")
353
+ lines.extend(
354
+ [f"\u2551 {line} {' ' * (tab_w - len(line))}\u2551" for line in (alb, art, mbr, med)]
355
+ )
356
+ fmt = f"\u2551 {{c1: <{col1_w}}} \u2502 {{c2: <{col2_w}}} \u2502 {{c3: <{col3_w}}} \u2551"
357
+ lines.append(f"\u2560{c1_line}\u2564{c2_line}\u2564{c3_line}\u2563")
358
+ lines.append(fmt.format(c1="Source", c2="Destination", c3="Title"))
359
+ lines.append(f"\u2560{c1_line}\u256a{c2_line}\u256a{c3_line}\u2563")
360
+ rows = max(len(col1), len(col2), len(col3))
361
+ for i in range(rows):
362
+ col1_ = (
363
+ ((col1[i][: width - 3] + "...") if len(col1[i]) > width else col1[i])
364
+ if len(col1) > i
365
+ else no_match
366
+ )
367
+ col2_ = (
368
+ ((col2[i][: width - 3] + "...") if len(col2[i]) > width else col2[i])
369
+ if len(col2) > i
370
+ else no_match
371
+ )
372
+ col3_ = (
373
+ ((col3[i][: width - 3] + "...") if len(col3[i]) > width else col3[i])
374
+ if len(col3) > i
375
+ else no_match
376
+ )
377
+ lines.append(fmt.format(c1=col1_, c2=col2_, c3=col3_))
378
+ if no_match in (col1_, col2_, col3_):
379
+ okay = False
380
+ lines.append(f"\u255a{c1_line}\u2567{c2_line}\u2567{c3_line}\u255d")
381
+ return "\n".join(lines), okay
382
+
383
+ def _tag_files(self, filenames: list[pathlib.Path]) -> None:
384
+ """Tag the given list of files."""
385
+ for filename in filenames:
386
+ song = audiofile.AudioFile.open(filename)
387
+ song.one_track = records.OneTrack(
388
+ release=self._release,
389
+ medium_number=self._disc_number,
390
+ track_number=int(filename.name.split("__")[0]),
391
+ )
392
+ song.write_tags()
393
+
394
+ def _write_manifest(self) -> None:
395
+ """Write out a manifest file with release information."""
396
+ release = self._release # We use this a lot below.
397
+ file_info = self._source_example.track.file_info
398
+ manifest = {
399
+ "album": release.album,
400
+ "artist": release.album_artists.first,
401
+ "artist_sort_name": release.album_artists_sort.first,
402
+ "media": release.media[self._disc_number].formats.first,
403
+ "genre": release.genres.first,
404
+ "disc_number": self._disc_number,
405
+ "disc_count": self._disc_count,
406
+ "original_year": release.original_year,
407
+ "date": release.date,
408
+ "musicbrainz_info": {
409
+ "albumid": release.musicbrainz_album_id,
410
+ "albumartistid": release.musicbrainz_album_artist_ids.first,
411
+ "releasegroupid": release.musicbrainz_release_group_id,
412
+ },
413
+ "source_info": {
414
+ "type": file_info.type.name,
415
+ "bitrate": file_info.bitrate,
416
+ "bitrate_mode": file_info.bitrate_mode.name,
417
+ },
418
+ }
419
+ if self.command == "manifest":
420
+ manifest_filename = self._manifest_file # Write to current directory.
421
+ if self._source_is_cd:
422
+ manifest["source_info"] = {
423
+ "type": "CD",
424
+ "bitrate": 1411,
425
+ "bitrate_mode": "CBR",
426
+ }
427
+ else:
428
+ source_dir = self._library_dir / "source"
429
+ artist_album_dir = self._release.get_artist_album_path()
430
+ manifest_filename = str(source_dir / artist_album_dir / self._manifest_file)
431
+ with pathlib.Path(manifest_filename).open("w", encoding="utf-8") as manifest_file:
432
+ yaml.dump(manifest, manifest_file)
433
+ print(f"Wrote {manifest_filename}")
audiolibrarian/cli.py ADDED
@@ -0,0 +1,123 @@
1
+ """Audiolibrarian command line interface."""
2
+
3
+ #
4
+ # Copyright (c) 2021 Stephen Jibson
5
+ #
6
+ # This file is part of audiolibrarian.
7
+ #
8
+ # Audiolibrarian is free software: you can redistribute it and/or modify it under the terms of the
9
+ # GNU General Public License as published by the Free Software Foundation, either version 3 of the
10
+ # License, or (at your option) any later version.
11
+ #
12
+ # Audiolibrarian is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
13
+ # without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
14
+ # the GNU General Public License for more details.
15
+ #
16
+ # You should have received a copy of the GNU General Public License along with audiolibrarian.
17
+ # If not, see <https://www.gnu.org/licenses/>.
18
+ #
19
+ import argparse
20
+ import logging
21
+ import pathlib
22
+ import subprocess
23
+ import sys
24
+ from typing import Final
25
+
26
+ from audiolibrarian import commands
27
+
28
+ log = logging.getLogger("audiolibrarian")
29
+
30
+
31
+ class CommandLineInterface:
32
+ """Command line interface."""
33
+
34
+ _REQUIRED_EXE: Final[set[str]] = {
35
+ "cd-paranoia",
36
+ "eject",
37
+ "faad",
38
+ "fdkaac",
39
+ "flac",
40
+ "lame",
41
+ "mpg123",
42
+ "sndfile-convert",
43
+ "wavegain",
44
+ }
45
+
46
+ def __init__(self, *, parse_args: bool = True) -> None:
47
+ """Initialize a CommandLineInterface handler."""
48
+ if parse_args:
49
+ self._args = self._parse_args()
50
+ self.log_level = self._args.log_level
51
+
52
+ def execute(self) -> None:
53
+ """Execute the command."""
54
+ log.info("ARGS: %s", self._args)
55
+ if not self._check_deps():
56
+ sys.exit(1)
57
+ for cmd in commands.COMMANDS:
58
+ if self._args.command == cmd.command:
59
+ if not cmd.validate_args(self._args):
60
+ sys.exit(2)
61
+ cmd(self._args)
62
+ break
63
+ if self._args.log_level == logging.DEBUG:
64
+ print(pathlib.Path("/proc/self/status").read_text(encoding="utf-8"))
65
+
66
+ def _check_deps(self) -> bool:
67
+ """Check that all the executables defined in REQUIRED_EXE exist on the system.
68
+
69
+ If any of the required executables are missing, list them and return False.
70
+ """
71
+ missing = []
72
+ for exe in self._REQUIRED_EXE:
73
+ try:
74
+ subprocess.run( # noqa: S602
75
+ f"command -v {exe}",
76
+ shell=True,
77
+ stdout=subprocess.DEVNULL,
78
+ stderr=subprocess.DEVNULL,
79
+ check=True,
80
+ )
81
+ except subprocess.CalledProcessError:
82
+ missing.append(exe)
83
+ if missing:
84
+ print(f"\nMissing required executable(s): {', '.join(missing)}\n")
85
+ return False
86
+ return True
87
+
88
+ # noinspection PyProtectedMember
89
+ @staticmethod
90
+ def _parse_args() -> argparse.Namespace:
91
+ """Parse command line arguments."""
92
+ parser = argparse.ArgumentParser(description="audiolibrarian")
93
+
94
+ # global options
95
+ parser.add_argument(
96
+ "--log-level",
97
+ "-l",
98
+ choices=("CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"),
99
+ default="ERROR",
100
+ help="log level (default: ERROR)",
101
+ )
102
+
103
+ # Add sub-commands and args for sub_commands.
104
+ subparsers = parser.add_subparsers(title="commands", dest="command")
105
+ for cmd_ in commands.COMMANDS:
106
+ # This is a total hack because argparse won't allow you to add an already
107
+ # existing ArgumentParser as a sub-parser.
108
+ if cmd_.parser:
109
+ cmd_.parser.prog = f"{subparsers._prog_prefix} {cmd_.command}" # noqa: SLF001
110
+ subparsers._choices_actions.append( # noqa: SLF001
111
+ subparsers._ChoicesPseudoAction(cmd_.command, (), cmd_.help) # noqa: SLF001
112
+ )
113
+ subparsers._name_parser_map[cmd_.command] = cmd_.parser # noqa: SLF001
114
+
115
+ return parser.parse_args()
116
+
117
+
118
+ def main() -> None:
119
+ """Execute the command line interface."""
120
+ cli_ = CommandLineInterface()
121
+ logging.basicConfig(level=cli_.log_level)
122
+ logging.captureWarnings(capture=True)
123
+ cli_.execute()