fix: align CLI with classifier list output and mount classifications
This commit is contained in:
parent
196a78da1d
commit
e6f6d18c8a
1 changed files with 280 additions and 203 deletions
|
|
@ -1,293 +1,370 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import sys
|
import json
|
||||||
|
import logging
|
||||||
|
import shlex
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Iterable
|
from typing import Any, Iterable
|
||||||
|
|
||||||
from .borg import (
|
from . import __version__
|
||||||
build_borg_create_command,
|
|
||||||
command_to_shell,
|
|
||||||
run_borg_create,
|
|
||||||
)
|
|
||||||
from .classifier import classify_compose
|
from .classifier import classify_compose
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger("dockervault")
|
||||||
def _get_value(obj: Any, *names: str, default: Any = None) -> Any:
|
|
||||||
for name in names:
|
|
||||||
if isinstance(obj, dict) and name in obj:
|
|
||||||
return obj[name]
|
|
||||||
if hasattr(obj, name):
|
|
||||||
return getattr(obj, name)
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_entries(entries: Any) -> list[dict[str, Any]]:
|
def setup_logging(verbose: bool = False) -> None:
|
||||||
|
level = logging.DEBUG if verbose else logging.INFO
|
||||||
|
logging.basicConfig(level=level, format="%(levelname)s: %(message)s")
|
||||||
|
|
||||||
|
|
||||||
|
def safe_get(obj: Any, key: str, default: Any = None) -> Any:
|
||||||
|
if obj is None:
|
||||||
|
return default
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return obj.get(key, default)
|
||||||
|
return getattr(obj, key, default)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_entries(entries: Any) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Normaliserer classifier-output til ensartede dict entries.
|
||||||
|
|
||||||
|
Understøtter:
|
||||||
|
- list[MountEntry]
|
||||||
|
- list[dict]
|
||||||
|
- enkeltobjekter
|
||||||
|
"""
|
||||||
if not entries:
|
if not entries:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
if not isinstance(entries, (list, tuple)):
|
||||||
|
entries = [entries]
|
||||||
|
|
||||||
normalized: list[dict[str, Any]] = []
|
normalized: list[dict[str, Any]] = []
|
||||||
|
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
if isinstance(entry, dict):
|
if isinstance(entry, dict):
|
||||||
normalized.append(
|
normalized.append(
|
||||||
{
|
{
|
||||||
"source": (
|
"path": entry.get("path") or entry.get("source") or entry.get("host_path"),
|
||||||
entry.get("source")
|
"priority": entry.get("priority") or entry.get("classification"),
|
||||||
or entry.get("path")
|
|
||||||
or entry.get("host_path")
|
|
||||||
or entry.get("src")
|
|
||||||
),
|
|
||||||
"service": entry.get("service"),
|
"service": entry.get("service"),
|
||||||
"target": (
|
"target": entry.get("target") or entry.get("container_path"),
|
||||||
entry.get("target")
|
"source_type": entry.get("source_type"),
|
||||||
or entry.get("mount_target")
|
|
||||||
or entry.get("container_path")
|
|
||||||
or entry.get("destination")
|
|
||||||
),
|
|
||||||
"classification": (
|
|
||||||
entry.get("classification")
|
|
||||||
or entry.get("priority")
|
|
||||||
or entry.get("category")
|
|
||||||
or entry.get("kind")
|
|
||||||
),
|
|
||||||
"reason": entry.get("reason"),
|
"reason": entry.get("reason"),
|
||||||
|
"exists": entry.get("exists"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
normalized.append(
|
||||||
|
{
|
||||||
|
"path": safe_get(entry, "path", safe_get(entry, "source")),
|
||||||
|
"priority": safe_get(entry, "priority", safe_get(entry, "classification")),
|
||||||
|
"service": safe_get(entry, "service"),
|
||||||
|
"target": safe_get(entry, "target", safe_get(entry, "container_path")),
|
||||||
|
"source_type": safe_get(entry, "source_type"),
|
||||||
|
"reason": safe_get(entry, "reason"),
|
||||||
|
"exists": safe_get(entry, "exists"),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
continue
|
|
||||||
|
|
||||||
normalized.append(
|
|
||||||
{
|
|
||||||
"source": _get_value(entry, "source", "path", "host_path", "src"),
|
|
||||||
"service": _get_value(entry, "service"),
|
|
||||||
"target": _get_value(
|
|
||||||
entry, "target", "mount_target", "container_path", "destination"
|
|
||||||
),
|
|
||||||
"classification": _get_value(
|
|
||||||
entry, "classification", "priority", "category", "kind"
|
|
||||||
),
|
|
||||||
"reason": _get_value(entry, "reason"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
def _extract_plan_sections(
|
def classify_entries(
|
||||||
plan: Any,
|
raw_plan: Any,
|
||||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]:
|
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]:
|
||||||
include_entries = _normalize_entries(
|
"""
|
||||||
_get_value(plan, "include", "include_paths", "includes", default=[])
|
classify_compose() returnerer aktuelt en liste af MountEntry.
|
||||||
)
|
Vi mapper dem til CLI-sektioner sådan her:
|
||||||
review_entries = _normalize_entries(
|
|
||||||
_get_value(plan, "review", "review_paths", "reviews", default=[])
|
- critical -> include
|
||||||
)
|
- optional -> skip
|
||||||
skip_entries = _normalize_entries(
|
- alt andet -> review
|
||||||
_get_value(plan, "skip", "skip_paths", "skips", default=[])
|
"""
|
||||||
)
|
entries = normalize_entries(raw_plan)
|
||||||
|
|
||||||
|
include_entries: list[dict[str, Any]] = []
|
||||||
|
review_entries: list[dict[str, Any]] = []
|
||||||
|
skip_entries: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
classification = (entry.get("priority") or "").strip().lower()
|
||||||
|
|
||||||
|
if classification == "critical":
|
||||||
|
include_entries.append(entry)
|
||||||
|
elif classification in {"optional", "skip", "ignored"}:
|
||||||
|
skip_entries.append(entry)
|
||||||
|
else:
|
||||||
|
review_entries.append(entry)
|
||||||
|
|
||||||
return include_entries, review_entries, skip_entries
|
return include_entries, review_entries, skip_entries
|
||||||
|
|
||||||
|
|
||||||
def _entry_path(entry: dict[str, Any]) -> str:
|
def extract_paths(entries: Iterable[dict[str, Any]]) -> list[str]:
|
||||||
return str(entry.get("source") or "(unknown)")
|
|
||||||
|
|
||||||
|
|
||||||
def _entry_label(entry: dict[str, Any]) -> str:
|
|
||||||
classification = entry.get("classification") or "unknown"
|
|
||||||
service = entry.get("service") or "unknown"
|
|
||||||
target = entry.get("target") or "unknown"
|
|
||||||
reason = entry.get("reason")
|
|
||||||
|
|
||||||
label = f"[{classification}] service={service} target={target}"
|
|
||||||
if reason:
|
|
||||||
label += f" reason={reason}"
|
|
||||||
return label
|
|
||||||
|
|
||||||
|
|
||||||
def _print_section(title: str, entries: Iterable[dict[str, Any]]) -> None:
|
|
||||||
entries = list(entries)
|
|
||||||
print(f"{title}:")
|
|
||||||
if not entries:
|
|
||||||
print(" - (none)")
|
|
||||||
return
|
|
||||||
|
|
||||||
for entry in entries:
|
|
||||||
print(f" - {_entry_path(entry):<40} {_entry_label(entry)}")
|
|
||||||
|
|
||||||
|
|
||||||
def _collect_include_paths(include_entries: Iterable[dict[str, Any]]) -> list[str]:
|
|
||||||
paths: list[str] = []
|
paths: list[str] = []
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
|
|
||||||
for entry in include_entries:
|
for entry in entries:
|
||||||
path = _entry_path(entry)
|
path = entry.get("path")
|
||||||
if path == "(unknown)" or path in seen:
|
if not path:
|
||||||
continue
|
continue
|
||||||
seen.add(path)
|
path_str = str(path)
|
||||||
paths.append(path)
|
if path_str not in seen:
|
||||||
|
seen.add(path_str)
|
||||||
|
paths.append(path_str)
|
||||||
|
|
||||||
return paths
|
return paths
|
||||||
|
|
||||||
|
|
||||||
def _print_borg_plan(
|
def entry_to_line(entry: dict[str, Any]) -> str:
|
||||||
compose_path: Path,
|
path = entry.get("path") or "(unknown)"
|
||||||
project_root: Path,
|
priority = entry.get("priority") or "unknown"
|
||||||
include_entries: list[dict[str, Any]],
|
service = entry.get("service") or "unknown"
|
||||||
review_entries: list[dict[str, Any]],
|
target = entry.get("target") or "unknown"
|
||||||
skip_entries: list[dict[str, Any]],
|
exists = entry.get("exists")
|
||||||
repo: str | None,
|
|
||||||
) -> None:
|
extra = []
|
||||||
|
if entry.get("source_type"):
|
||||||
|
extra.append(f"type={entry['source_type']}")
|
||||||
|
if exists is not None:
|
||||||
|
extra.append(f"exists={exists}")
|
||||||
|
if entry.get("reason"):
|
||||||
|
extra.append(f"reason={entry['reason']}")
|
||||||
|
|
||||||
|
suffix = f" ({', '.join(extra)})" if extra else ""
|
||||||
|
return f" - {path} [{priority}] service={service} target={target}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def default_archive_name() -> str:
|
||||||
|
hostname = socket.gethostname()
|
||||||
|
now = datetime.now().strftime("%Y-%m-%d_%H-%M")
|
||||||
|
return f"{hostname}-{now}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_borg_command(repo: str, archive_name: str, include_paths: list[str]) -> list[str]:
|
||||||
|
if not repo or not include_paths:
|
||||||
|
return []
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"borg",
|
||||||
|
"create",
|
||||||
|
"--stats",
|
||||||
|
"--progress",
|
||||||
|
f"{repo}::{archive_name}",
|
||||||
|
]
|
||||||
|
cmd.extend(include_paths)
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
def print_human_plan(raw_plan: Any, compose_path: Path, repo: str | None = None) -> None:
|
||||||
|
include_entries, review_entries, skip_entries = classify_entries(raw_plan)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print("Borg Backup Plan")
|
print("DockerVault Backup Plan")
|
||||||
print("================")
|
print("=======================")
|
||||||
print(f"Compose file: {compose_path}")
|
print(f"Compose file: {compose_path.resolve()}")
|
||||||
print(f"Project root: {project_root}")
|
print(f"Project root: {compose_path.resolve().parent}")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
_print_section("INCLUDE PATHS", include_entries)
|
print("INCLUDE PATHS:")
|
||||||
|
if include_entries:
|
||||||
|
for entry in include_entries:
|
||||||
|
print(entry_to_line(entry))
|
||||||
|
else:
|
||||||
|
print(" - (none)")
|
||||||
print()
|
print()
|
||||||
_print_section("REVIEW PATHS", review_entries)
|
|
||||||
|
print("REVIEW PATHS:")
|
||||||
|
if review_entries:
|
||||||
|
for entry in review_entries:
|
||||||
|
print(entry_to_line(entry))
|
||||||
|
else:
|
||||||
|
print(" - (none)")
|
||||||
print()
|
print()
|
||||||
_print_section("SKIP PATHS", skip_entries)
|
|
||||||
|
|
||||||
include_paths = _collect_include_paths(include_entries)
|
print("SKIP PATHS:")
|
||||||
|
if skip_entries:
|
||||||
|
for entry in skip_entries:
|
||||||
|
print(entry_to_line(entry))
|
||||||
|
else:
|
||||||
|
print(" - (none)")
|
||||||
|
print()
|
||||||
|
|
||||||
if repo and include_paths:
|
if repo:
|
||||||
command = build_borg_create_command(
|
include_paths = extract_paths(include_entries)
|
||||||
|
archive_name = default_archive_name()
|
||||||
|
cmd = build_borg_command(repo=repo, archive_name=archive_name, include_paths=include_paths)
|
||||||
|
|
||||||
|
print("Suggested borg create command")
|
||||||
|
print("============================")
|
||||||
|
if cmd:
|
||||||
|
rendered = " \\\n ".join(shlex.quote(part) for part in cmd)
|
||||||
|
print(rendered)
|
||||||
|
else:
|
||||||
|
print("(no command generated)")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def print_automation_output(raw_plan: Any, compose_path: Path, repo: str | None = None) -> None:
|
||||||
|
include_entries, review_entries, skip_entries = classify_entries(raw_plan)
|
||||||
|
include_paths = extract_paths(include_entries)
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"compose_file": str(compose_path.resolve()),
|
||||||
|
"project_root": str(compose_path.resolve().parent),
|
||||||
|
"include_paths": include_paths,
|
||||||
|
"review_paths": [str(e["path"]) for e in review_entries if e.get("path")],
|
||||||
|
"skip_paths": [str(e["path"]) for e in skip_entries if e.get("path")],
|
||||||
|
}
|
||||||
|
|
||||||
|
if repo:
|
||||||
|
payload["borg_repo"] = repo
|
||||||
|
payload["suggested_archive_name"] = default_archive_name()
|
||||||
|
payload["borg_command"] = build_borg_command(
|
||||||
repo=repo,
|
repo=repo,
|
||||||
|
archive_name=payload["suggested_archive_name"],
|
||||||
include_paths=include_paths,
|
include_paths=include_paths,
|
||||||
)
|
)
|
||||||
print()
|
|
||||||
print("Suggested borg create command")
|
print(json.dumps(payload, indent=2))
|
||||||
print("=============================")
|
|
||||||
print(command_to_shell(command))
|
|
||||||
|
|
||||||
|
|
||||||
def build_parser() -> argparse.ArgumentParser:
|
def run_borg(repo: str, include_paths: list[str], dry_run: bool = False) -> int:
|
||||||
|
if not include_paths:
|
||||||
|
LOGGER.warning("No include paths found. Nothing to back up.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
archive_name = default_archive_name()
|
||||||
|
cmd = build_borg_command(repo=repo, archive_name=archive_name, include_paths=include_paths)
|
||||||
|
|
||||||
|
LOGGER.info("Borg repository: %s", repo)
|
||||||
|
LOGGER.info("Archive name: %s", archive_name)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
LOGGER.info("Dry-run enabled. Borg command will not be executed.")
|
||||||
|
print(" ".join(shlex.quote(part) for part in cmd))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
LOGGER.info("Running borg backup...")
|
||||||
|
result = subprocess.run(cmd, text=True)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
LOGGER.error("Borg exited with status %s", result.returncode)
|
||||||
|
return result.returncode
|
||||||
|
|
||||||
|
LOGGER.info("Borg backup completed successfully.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="dockervault",
|
prog="dockervault",
|
||||||
description="DockerVault - intelligent Docker backup discovery",
|
description="Intelligent Docker backup discovery with Borg integration",
|
||||||
)
|
|
||||||
|
|
||||||
parser.add_argument("compose", help="Path to docker-compose.yml")
|
|
||||||
|
|
||||||
parser.add_argument(
|
|
||||||
"--borg",
|
|
||||||
action="store_true",
|
|
||||||
help="Show borg backup plan and suggested command",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
parser.add_argument("compose", help="Path to docker-compose.yml or compose.yaml")
|
||||||
|
parser.add_argument("--repo", help="Borg repository path")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--run-borg",
|
"--run-borg",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Run borg create using discovered include paths",
|
help="Run borg create after building the backup plan",
|
||||||
)
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--repo",
|
"--dry-run",
|
||||||
help="Borg repository path, e.g. /mnt/backups/borg/dockervault",
|
|
||||||
)
|
|
||||||
|
|
||||||
parser.add_argument(
|
|
||||||
"--passphrase",
|
|
||||||
help="Optional borg passphrase",
|
|
||||||
)
|
|
||||||
|
|
||||||
parser.add_argument(
|
|
||||||
"--quiet",
|
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Suppress borg stdout/stderr output during execution",
|
help="Show borg command without executing it",
|
||||||
)
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--automation",
|
"--automation",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Automation mode: minimal output, non-interactive behavior",
|
help="Output machine-readable JSON",
|
||||||
)
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--fail-on-review",
|
"--quiet",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Exit with code 4 if review paths are present",
|
help="Suppress normal human-readable plan output",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--verbose",
|
||||||
|
action="store_true",
|
||||||
|
help="Enable verbose logging",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--version",
|
||||||
|
action="version",
|
||||||
|
version=f"%(prog)s {__version__}",
|
||||||
)
|
)
|
||||||
|
|
||||||
return parser
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
parser = build_parser()
|
args = parse_args(argv)
|
||||||
args = parser.parse_args()
|
setup_logging(args.verbose)
|
||||||
|
|
||||||
compose_path = Path(args.compose).expanduser().resolve()
|
compose_path = Path(args.compose)
|
||||||
|
|
||||||
if not compose_path.exists():
|
|
||||||
print(f"Error: compose file not found: {compose_path}", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
plan = classify_compose(compose_path)
|
if not compose_path.exists():
|
||||||
except Exception as exc:
|
LOGGER.error("Compose file not found: %s", compose_path)
|
||||||
print(f"Error: failed to classify compose file: {exc}", file=sys.stderr)
|
return 1
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
include_entries, review_entries, skip_entries = _extract_plan_sections(plan)
|
if not compose_path.is_file():
|
||||||
include_paths = _collect_include_paths(include_entries)
|
LOGGER.error("Compose path is not a file: %s", compose_path)
|
||||||
project_root = compose_path.parent
|
return 1
|
||||||
|
|
||||||
should_show_plan = args.borg or (not args.automation and not args.quiet)
|
raw_plan = classify_compose(compose_path)
|
||||||
|
|
||||||
if should_show_plan:
|
if args.verbose:
|
||||||
_print_borg_plan(
|
LOGGER.debug("Raw plan type: %s", type(raw_plan))
|
||||||
compose_path=compose_path,
|
LOGGER.debug("Raw plan repr: %r", raw_plan)
|
||||||
project_root=project_root,
|
|
||||||
include_entries=include_entries,
|
|
||||||
review_entries=review_entries,
|
|
||||||
skip_entries=skip_entries,
|
|
||||||
repo=args.repo,
|
|
||||||
)
|
|
||||||
|
|
||||||
if args.fail_on_review and review_entries:
|
include_entries, _, _ = classify_entries(raw_plan)
|
||||||
if args.automation or args.quiet:
|
include_paths = extract_paths(include_entries)
|
||||||
print("REVIEW required", file=sys.stderr)
|
|
||||||
else:
|
|
||||||
print()
|
|
||||||
print("Review required before automated backup can proceed.", file=sys.stderr)
|
|
||||||
sys.exit(4)
|
|
||||||
|
|
||||||
if args.run_borg:
|
if args.automation:
|
||||||
if not args.repo:
|
print_automation_output(raw_plan, compose_path, repo=args.repo)
|
||||||
print("Error: --run-borg requires --repo", file=sys.stderr)
|
elif not args.quiet:
|
||||||
sys.exit(2)
|
print_human_plan(raw_plan, compose_path, repo=args.repo)
|
||||||
|
|
||||||
if not include_paths:
|
if args.run_borg:
|
||||||
print("Error: no include paths found for borg backup", file=sys.stderr)
|
if not args.repo:
|
||||||
sys.exit(3)
|
LOGGER.error("--repo is required when using --run-borg")
|
||||||
|
return 2
|
||||||
|
|
||||||
if not args.quiet:
|
return run_borg(
|
||||||
print()
|
repo=args.repo,
|
||||||
print("Running borg backup...")
|
include_paths=include_paths,
|
||||||
print("======================")
|
dry_run=args.dry_run,
|
||||||
|
)
|
||||||
|
|
||||||
exit_code = run_borg_create(
|
if args.dry_run:
|
||||||
repo=args.repo,
|
if not args.repo:
|
||||||
include_paths=include_paths,
|
LOGGER.error("--repo is required when using --dry-run")
|
||||||
passphrase=args.passphrase,
|
return 2
|
||||||
quiet=args.quiet,
|
|
||||||
stats=not args.quiet,
|
|
||||||
progress=not args.quiet,
|
|
||||||
)
|
|
||||||
|
|
||||||
if exit_code != 0:
|
archive_name = default_archive_name()
|
||||||
print(f"Error: borg exited with status {exit_code}", file=sys.stderr)
|
cmd = build_borg_command(args.repo, archive_name, include_paths)
|
||||||
sys.exit(exit_code)
|
print(" ".join(shlex.quote(part) for part in cmd))
|
||||||
|
return 0
|
||||||
|
|
||||||
if not args.quiet:
|
return 0
|
||||||
print()
|
|
||||||
print("Borg backup completed successfully.")
|
|
||||||
|
|
||||||
sys.exit(0)
|
except FileNotFoundError as exc:
|
||||||
|
LOGGER.error("File not found: %s", exc)
|
||||||
|
return 1
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
LOGGER.error("Interrupted by user")
|
||||||
|
return 130
|
||||||
|
except Exception:
|
||||||
|
LOGGER.exception("Unexpected error")
|
||||||
|
return 99
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
raise SystemExit(main())
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue