log vs config vs app (DIRTY)
This commit is contained in:
@@ -13,11 +13,8 @@ except PackageNotFoundError:
|
|||||||
# package is not installed
|
# package is not installed
|
||||||
pass
|
pass
|
||||||
|
|
||||||
from typing import Union
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
PathLike = Union[str, Path]
|
|
||||||
|
|
||||||
from .logger import logger, loglevel_map
|
from .logger import logger, loglevel_map, get_logger
|
||||||
from .utils import DataDict
|
from .utils import DataDict, PathLike, path
|
||||||
from .app import App
|
from .app import App
|
||||||
|
|||||||
+9
-184
@@ -15,6 +15,7 @@ The industry-standard hierarchy for application configuration is:
|
|||||||
from argparse import ArgumentParser
|
from argparse import ArgumentParser
|
||||||
from collections import UserDict
|
from collections import UserDict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -30,13 +31,10 @@ from . import tts_aedocw, tts_generic, tts_kokoro
|
|||||||
from . import logger, PathLike, loglevel_map
|
from . import logger, PathLike, loglevel_map
|
||||||
from . import DataDict
|
from . import DataDict
|
||||||
|
|
||||||
class App(DataDict):
|
class App():
|
||||||
# Globals/Defaults
|
|
||||||
DEFAULT_ENV_FILE=".env"
|
|
||||||
ENV_FILE_OVERRIDE=False
|
|
||||||
WHICH = ["ffmpeg"] # Needed in $PATH
|
WHICH = ["ffmpeg"] # Needed in $PATH
|
||||||
DEBUG = False
|
|
||||||
LOGLEVEL = "WARNING"
|
|
||||||
BACKENDS = {
|
BACKENDS = {
|
||||||
# keys are the backend names, values are the corresponding TTS classes
|
# keys are the backend names, values are the corresponding TTS classes
|
||||||
"default": tts_generic.GenericTTSBackend,
|
"default": tts_generic.GenericTTSBackend,
|
||||||
@@ -52,33 +50,12 @@ class App(DataDict):
|
|||||||
"kokoro": tts_kokoro.KokoroBackend,
|
"kokoro": tts_kokoro.KokoroBackend,
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self,
|
||||||
super().__init__(*args,**kwargs)
|
input_str: Optional[str|Path|list[str|Path]],
|
||||||
self._argv = sys.argv
|
output_str: Optional[str|Path],
|
||||||
self._args_list = args if args is not None else self._argv
|
backend: str,
|
||||||
|
):
|
||||||
|
|
||||||
self.setup_logger()
|
|
||||||
|
|
||||||
if "env_file" not in self._data:
|
|
||||||
self._data["env_file"] = App.DEFAULT_ENV_FILE
|
|
||||||
self.logger.debug(self._data["env_file"])
|
|
||||||
|
|
||||||
|
|
||||||
# for path_flag in ["env_file"]:
|
|
||||||
# if not Path(self[path_flag]).exists():
|
|
||||||
# self.logger.warning(f"Could not read %s. Ignoring '%s' and using '%s'", path_flag, self[path_flag], App.DEFAULT_ENV_FILE)
|
|
||||||
# self[path_flag] = App["DEFAULT_" + path_flag.upper()]
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# load .env/env_file
|
|
||||||
load_dotenv(self.env_file, override=os.environ.get("ENV_FILE_OVERRIDE",App.ENV_FILE_OVERRIDE))
|
|
||||||
|
|
||||||
# Now we can call argparser with correct defaults and logger/loglevel
|
|
||||||
# Get ArgumentParser and pased *args or read from sys.argv
|
|
||||||
self._parsed_args = self.get_parser().parse_args(args or sys.argv[1:])
|
|
||||||
for k,v in self._parsed_args.__dict__.items():
|
|
||||||
self[k] = v
|
|
||||||
|
|
||||||
# Validate and sanitize output
|
# Validate and sanitize output
|
||||||
self.output_path = App.path(self.output)
|
self.output_path = App.path(self.output)
|
||||||
@@ -126,76 +103,6 @@ class App(DataDict):
|
|||||||
|
|
||||||
self.engine = App.BACKENDS[self.backend](self)
|
self.engine = App.BACKENDS[self.backend](self)
|
||||||
|
|
||||||
def setup_logger(self, verbose=None, debug_level=None, loglevel: str|int = None, logfile:PathLike=None):
|
|
||||||
"""Initialize the package logger from CLI flags and defaults.
|
|
||||||
|
|
||||||
Resolve the effective logging level from the class args (or sys.argv).
|
|
||||||
--debug (or debug_level=True) takes priority, then --verbose (or verbose=True)
|
|
||||||
is compared to --loglevel (or loglevel=) and the lower one is proritized.
|
|
||||||
"""
|
|
||||||
# Setup logger
|
|
||||||
self.logger = logger
|
|
||||||
|
|
||||||
# Find lowest priority loglevel
|
|
||||||
if debug_level or App.DEBUG or "--debug" in sys.argv or "--debug" in self._args_list:
|
|
||||||
print("DEBUG!")
|
|
||||||
self.debug_level = True
|
|
||||||
self.loglevel = "DEBUG"
|
|
||||||
else: # compare verbose to loglevel
|
|
||||||
if "--verbose" in self._args_list or verbose == True:
|
|
||||||
self.verbose = True
|
|
||||||
for i, a in enumerate(self._args_list):
|
|
||||||
if a == "--loglevel":
|
|
||||||
self.loglevel = self._args_list[i+1]
|
|
||||||
elif a.startswith("--loglevel="):
|
|
||||||
self.loglevel = a.replace("--loglevel=", "")
|
|
||||||
|
|
||||||
if "loglevel" in self and self.loglevel is None:
|
|
||||||
if loglevel is not None:
|
|
||||||
self.logger.debug("self.loglevel is None, but setup_logger(loglevel) is not None")
|
|
||||||
self.loglevel = loglevel
|
|
||||||
else:
|
|
||||||
self.logger.debug("self.loglevel = App.LOGLEVEL (%s)", App.LOGLEVEL)
|
|
||||||
self.loglevel = App.LOGLEVEL
|
|
||||||
|
|
||||||
# lower = more priority
|
|
||||||
if loglevel_map[self.loglevel] > loglevel_map["INFO"]:
|
|
||||||
self.logger.debug("self.loglevel='%s'(%d) > INFO", self.loglevel, loglevel_map[self.loglevel])
|
|
||||||
self.loglevel = "INFO"
|
|
||||||
|
|
||||||
self.logger.info("Setting loglevel to %s",self.loglevel)
|
|
||||||
self.logger.setLevel(loglevel_map[self.loglevel])
|
|
||||||
|
|
||||||
# Get logfile
|
|
||||||
for i, a in enumerate(self._args_list):
|
|
||||||
if a == "--logfile":
|
|
||||||
self.logfile = self._args_list[i+1]
|
|
||||||
elif a.startswith("--logfile="):
|
|
||||||
self.logfile = a.replace("--logfile=", "")
|
|
||||||
|
|
||||||
if "logfile" in self and self.logfile:
|
|
||||||
if logfile:
|
|
||||||
self.logger.warning("Overriding --logfile='%s' from setup_logger(logfile='%s')", self.logfile, logfile)
|
|
||||||
self.logfile = logfile
|
|
||||||
fh = logging.FileHandler(logfile)
|
|
||||||
fh.setLevel(self.loglevel)
|
|
||||||
fh.setFormatter(logger.formatter)
|
|
||||||
self.logger.addHandler(fh)
|
|
||||||
|
|
||||||
# if self.debug_level:
|
|
||||||
# logger.info("Loglevel: DEBUG")
|
|
||||||
# if self.loglevel:
|
|
||||||
# logger.warning("Ignoring '--loglevel' flag because DEBUG/'--debug' was also set.")
|
|
||||||
# if "--verbose" in self._args_list:
|
|
||||||
# logger.warning("Ignoring '--verbose' flag because '--debug' was also set.")
|
|
||||||
# self.verbose = False
|
|
||||||
# if self.loglevel and self.verbose:
|
|
||||||
# self.warning("Conflict: trying to --loglevel=%s (%d) and --verbose.", self.loglevel, loglevel_map[self.loglevel])
|
|
||||||
# if loglevel_map[self.loglevel] < logging.INFO:
|
|
||||||
# self.logger.info("Setting loglevel to %s (%d), which is more verbose than INFO.", self.loglevel, loglevel_map[self.loglevel])
|
|
||||||
|
|
||||||
|
|
||||||
return self.logger
|
|
||||||
|
|
||||||
# Utility methods
|
# Utility methods
|
||||||
def check_env(self, which=None):
|
def check_env(self, which=None):
|
||||||
@@ -218,85 +125,3 @@ class App(DataDict):
|
|||||||
else:
|
else:
|
||||||
self.logger.info(f"%s found at: %s", bin, p)
|
self.logger.info(f"%s found at: %s", bin, p)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def get_parser(self):
|
|
||||||
p = ArgumentParser(description="Convert EPUB to audio using TTS")
|
|
||||||
p.add_argument(
|
|
||||||
"--version",
|
|
||||||
help="Show version and exit",
|
|
||||||
action="version",
|
|
||||||
version=f"%(prog)s {__import__('epub_tts').__version__}")
|
|
||||||
|
|
||||||
# General options
|
|
||||||
p.add_argument(
|
|
||||||
"--debug", dest="debug_level", action="store_true",
|
|
||||||
help="Enable debug logging and verbose diagnostics")
|
|
||||||
p.add_argument(
|
|
||||||
"--verbose", action="store_true",
|
|
||||||
help="Enable info-level logging")
|
|
||||||
p.add_argument(
|
|
||||||
"--loglevel",
|
|
||||||
type=str.upper, default=logging.INFO,
|
|
||||||
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
|
||||||
help="Set logging level, from most verbose to least verbose:")
|
|
||||||
p.add_argument(
|
|
||||||
"--logfile", default=None,
|
|
||||||
help="Write logs to the specified file")
|
|
||||||
|
|
||||||
# Input options and processing
|
|
||||||
p.add_argument(
|
|
||||||
"-i", "--input",
|
|
||||||
nargs="+", required=True,
|
|
||||||
help="Input EPUB file")
|
|
||||||
p.add_argument(
|
|
||||||
"-r", "--replace", action="append", nargs=2,
|
|
||||||
help="Replace text in the intermediate output. "
|
|
||||||
"Specify pairs of old_text new_text. "
|
|
||||||
"Can be used multiple times.")
|
|
||||||
p.add_argument(
|
|
||||||
"--check-env",action="store_true",
|
|
||||||
help="Check the runtime environment and exit")
|
|
||||||
p.add_argument(
|
|
||||||
"-b", "--backend", default="default",
|
|
||||||
choices=App.BACKENDS.keys(),
|
|
||||||
help="Backend to use for TTS")
|
|
||||||
|
|
||||||
# Output options
|
|
||||||
p.add_argument(
|
|
||||||
"-o", "--output",
|
|
||||||
help="Output audio file", required=True)
|
|
||||||
p.add_argument(
|
|
||||||
"-c", "--cover",
|
|
||||||
help="Path to cover image to embed or use "
|
|
||||||
"for output metadata")
|
|
||||||
|
|
||||||
# Speech options
|
|
||||||
p.add_argument(
|
|
||||||
"-l", "--language",
|
|
||||||
help="Language to use for TTS (see your "
|
|
||||||
"backend's documentation for available voices)")
|
|
||||||
p.add_argument(
|
|
||||||
"-v", "--voice",
|
|
||||||
help="Voice to use for TTS (see your backend's "
|
|
||||||
"documentation for available voices)")
|
|
||||||
p.add_argument(
|
|
||||||
"--speed", type=float, default=1.0,
|
|
||||||
help="Playback speed multiplier (default: 1.0) "
|
|
||||||
"(not all backends support this)")
|
|
||||||
p.add_argument(
|
|
||||||
"--short-pause", type=int, default=None,
|
|
||||||
help="Short pause duration in milliseconds between "
|
|
||||||
"phrases or sentences (not all backends support this)")
|
|
||||||
p.add_argument(
|
|
||||||
"--long-pause", type=int, default=None,
|
|
||||||
help="Long pause duration in milliseconds between "
|
|
||||||
"sections or paragraphs (not all backends support this)")
|
|
||||||
p.add_argument(
|
|
||||||
"--notitles", action="store_true",
|
|
||||||
help="Do not read chapter titles")
|
|
||||||
|
|
||||||
return p
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def path(fpath: PathLike = None):
|
|
||||||
return Path(fpath).resolve()
|
|
||||||
@@ -31,6 +31,118 @@ from argparse import ArgumentParser
|
|||||||
|
|
||||||
# Local imports
|
# Local imports
|
||||||
from . import App
|
from . import App
|
||||||
|
from . import logger, get_logger
|
||||||
|
|
||||||
|
# Globals/Defaults
|
||||||
|
DEFAULT_ENV_FILE=".env"
|
||||||
|
ENV_FILE_OVERRIDE=False
|
||||||
|
DEBUG = False
|
||||||
|
LOGLEVEL = "WARNING"
|
||||||
|
|
||||||
|
|
||||||
|
def get_parser():
|
||||||
|
p = ArgumentParser(description="Convert EPUB to audio using TTS")
|
||||||
|
p.add_argument(
|
||||||
|
"--version",
|
||||||
|
help="Show version and exit",
|
||||||
|
action="version",
|
||||||
|
version=f"%(prog)s {__import__('epub_tts').__version__}")
|
||||||
|
|
||||||
|
# General options
|
||||||
|
p.add_argument(
|
||||||
|
"--debug", dest="debug_level", action="store_true",
|
||||||
|
help="Enable debug logging and verbose diagnostics")
|
||||||
|
p.add_argument(
|
||||||
|
"--verbose", action="store_true",
|
||||||
|
help="Enable info-level logging")
|
||||||
|
p.add_argument(
|
||||||
|
"--loglevel",
|
||||||
|
type=str.upper, default=logging.INFO,
|
||||||
|
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
||||||
|
help="Set logging level, from most verbose to least verbose:")
|
||||||
|
p.add_argument(
|
||||||
|
"--logfile", default=None,
|
||||||
|
help="Write logs to the specified file")
|
||||||
|
|
||||||
|
# Input options and processing
|
||||||
|
p.add_argument(
|
||||||
|
"-i", "--input",
|
||||||
|
nargs="+", required=True,
|
||||||
|
help="Input EPUB file")
|
||||||
|
p.add_argument(
|
||||||
|
"-r", "--replace", action="append", nargs=2,
|
||||||
|
help="Replace text in the intermediate output. "
|
||||||
|
"Specify pairs of old_text new_text. "
|
||||||
|
"Can be used multiple times.")
|
||||||
|
p.add_argument(
|
||||||
|
"--check-env",action="store_true",
|
||||||
|
help="Check the runtime environment and exit")
|
||||||
|
p.add_argument(
|
||||||
|
"-b", "--backend", default="default",
|
||||||
|
choices=App.BACKENDS.keys(),
|
||||||
|
help="Backend to use for TTS")
|
||||||
|
|
||||||
|
# Output options
|
||||||
|
p.add_argument(
|
||||||
|
"-o", "--output",
|
||||||
|
help="Output audio file", required=True)
|
||||||
|
p.add_argument(
|
||||||
|
"-c", "--cover",
|
||||||
|
help="Path to cover image to embed or use "
|
||||||
|
"for output metadata")
|
||||||
|
|
||||||
|
# Speech options
|
||||||
|
p.add_argument(
|
||||||
|
"-l", "--language",
|
||||||
|
help="Language to use for TTS (see your "
|
||||||
|
"backend's documentation for available voices)")
|
||||||
|
p.add_argument(
|
||||||
|
"-v", "--voice",
|
||||||
|
help="Voice to use for TTS (see your backend's "
|
||||||
|
"documentation for available voices)")
|
||||||
|
p.add_argument(
|
||||||
|
"--speed", type=float, default=1.0,
|
||||||
|
help="Playback speed multiplier (default: 1.0) "
|
||||||
|
"(not all backends support this)")
|
||||||
|
p.add_argument(
|
||||||
|
"--short-pause", type=int, default=None,
|
||||||
|
help="Short pause duration in milliseconds between "
|
||||||
|
"phrases or sentences (not all backends support this)")
|
||||||
|
p.add_argument(
|
||||||
|
"--long-pause", type=int, default=None,
|
||||||
|
help="Long pause duration in milliseconds between "
|
||||||
|
"sections or paragraphs (not all backends support this)")
|
||||||
|
p.add_argument(
|
||||||
|
"--notitles", action="store_true",
|
||||||
|
help="Do not read chapter titles")
|
||||||
|
|
||||||
|
return p
|
||||||
|
|
||||||
|
def setup_app(cmdline:str|list[str]=None):
|
||||||
|
|
||||||
|
logger = get_logger()
|
||||||
|
parser = get_parser()
|
||||||
|
parsed_args = p.parse_args(cmdline)
|
||||||
|
if "env_file" not in self._data:
|
||||||
|
self._data["env_file"] = App.DEFAULT_ENV_FILE
|
||||||
|
self.logger.debug(self._data["env_file"])
|
||||||
|
|
||||||
|
|
||||||
|
# for path_flag in ["env_file"]:
|
||||||
|
# if not Path(self[path_flag]).exists():
|
||||||
|
# self.logger.warning(f"Could not read %s. Ignoring '%s' and using '%s'", path_flag, self[path_flag], App.DEFAULT_ENV_FILE)
|
||||||
|
# self[path_flag] = App["DEFAULT_" + path_flag.upper()]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# load .env/env_file
|
||||||
|
load_dotenv(self.env_file, override=os.environ.get("ENV_FILE_OVERRIDE",App.ENV_FILE_OVERRIDE))
|
||||||
|
|
||||||
|
# Now we can call argparser with correct defaults and logger/loglevel
|
||||||
|
# Get ArgumentParser and pased *args or read from sys.argv
|
||||||
|
self._parsed_args = self.get_parser().parse_args(args or sys.argv[1:])
|
||||||
|
for k,v in self._parsed_args.__dict__.items():
|
||||||
|
self[k] = v
|
||||||
|
|
||||||
def cli(args=None):
|
def cli(args=None):
|
||||||
app = App()
|
app = App()
|
||||||
|
|||||||
+81
-1
@@ -5,7 +5,7 @@
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
# Logging
|
# Logging
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
from pathlib import Path
|
||||||
import enum
|
import enum
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -67,3 +67,83 @@ def print(message):
|
|||||||
print(formatted.replace("', '", "',\n'"))
|
print(formatted.replace("', '", "',\n'"))
|
||||||
else:
|
else:
|
||||||
pp.pprint(message)
|
pp.pprint(message)
|
||||||
|
|
||||||
|
|
||||||
|
def get_level(
|
||||||
|
cmdline: str|list[str] = None,
|
||||||
|
verbose: bool = False,
|
||||||
|
debug: bool = False,
|
||||||
|
quiet: bool = False,
|
||||||
|
default: int = logging.INFO,
|
||||||
|
) -> int:
|
||||||
|
# Find lowest priority loglevel
|
||||||
|
if "--debug" in cmdline:
|
||||||
|
debug = True
|
||||||
|
if "--verbose" in cmdline:
|
||||||
|
verbose = True
|
||||||
|
if "--quiet" in cmdline:
|
||||||
|
quiet = True
|
||||||
|
|
||||||
|
if debug:
|
||||||
|
return logging.DEBUG
|
||||||
|
|
||||||
|
for i, a in enumerate(cmdline):
|
||||||
|
if a == "--loglevel":
|
||||||
|
loglevel = loglevel_map.get(cmdline[i+1], None)
|
||||||
|
elif a.startswith("--loglevel="):
|
||||||
|
loglevel = loglevel_map.get(a.replace("--loglevel=", ""), None)
|
||||||
|
|
||||||
|
# lower = more priority
|
||||||
|
if loglevel_map[loglevel] > loglevel_map["INFO"]:
|
||||||
|
config.logger.debug("self.loglevel='%s'(%d) > INFO", config.loglevel, loglevel_map[config.loglevel])
|
||||||
|
config.loglevel = "INFO"
|
||||||
|
|
||||||
|
def get_logger(
|
||||||
|
verbose:bool = False,
|
||||||
|
debug:bool = False,
|
||||||
|
quiet: bool = False,
|
||||||
|
loglevel: str|int = logging.INFO,
|
||||||
|
logfile: str|Path = None,
|
||||||
|
):
|
||||||
|
"""Initialize the package logger from CLI flags and defaults.
|
||||||
|
|
||||||
|
Resolve the effective logging level from the class args (or sys.argv).
|
||||||
|
--debug (or debug_level=True) takes priority, then --verbose (or verbose=True)
|
||||||
|
is compared to --loglevel (or loglevel=) and the lower one is proritized.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
config.logger.info("Setting loglevel to %s",config.loglevel)
|
||||||
|
config.logger.setLevel(loglevel_map[config.loglevel])
|
||||||
|
|
||||||
|
# Get logfile
|
||||||
|
for i, a in enumerate(config._args_list):
|
||||||
|
if a == "--logfile":
|
||||||
|
config.logfile = config._args_list[i+1]
|
||||||
|
elif a.startswith("--logfile="):
|
||||||
|
config.logfile = a.replace("--logfile=", "")
|
||||||
|
|
||||||
|
if "logfile" in config and config.logfile:
|
||||||
|
if logfile:
|
||||||
|
config.logger.warning("Overriding --logfile='%s' from setup_logger(logfile='%s')", config.logfile, logfile)
|
||||||
|
config.logfile = logfile
|
||||||
|
fh = logging.FileHandler(logfile)
|
||||||
|
fh.setLevel(config.loglevel)
|
||||||
|
fh.setFormatter(logger.formatter)
|
||||||
|
config.logger.addHandler(fh)
|
||||||
|
|
||||||
|
# if self.debug_level:
|
||||||
|
# logger.info("Loglevel: DEBUG")
|
||||||
|
# if self.loglevel:
|
||||||
|
# logger.warning("Ignoring '--loglevel' flag because DEBUG/'--debug' was also set.")
|
||||||
|
# if "--verbose" in self._args_list:
|
||||||
|
# logger.warning("Ignoring '--verbose' flag because '--debug' was also set.")
|
||||||
|
# self.verbose = False
|
||||||
|
# if self.loglevel and self.verbose:
|
||||||
|
# self.warning("Conflict: trying to --loglevel=%s (%d) and --verbose.", self.loglevel, loglevel_map[self.loglevel])
|
||||||
|
# if loglevel_map[self.loglevel] < logging.INFO:
|
||||||
|
# self.logger.info("Setting loglevel to %s (%d), which is more verbose than INFO.", self.loglevel, loglevel_map[self.loglevel])
|
||||||
|
|
||||||
|
|
||||||
|
return config.logger
|
||||||
@@ -10,12 +10,15 @@ import shutil
|
|||||||
import sys
|
import sys
|
||||||
import venv
|
import venv
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional, Union
|
||||||
from collections.abc import MutableMapping
|
from collections.abc import MutableMapping
|
||||||
|
|
||||||
from . import logger, PathLike
|
from . import logger, PathLike
|
||||||
|
|
||||||
|
|
||||||
|
def path(fpath: str|Path = None):
|
||||||
|
return Path(fpath).resolve()
|
||||||
|
|
||||||
class DataDict(dict):
|
class DataDict(dict):
|
||||||
"""Wrapper around dictionary objects for easier dict subclassing
|
"""Wrapper around dictionary objects for easier dict subclassing
|
||||||
See module [collections](https://github.com/python/cpython/blob/3.14/Lib/collections/__init__.py#L1133)
|
See module [collections](https://github.com/python/cpython/blob/3.14/Lib/collections/__init__.py#L1133)
|
||||||
|
|||||||
Reference in New Issue
Block a user