Clean up main and improve error handling

This commit is contained in:
Joscha 2021-05-22 16:47:24 +02:00
parent b5785f260e
commit 54dd2f8337
3 changed files with 125 additions and 56 deletions

View File

@ -4,15 +4,13 @@ import configparser
from pathlib import Path from pathlib import Path
from .cli import PARSER, load_default_section from .cli import PARSER, load_default_section
from .config import Config, ConfigDumpException, ConfigLoadException from .config import Config, ConfigDumpError, ConfigLoadError, ConfigOptionError
from .logging import log from .logging import log
from .pferd import Pferd from .pferd import Pferd
from .version import NAME, VERSION from .version import NAME, VERSION
def load_parser( def load_config_parser(args: argparse.Namespace) -> configparser.ConfigParser:
args: argparse.Namespace,
) -> configparser.ConfigParser:
log.explain_topic("Loading config") log.explain_topic("Loading config")
parser = configparser.ConfigParser() parser = configparser.ConfigParser()
@ -47,46 +45,88 @@ def prune_crawlers(
# TODO Check if crawlers actually exist # TODO Check if crawlers actually exist
def main() -> None: def load_config(args: argparse.Namespace) -> Config:
args = PARSER.parse_args() try:
return Config(load_config_parser(args))
except ConfigLoadError as e:
log.error(str(e))
log.error_contd(e.reason)
exit(1)
# Configure log levels set by command line arguments
def configure_logging_from_args(args: argparse.Namespace) -> None:
if args.explain is not None: if args.explain is not None:
log.output_explain = args.explain log.output_explain = args.explain
if args.dump_config:
# We want to prevent any unnecessary output if we're printing the config to
# stdout, otherwise it would not be a valid config file.
if args.dump_config == "-":
log.output_explain = False log.output_explain = False
def configure_logging_from_config(args: argparse.Namespace, config: Config) -> None:
# In configure_logging_from_args(), all normal logging is already disabled
# whenever we dump the config. We don't want to override that decision with
# values from the config file.
if args.dump_config == "-":
return
try:
if args.explain is None:
log.output_explain = config.default_section.explain()
except ConfigOptionError as e:
log.error(str(e))
exit(1)
def dump_config(args: argparse.Namespace, config: Config) -> None:
try:
if args.dump_config is True:
config.dump()
elif args.dump_config == "-":
config.dump_to_stdout()
else:
config.dump(Path(args.dump_config))
except ConfigDumpError as e:
log.error(str(e))
log.error_contd(e.reason)
exit(1)
def main() -> None:
args = PARSER.parse_args()
if args.version: if args.version:
print(f"{NAME} {VERSION}") print(f"{NAME} {VERSION}")
exit() exit()
try: # Configuring logging happens in two stages because CLI args have
config = Config(load_parser(args)) # precedence over config file options and loading the config already
except ConfigLoadException as e: # produces some kinds of log messages (usually only explain()-s).
log.error(f"Failed to load config file at path {str(e.path)!r}") configure_logging_from_args(args)
log.error_contd(f"Reason: {e.reason}")
exit(1)
# Configure log levels set in the config file config = load_config(args)
# TODO Catch config section exceptions
if args.explain is None: # Now, after loading the config file, we can apply its logging settings in
log.output_explain = config.default_section.explain() # all places that were not already covered by CLI args.
configure_logging_from_config(args, config)
if args.dump_config is not None: if args.dump_config is not None:
try: dump_config(args, config)
if args.dump_config is True:
config.dump()
elif args.dump_config == "-":
config.dump_to_stdout()
else:
config.dump(Path(args.dump_config))
except ConfigDumpException:
exit(1)
exit() exit()
# TODO Unset exclusive output on exceptions (if it was being held)
pferd = Pferd(config) pferd = Pferd(config)
try: try:
asyncio.run(pferd.run()) asyncio.run(pferd.run())
except KeyboardInterrupt: except KeyboardInterrupt:
log.explain_topic("Interrupted, exiting immediately")
log.explain("Open files and connections are left for the OS to clean up")
log.explain("Temporary files are not cleaned up")
# TODO Clean up tmp files # TODO Clean up tmp files
pass # And when those files *do* actually get cleaned up properly,
# reconsider what exit code to use here.
exit(1)
except Exception:
log.unexpected_exception()
exit(1)

View File

@ -2,7 +2,6 @@ import asyncio
import os import os
import sys import sys
from configparser import ConfigParser, SectionProxy from configparser import ConfigParser, SectionProxy
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, List, NoReturn, Optional, Tuple from typing import Any, List, NoReturn, Optional, Tuple
@ -10,21 +9,34 @@ from .logging import log
from .utils import prompt_yes_no from .utils import prompt_yes_no
@dataclass class ConfigLoadError(Exception):
class ConfigLoadException(Exception): """
path: Path Something went wrong while loading the config from a file.
reason: str """
def __init__(self, path: Path, reason: str):
super().__init__(f"Failed to load config from {path}")
self.path = path
self.reason = reason
class ConfigDumpException(Exception): class ConfigOptionError(Exception):
pass """
An option in the config file has an invalid or missing value.
"""
def __init__(self, section: str, key: str, desc: str):
super().__init__(f"Section {section!r}, key {key!r}: {desc}")
self.section = section
self.key = key
self.desc = desc
@dataclass class ConfigDumpError(Exception):
class ConfigFormatException(Exception): def __init__(self, path: Path, reason: str):
section: str super().__init__(f"Failed to dump config to {path}")
key: str self.path = path
desc: str self.reason = reason
class Section: class Section:
@ -36,7 +48,7 @@ class Section:
self.s = section self.s = section
def error(self, key: str, desc: str) -> NoReturn: def error(self, key: str, desc: str) -> NoReturn:
raise ConfigFormatException(self.s.name, key, desc) raise ConfigOptionError(self.s.name, key, desc)
def invalid_value( def invalid_value(
self, self,
@ -83,7 +95,7 @@ class Config:
@staticmethod @staticmethod
def load_parser(parser: ConfigParser, path: Optional[Path] = None) -> None: def load_parser(parser: ConfigParser, path: Optional[Path] = None) -> None:
""" """
May throw a ConfigLoadException. May throw a ConfigLoadError.
""" """
if path: if path:
@ -99,21 +111,15 @@ class Config:
with open(path) as f: with open(path) as f:
parser.read_file(f, source=str(path)) parser.read_file(f, source=str(path))
except FileNotFoundError: except FileNotFoundError:
raise ConfigLoadException(path, "File does not exist") raise ConfigLoadError(path, "File does not exist")
except IsADirectoryError: except IsADirectoryError:
raise ConfigLoadException(path, "That's a directory, not a file") raise ConfigLoadError(path, "That's a directory, not a file")
except PermissionError: except PermissionError:
raise ConfigLoadException(path, "Insufficient permissions") raise ConfigLoadError(path, "Insufficient permissions")
@staticmethod
def _fail_dump(path: Path, reason: str) -> None:
print(f"Failed to dump config file to {path}")
print(f"Reason: {reason}")
raise ConfigDumpException()
def dump(self, path: Optional[Path] = None) -> None: def dump(self, path: Optional[Path] = None) -> None:
""" """
May throw a ConfigDumpException. May throw a ConfigDumpError.
""" """
if not path: if not path:
@ -124,7 +130,7 @@ class Config:
try: try:
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
except PermissionError: except PermissionError:
self._fail_dump(path, "Could not create parent directory") raise ConfigDumpError(path, "Could not create parent directory")
try: try:
# Ensuring we don't accidentally overwrite any existing files by # Ensuring we don't accidentally overwrite any existing files by
@ -140,11 +146,11 @@ class Config:
with open(path, "w") as f: with open(path, "w") as f:
self._parser.write(f) self._parser.write(f)
else: else:
self._fail_dump(path, "File already exists") raise ConfigDumpError(path, "File already exists")
except IsADirectoryError: except IsADirectoryError:
self._fail_dump(path, "That's a directory, not a file") raise ConfigDumpError(path, "That's a directory, not a file")
except PermissionError: except PermissionError:
self._fail_dump(path, "Insufficient permissions") raise ConfigDumpError(path, "Insufficient permissions")
def dump_to_stdout(self) -> None: def dump_to_stdout(self) -> None:
self._parser.write(sys.stdout) self._parser.write(sys.stdout)

View File

@ -1,4 +1,6 @@
import asyncio import asyncio
import sys
import traceback
from contextlib import asynccontextmanager, contextmanager from contextlib import asynccontextmanager, contextmanager
# TODO In Python 3.9 and above, ContextManager and AsyncContextManager are deprecated # TODO In Python 3.9 and above, ContextManager and AsyncContextManager are deprecated
from typing import AsyncIterator, ContextManager, Iterator, List, Optional from typing import AsyncIterator, ContextManager, Iterator, List, Optional
@ -110,6 +112,27 @@ class Log:
def error_contd(self, text: str) -> None: def error_contd(self, text: str) -> None:
self.print(f"[red]{escape(text)}") self.print(f"[red]{escape(text)}")
def unexpected_exception(self) -> None:
t, v, tb = sys.exc_info()
self.error("An unexpected exception occurred")
self.error_contd("")
for line in traceback.format_tb(tb):
self.error_contd(line[:-1]) # Without trailing newline
if str(v):
self.error_contd(f"{t.__name__}: {v}")
else:
self.error_contd(t.__name__)
self.error_contd("")
self.error_contd("""
An unexpected exception occurred. This usually shouldn't happen. Please copy
your program output and send it to the PFERD maintainers, either directly or as
a GitHub issue: https://github.com/Garmelon/PFERD/issues/new
""".strip())
def explain_topic(self, text: str) -> None: def explain_topic(self, text: str) -> None:
if self.output_explain: if self.output_explain:
self.print(f"[cyan]{escape(text)}") self.print(f"[cyan]{escape(text)}")