#!/usr/bin/env python3 """ Match local video filenames against canonical WCX movie names. The script is deliberately conservative and read-only. Matching sources: - Current canonical name from movie.name - Verified aliases from movie_name_alias - Current duration from movie.duration_seconds - Historical durations from movie_history.duration_seconds - Actual file duration read with ffprobe Important principles: - Duration never creates a candidate without a name or alias match. - A close duration may resolve an otherwise ambiguous name match. - The least-wrong duration does not win when all candidates have poor matches. - The current canonical movie name is always displayed. - Files and database contents are never modified. Examples: match_filenames.py /storage/disk1/X \ --database /path/to/wcx.db match_filenames.py /storage/disk1/X \ --database /path/to/wcx.db \ --recursive match_filenames.py /storage/disk1/X \ --database /path/to/wcx.db \ --ending mp4,avi match_filenames.py /storage/disk1/X \ --database /path/to/wcx.db \ --ending mp4 \ --ending avi \ --debug """ import argparse import json import os import shutil import sqlite3 import subprocess import sys import unicodedata from dataclasses import dataclass, replace from pathlib import Path VIDEO_EXTENSIONS = { ".avi", ".m4v", ".mkv", ".mov", ".mp4", ".mpeg", ".mpg", ".ts", ".webm", ".wmv", } MIN_NAME_SCORE = 100 MIN_NAME_MARGIN = 15 # At least this score is considered credible duration support. MIN_CREDIBLE_DURATION_SCORE = 10 # Difference between duration scores required to resolve several name matches. MIN_DURATION_SCORE_MARGIN = 15 @dataclass(frozen=True) class MatchName: """ One searchable name belonging to a movie. source is normally: current manual history csv """ display_name: str normalized_name: str normalized_persons: tuple[str, ...] source: str @dataclass(frozen=True) class DurationVersion: duration_seconds: int source: str archived_at: str | None = None @dataclass(frozen=True) class Movie: movie_id: str name: str match_names: tuple[MatchName, ...] duration_versions: tuple[DurationVersion, ...] @dataclass(frozen=True) class DurationMatch: score: int classification: str matched_duration: int | None difference_seconds: int | None source: str | None archived_at: str | None @dataclass(frozen=True) class MatchCandidate: movie: Movie matched_name: MatchName name_score: int duration_score: int total_score: int name_reason: str duration_reason: str full_name_occurrences: int all_persons_matched: bool duration_match: DurationMatch @dataclass(frozen=True) class MatchResult: status: str best: MatchCandidate | None margin: int reason: str detected_movies: tuple[str, ...] def parse_arguments() -> argparse.Namespace: parser = argparse.ArgumentParser( description=( "Match local video filenames against canonical WCX movie names." ) ) parser.add_argument( "directory", type=Path, help="Directory containing video files.", ) parser.add_argument( "--database", type=Path, required=True, help="SQLite database", ) parser.add_argument( "--recursive", action="store_true", help="Search recursively below the input directory.", ) parser.add_argument( "--ending", action="append", help=( "Video file endings to include. Examples: " "'--ending mp4,avi' or '--ending mp4 --ending avi'. " "A leading dot is optional. " "Default: all supported video endings." ), ) parser.add_argument( "--limit", type=int, help="Analyze at most N files.", ) parser.add_argument( "--debug", action="store_true", help="Show candidates, scores, durations and classification details.", ) parser.add_argument( "--detection-report", type=Path, help=( "JSON detection report; only files classified as wcx " "will be matched." ), ) return parser.parse_args() def normalize_text(value: str) -> str: decomposed = unicodedata.normalize("NFKD", value) without_diacritics = "".join( character for character in decomposed if not unicodedata.combining(character) ) return "".join( character.casefold() for character in without_diacritics if character.isalnum() ) def split_person_names(value: str) -> tuple[str, ...]: persons = [] for part in value.split("+"): normalized = normalize_text(part) if normalized: persons.append(normalized) return tuple(persons) def normalize_duration(value: object) -> int | None: if value is None: return None try: duration = int(value) except (TypeError, ValueError): return None if duration <= 0: return None return duration def parse_video_extensions( ending_arguments: list[str] | None, ) -> set[str]: """ Parse --ending arguments. Supported examples: --ending mp4,avi --ending .mp4,.avi --ending mp4 --ending avi Without --ending, all extensions in VIDEO_EXTENSIONS are used. """ if not ending_arguments: return set(VIDEO_EXTENSIONS) extensions: set[str] = set() for argument in ending_arguments: for value in argument.split(","): extension = value.strip().casefold() if not extension: continue if not extension.startswith("."): extension = f".{extension}" extensions.add(extension) if not extensions: raise ValueError( "--ending did not contain any valid file endings." ) return extensions def normalize_absolute_path(path: Path) -> Path: return Path(os.path.abspath(path)) def load_detection_report( report_file: Path, ) -> dict[Path, str]: if not report_file.is_file(): raise FileNotFoundError( f"Detection report does not exist: {report_file}" ) try: with report_file.open(encoding="utf-8") as file: report = json.load(file) except json.JSONDecodeError as error: raise ValueError( f"Detection report is not valid JSON: {report_file}: {error}" ) from error if not isinstance(report, dict): raise ValueError( "Detection report top level must be a JSON object." ) schema_version = report.get("schema_version") if type(schema_version) is not int or schema_version != 1: raise ValueError( "Detection report schema_version must be 1." ) entries = report.get("files") if not isinstance(entries, list): raise ValueError( "Detection report files must be a list." ) allowed_classifications = { "wcx", "uncertain", "not_wcx", "error", } classifications_by_path = {} for index, entry in enumerate(entries): if not isinstance(entry, dict): raise ValueError( f"Detection report files[{index}] must be an object." ) if "path" not in entry: raise ValueError( f"Detection report files[{index}] is missing path." ) path_value = entry["path"] if not isinstance(path_value, str): raise ValueError( f"Detection report files[{index}].path must be a string." ) if "classification" not in entry: raise ValueError( f"Detection report files[{index}] is missing classification." ) classification = entry["classification"] if not isinstance(classification, str): raise ValueError( f"Detection report files[{index}].classification " f"must be a string." ) if classification not in allowed_classifications: raise ValueError( f"Detection report files[{index}] has unknown " f"classification: {classification}" ) normalized_path = normalize_absolute_path( Path(path_value) ) if normalized_path in classifications_by_path: raise ValueError( f"Detection report contains duplicate path: " f"{normalized_path}" ) classifications_by_path[normalized_path] = classification return classifications_by_path def select_wcx_files( inventoried_files: list[Path], report_entries: dict[Path, str], ) -> list[Path]: wcx_paths = { path for path, classification in report_entries.items() if classification == "wcx" } return [ path for path in inventoried_files if normalize_absolute_path(path) in wcx_paths ] def load_movies(database_file: Path) -> list[Movie]: if not database_file.is_file(): raise FileNotFoundError( f"Database file does not exist: {database_file}" ) database_uri = f"{database_file.resolve().as_uri()}?mode=ro" with sqlite3.connect(database_uri, uri=True) as connection: connection.row_factory = sqlite3.Row movie_rows = connection.execute( """ SELECT id, name, duration_seconds FROM movie WHERE name IS NOT NULL AND TRIM(name) <> '' ORDER BY name, id """ ).fetchall() alias_rows = connection.execute( """ SELECT movie_id, alias, normalized_alias, source FROM movie_name_alias WHERE alias IS NOT NULL AND TRIM(alias) <> '' ORDER BY movie_id, alias """ ).fetchall() history_rows = connection.execute( """ SELECT movie_id, duration_seconds, archived_at FROM movie_history WHERE duration_seconds IS NOT NULL AND duration_seconds > 0 ORDER BY movie_id, archived_at """ ).fetchall() aliases_by_movie: dict[str, list[MatchName]] = {} for row in alias_rows: movie_id = str(row["movie_id"]) alias = str(row["alias"]) normalized_alias = ( str(row["normalized_alias"]).strip() if row["normalized_alias"] else normalize_text(alias) ) if not normalized_alias: continue aliases_by_movie.setdefault( movie_id, [], ).append( MatchName( display_name=alias, normalized_name=normalized_alias, normalized_persons=split_person_names(alias), source=str(row["source"] or "alias"), ) ) historical_durations_by_movie: dict[ str, list[DurationVersion], ] = {} for row in history_rows: duration = normalize_duration( row["duration_seconds"] ) if duration is None: continue movie_id = str(row["movie_id"]) historical_durations_by_movie.setdefault( movie_id, [], ).append( DurationVersion( duration_seconds=duration, source="historical", archived_at=( str(row["archived_at"]) if row["archived_at"] is not None else None ), ) ) movies = [] for row in movie_rows: movie_id = str(row["id"]) current_name = str(row["name"]) current_match_name = MatchName( display_name=current_name, normalized_name=normalize_text(current_name), normalized_persons=split_person_names(current_name), source="current", ) match_names = [current_match_name] seen_normalized_names = { current_match_name.normalized_name } for alias in aliases_by_movie.get(movie_id, []): if alias.normalized_name in seen_normalized_names: continue seen_normalized_names.add( alias.normalized_name ) match_names.append(alias) duration_versions: list[DurationVersion] = [] seen_durations: set[int] = set() current_duration = normalize_duration( row["duration_seconds"] ) if current_duration is not None: duration_versions.append( DurationVersion( duration_seconds=current_duration, source="current", ) ) seen_durations.add(current_duration) for historical in historical_durations_by_movie.get( movie_id, [], ): if historical.duration_seconds in seen_durations: continue seen_durations.add( historical.duration_seconds ) duration_versions.append(historical) movies.append( Movie( movie_id=movie_id, name=current_name, match_names=tuple(match_names), duration_versions=tuple(duration_versions), ) ) return movies def should_ignore_file(path: Path) -> bool: """ Ignore common operating-system metadata and resource-fork files. macOS commonly creates files such as: ._Video.mp4 .DS_Store AppleDouble files may have a valid video extension but are not videos. """ name = path.name if name.startswith("._"): return True if name in { ".DS_Store", "Thumbs.db", "desktop.ini", }: return True return False def find_video_files( directory: Path, recursive: bool, extensions: set[str], ) -> list[Path]: if not directory.is_dir(): raise NotADirectoryError( f"Input directory does not exist: {directory}" ) iterator = ( directory.rglob("*") if recursive else directory.glob("*") ) files = [] for path in iterator: if not path.is_file(): continue if should_ignore_file(path): continue if path.suffix.casefold() not in extensions: continue files.append(path) return sorted( files, key=lambda path: str(path).casefold(), ) def read_file_duration(file_path: Path) -> int | None: result = subprocess.run( [ "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", str(file_path), ], capture_output=True, text=True, check=False, ) if result.returncode != 0: return None output = result.stdout.strip() if not output: return None try: duration = round(float(output)) except ValueError: return None return duration if duration > 0 else None def calculate_duration_score( difference_seconds: int, reference_duration: int, ) -> int: relative_difference = ( difference_seconds / reference_duration if reference_duration > 0 else 1.0 ) if difference_seconds <= 15: return 50 if difference_seconds <= 60: return 40 if difference_seconds <= 180: return 25 if relative_difference <= 0.05: return 20 if difference_seconds <= 600: return 10 return 0 def classify_poor_duration( file_duration: int, reference_duration: int, ) -> str: if file_duration < reference_duration: return "shorter_than_known_version" if file_duration > reference_duration: return "longer_than_known_version" return "duration_mismatch" def match_duration( file_duration: int | None, movie: Movie, ) -> DurationMatch: if file_duration is None: return DurationMatch( score=0, classification="file_duration_unknown", matched_duration=None, difference_seconds=None, source=None, archived_at=None, ) if not movie.duration_versions: return DurationMatch( score=0, classification="database_duration_unknown", matched_duration=None, difference_seconds=None, source=None, archived_at=None, ) scored_versions = [] for version in movie.duration_versions: difference = abs( file_duration - version.duration_seconds ) score = calculate_duration_score( difference_seconds=difference, reference_duration=version.duration_seconds, ) scored_versions.append( ( score, -difference, version.source == "current", version, difference, ) ) ( score, _negative_difference, _prefer_current, best_version, difference, ) = max(scored_versions) if score > 0: classification = ( "current_version_match" if best_version.source == "current" else "historical_version_match" ) else: classification = classify_poor_duration( file_duration=file_duration, reference_duration=best_version.duration_seconds, ) return DurationMatch( score=score, classification=classification, matched_duration=best_version.duration_seconds, difference_seconds=difference, source=best_version.source, archived_at=best_version.archived_at, ) def score_match_name( normalized_filename: str, movie: Movie, match_name: MatchName, ) -> MatchCandidate | None: full_name_occurrences = normalized_filename.count( match_name.normalized_name ) matched_persons = [ person for person in match_name.normalized_persons if person in normalized_filename ] all_persons_matched = ( bool(match_name.normalized_persons) and len(matched_persons) == len(match_name.normalized_persons) ) if full_name_occurrences == 0 and not all_persons_matched: return None name_score = 0 reasons = [] if full_name_occurrences: name_score += 80 if match_name.source == "current": reasons.append("exact current-name match") else: reasons.append( f"exact alias match: {match_name.display_name}" ) if full_name_occurrences > 1: repetition_bonus = min( 15, (full_name_occurrences - 1) * 5, ) name_score += repetition_bonus reasons.append( f"matched name occurs " f"{full_name_occurrences} times" ) if all_persons_matched: name_score += 20 reasons.append("all persons matched") if len(match_name.normalized_persons) > 1: name_score += 10 reasons.append("multi-person name matched") specificity_bonus = min( 10, len(match_name.normalized_name) // 5, ) name_score += specificity_bonus reasons.append( f"specificity bonus {specificity_bonus}" ) empty_duration_match = DurationMatch( score=0, classification="not_evaluated", matched_duration=None, difference_seconds=None, source=None, archived_at=None, ) return MatchCandidate( movie=movie, matched_name=match_name, name_score=name_score, duration_score=0, total_score=name_score, name_reason=", ".join(reasons), duration_reason="duration not evaluated", full_name_occurrences=full_name_occurrences, all_persons_matched=all_persons_matched, duration_match=empty_duration_match, ) def best_name_candidate_for_movie( normalized_filename: str, movie: Movie, ) -> MatchCandidate | None: candidates = [] for match_name in movie.match_names: candidate = score_match_name( normalized_filename=normalized_filename, movie=movie, match_name=match_name, ) if candidate is not None: candidates.append(candidate) if not candidates: return None return max( candidates, key=lambda candidate: ( candidate.name_score, candidate.matched_name.source == "current", len(candidate.matched_name.normalized_persons), len(candidate.matched_name.normalized_name), ), ) def describe_duration_match( duration_match: DurationMatch, ) -> str: classification = duration_match.classification if classification == "file_duration_unknown": return "file duration unavailable" if classification == "database_duration_unknown": return "database duration unavailable" if duration_match.matched_duration is None: return classification difference = duration_match.difference_seconds or 0 if classification == "current_version_match": return ( f"current duration match, difference {difference}s" ) if classification == "historical_version_match": archived = ( f", archived {duration_match.archived_at}" if duration_match.archived_at else "" ) return ( f"historical duration match, " f"difference {difference}s{archived}" ) return ( f"{classification}, difference {difference}s" ) def apply_duration_score( candidate: MatchCandidate, file_duration: int | None, ) -> MatchCandidate: duration_match = match_duration( file_duration=file_duration, movie=candidate.movie, ) return replace( candidate, duration_score=duration_match.score, total_score=( candidate.name_score + duration_match.score ), duration_reason=describe_duration_match( duration_match ), duration_match=duration_match, ) def find_candidates( filename: str, movies: list[Movie], file_duration: int | None, ) -> list[MatchCandidate]: filename_stem = Path(filename).stem normalized_filename = normalize_text(filename_stem) candidates = [] for movie in movies: candidate = best_name_candidate_for_movie( normalized_filename=normalized_filename, movie=movie, ) if candidate is None: continue candidates.append( apply_duration_score( candidate=candidate, file_duration=file_duration, ) ) return sorted( candidates, key=lambda candidate: ( candidate.total_score, candidate.duration_score, candidate.name_score, len(candidate.matched_name.normalized_persons), len(candidate.matched_name.normalized_name), ), reverse=True, ) def get_detected_movies( candidates: list[MatchCandidate], ) -> dict[str, str]: detected: dict[str, str] = {} for candidate in candidates: if candidate.full_name_occurrences == 0: continue detected[candidate.movie.movie_id] = ( candidate.movie.name ) return detected def candidate_covers_detected_movies( candidate: MatchCandidate, candidates: list[MatchCandidate], detected_movie_ids: set[str], ) -> bool: candidate_persons = set( candidate.matched_name.normalized_persons ) if len(candidate_persons) <= 1: return False detected_names: set[str] = set() for other in candidates: if other.movie.movie_id not in detected_movie_ids: continue if other.full_name_occurrences == 0: continue if len(other.matched_name.normalized_persons) != 1: continue detected_names.add( other.matched_name.normalized_persons[0] ) return ( bool(detected_names) and detected_names.issubset(candidate_persons) ) def calculate_total_margin( candidates: list[MatchCandidate], ) -> int: if not candidates: return 0 if len(candidates) == 1: return candidates[0].total_score return ( candidates[0].total_score - candidates[1].total_score ) def resolve_with_duration( candidates: list[MatchCandidate], ) -> MatchCandidate | None: """ Resolve several exact name candidates using duration. Exactly one candidate must have credible duration support, or the best duration-supported candidate must have a clear duration-score margin. """ credible = [ candidate for candidate in candidates if candidate.duration_score >= MIN_CREDIBLE_DURATION_SCORE ] if not credible: return None credible = sorted( credible, key=lambda candidate: ( candidate.duration_score, candidate.total_score, candidate.name_score, ), reverse=True, ) if len(credible) == 1: return credible[0] duration_margin = ( credible[0].duration_score - credible[1].duration_score ) if duration_margin >= MIN_DURATION_SCORE_MARGIN: return credible[0] return None def classify_candidates( candidates: list[MatchCandidate], ) -> MatchResult: if not candidates: return MatchResult( status="unmatched", best=None, margin=0, reason="no exact database name or alias found", detected_movies=(), ) best = candidates[0] margin = calculate_total_margin(candidates) detected = get_detected_movies(candidates) detected_movie_ids = set(detected) if len(detected_movie_ids) > 1: covering_candidates = [ candidate for candidate in candidates if candidate_covers_detected_movies( candidate=candidate, candidates=candidates, detected_movie_ids=detected_movie_ids, ) ] if covering_candidates: covering_candidates.sort( key=lambda candidate: ( candidate.total_score, candidate.duration_score, candidate.name_score, ), reverse=True, ) covering_best = covering_candidates[0] other_scores = [ candidate.total_score for candidate in candidates if candidate.movie.movie_id != covering_best.movie.movie_id ] covering_margin = ( covering_best.total_score - max(other_scores, default=0) ) if ( covering_best.name_score >= MIN_NAME_SCORE and covering_margin >= MIN_NAME_MARGIN ): return MatchResult( status="matched", best=covering_best, margin=covering_margin, reason=( "multi-person database entry covers all " "detected movie names" ), detected_movies=tuple( sorted( detected.values(), key=str.casefold, ) ), ) duration_winner = resolve_with_duration( candidates ) if duration_winner is not None: other_scores = [ candidate.total_score for candidate in candidates if candidate.movie.movie_id != duration_winner.movie.movie_id ] winner_margin = ( duration_winner.total_score - max(other_scores, default=0) ) return MatchResult( status="matched", best=duration_winner, margin=winner_margin, reason=( "multiple names matched, but duration " "clearly supports one candidate" ), detected_movies=tuple( sorted( detected.values(), key=str.casefold, ) ), ) return MatchResult( status="ambiguous", best=best, margin=margin, reason=( "multiple distinct database movies matched; " "duration does not clearly resolve them" ), detected_movies=tuple( sorted( detected.values(), key=str.casefold, ) ), ) if ( best.name_score >= MIN_NAME_SCORE and ( len(candidates) == 1 or margin >= MIN_NAME_MARGIN ) ): return MatchResult( status="matched", best=best, margin=margin, reason=( "best candidate exceeds name and margin thresholds" ), detected_movies=tuple( sorted( detected.values(), key=str.casefold, ) ), ) return MatchResult( status="ambiguous", best=best, margin=margin, reason="score or margin is insufficient", detected_movies=tuple( sorted( detected.values(), key=str.casefold, ) ), ) def format_duration(seconds: int | None) -> str: if seconds is None: return "unknown" hours, remainder = divmod(seconds, 3600) minutes, seconds = divmod(remainder, 60) if hours: return f"{hours}:{minutes:02d}:{seconds:02d}" return f"{minutes}:{seconds:02d}" def format_match_source( candidate: MatchCandidate, ) -> str: if candidate.matched_name.source == "current": return "current name" return ( f"{candidate.matched_name.source} alias " f"{candidate.matched_name.display_name!r}" ) def print_result( path: Path, file_duration: int | None, result: MatchResult, debug: bool, candidates: list[MatchCandidate], ) -> None: if result.best is None: name = "" score = 0 version = "-" else: name = result.best.movie.name score = result.best.total_score version = result.best.duration_match.classification print( f"{path.name:<55} " f"{name:<35} " f"{score:>5} " f"{result.status:<10} " f"{version}" ) if not debug: return print( f" file duration: " f"{format_duration(file_duration)}" ) if not candidates: print(" no candidates") print(f" reason: {result.reason}") return if result.detected_movies: print( " detected movies: " + ", ".join(result.detected_movies) ) for position, candidate in enumerate( candidates[:5], start=1, ): matched_duration = ( candidate.duration_match.matched_duration ) print( f" {position}. " f"{candidate.movie.name} " f"[name={candidate.name_score}, " f"duration={candidate.duration_score}, " f"total={candidate.total_score}]" ) print( f" via {format_match_source(candidate)}" ) print( f" name: {candidate.name_reason}" ) print( f" duration: " f"{candidate.duration_reason}; " f"reference=" f"{format_duration(matched_duration)}" ) print(f" margin: {result.margin}") print(f" reason: {result.reason}") def main() -> None: args = parse_arguments() if args.limit is not None and args.limit <= 0: raise ValueError( "--limit must be greater than zero." ) if args.detection_report is None: if shutil.which("ffprobe") is None: raise FileNotFoundError( "ffprobe was not found in PATH." ) extensions = parse_video_extensions( args.ending ) movies = load_movies(args.database) video_files = find_video_files( directory=args.directory, recursive=args.recursive, extensions=extensions, ) if args.limit is not None: video_files = video_files[:args.limit] else: extensions = parse_video_extensions( args.ending ) inventoried_files = find_video_files( directory=args.directory, recursive=args.recursive, extensions=extensions, ) if args.limit is not None: inventoried_files = inventoried_files[:args.limit] report_entries = load_detection_report( args.detection_report ) video_files = select_wcx_files( inventoried_files, report_entries, ) report_wcx_files = sum( classification == "wcx" for classification in report_entries.values() ) print( f"detection_report: " f"{normalize_absolute_path(args.detection_report)}" ) print(f"inventoried_files: {len(inventoried_files)}") print(f"report_wcx_files: {report_wcx_files}") print(f"selected_files: {len(video_files)}") print() if not video_files: print( "No inventoried files were classified as wcx; " "nothing to match." ) return if shutil.which("ffprobe") is None: raise FileNotFoundError( "ffprobe was not found in PATH." ) movies = load_movies(args.database) print( f"{'Filename':<55} " f"{'Likely database name':<35} " f"{'Score':>5} " f"{'Status':<10} " f"Version" ) print( f"{'-' * 55} " f"{'-' * 35} " f"{'-' * 5} " f"{'-' * 10} " f"{'-' * 26}" ) matched = 0 ambiguous = 0 unmatched = 0 ffprobe_failures = 0 historical_matches = 0 for video_file in video_files: file_duration = read_file_duration( video_file ) if file_duration is None: ffprobe_failures += 1 candidates = find_candidates( filename=video_file.name, movies=movies, file_duration=file_duration, ) result = classify_candidates(candidates) if result.status == "matched": matched += 1 elif result.status == "ambiguous": ambiguous += 1 else: unmatched += 1 if ( result.best is not None and result.best.duration_match.classification == "historical_version_match" ): historical_matches += 1 print_result( path=video_file, file_duration=file_duration, result=result, debug=args.debug, candidates=candidates, ) print() print("Summary") print("-------") print(f"Files analyzed: {len(video_files)}") print(f"Matched: {matched}") print(f"Ambiguous: {ambiguous}") print(f"Unmatched: {unmatched}") print(f"Historical versions: {historical_matches}") print(f"ffprobe failures: {ffprobe_failures}") if __name__ == "__main__": try: main() except ( FileNotFoundError, NotADirectoryError, ValueError, sqlite3.Error, ) as error: print(f"Error: {error}", file=sys.stderr) sys.exit(1)