Guest User

lint.py

a guest
Apr 28th, 2026
93
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 22.46 KB | Source Code | 0 0
  1. """
  2. Music Library Linter
  3.  
  4. Validates a music library against configurable rules for:
  5. - Folder structure depth
  6. - Allowed file extensions
  7. - Lossy audio file placement
  8. - Artist/album picture requirements
  9. - Album folder naming conventions
  10. - Track numbering in song files
  11.  
  12. Written largely by GLM preview
  13. """
  14.  
  15. import os
  16. import re
  17. import logging
  18. from pathlib import Path
  19. from typing import List, Set, Dict, Optional
  20.  
  21. # =============================================================================
  22. # CONFIGURABLE CONSTANTS
  23. # =============================================================================
  24.  
  25. # Path to the music library
  26. LIBRARY_PATH = "../Library"
  27.  
  28. # Log file path
  29. LOG_FILE = "lint.log"
  30.  
  31. # Lossy audio extensions
  32. LOSSY_EXTENSIONS: Set[str] = {"opus", "mp3", "m4a"}
  33.  
  34. # Lossy folder indicator
  35. LOSSY_INDICATOR = "[Lossy]"
  36.  
  37. # Picture extensions
  38. PICTURE_EXTENSIONS: Set[str] = {"jpg", "png"}
  39.  
  40. # Allowed file extensions
  41. ALLOWED_EXTENSIONS: Set[str] = LOSSY_EXTENSIONS | PICTURE_EXTENSIONS | {
  42.     "flac",
  43.     "txt",
  44.     "log"
  45. }
  46.  
  47. # Maximum picture size in bytes (1MB = 1,048,576 bytes)
  48. MAX_PICTURE_SIZE = 1_048_576
  49.  
  50. # Picture file names for artist and album
  51. ARTIST_PICTURE_NAMES = {"artist"}
  52. ALBUM_PICTURE_NAMES = {"cover"}
  53.  
  54. # Valid year prefixes for album folders
  55. VALID_YEAR_MARKERS = {"(unknown)", "(various)"}
  56.  
  57. # Whitelist patterns for track number requirement (regex patterns)
  58. TRACK_NUMBER_WHITELIST: List[str] = [
  59.     r"\[Lossy\]",  # Lossy folders
  60. ]
  61.  
  62. # Track number regex pattern
  63. TRACK_NUMBER_PATTERN = r"^\d+\s*[-.\s]?\s*.+"
  64.  
  65. # =============================================================================
  66. # LOGGING SETUP
  67. # =============================================================================
  68.  
  69. def setup_logging(log_file: str) -> logging.Logger:
  70.     """
  71.    Configure logging to both console (INFO level) and file (DEBUG level).
  72.    """
  73.     logger = logging.getLogger("music_linter")
  74.     logger.setLevel(logging.DEBUG)
  75.  
  76.     # Prevent duplicate handlers if called multiple times
  77.     if logger.handlers:
  78.         return logger
  79.  
  80.     # Formatter
  81.     formatter = logging.Formatter(
  82.         "%(asctime)s - %(levelname)s - %(message)s",
  83.         datefmt="%Y-%m-%d %H:%M:%S"
  84.     )
  85.  
  86.     # Console handler (INFO level)
  87.     console_handler = logging.StreamHandler()
  88.     console_handler.setLevel(logging.INFO)
  89.     console_handler.setFormatter(formatter)
  90.     logger.addHandler(console_handler)
  91.  
  92.     # File handler (DEBUG level)
  93.     file_handler = logging.FileHandler(log_file, mode="w", encoding="utf-8")
  94.     file_handler.setLevel(logging.INFO) #(logging.DEBUG)
  95.     file_handler.setFormatter(formatter)
  96.     logger.addHandler(file_handler)
  97.  
  98.     return logger
  99.  
  100.  
  101. # Initialize logger
  102. logger = setup_logging(LOG_FILE)
  103.  
  104.  
  105. # =============================================================================
  106. # HELPER FUNCTIONS
  107. # =============================================================================
  108.  
  109. def get_extension(filename: str) -> str:
  110.     """Get file extension without the dot, lowercase."""
  111.     return Path(filename).suffix.lower().lstrip(".")
  112.  
  113.  
  114. def get_artist_album_path(folder_path: str) -> str:
  115.     """
  116.    Get the artist/album path from a full folder path.
  117.    Returns the last two directory names for warning messages.
  118.    """
  119.     parts = Path(folder_path).parts
  120.     if len(parts) >= 2:
  121.         return str(Path(parts[-2]) / parts[-1])
  122.     return folder_path
  123.  
  124.  
  125. def is_audio_file(filename: str) -> bool:
  126.     """Check if file is an audio file (flac, opus, mp3)."""
  127.     ext = get_extension(filename)
  128.     return ext in {"flac", "opus", "mp3"}
  129.  
  130.  
  131. def is_picture_file(filename: str) -> bool:
  132.     """Check if file is a picture (jpg, png)."""
  133.     ext = get_extension(filename)
  134.     return ext in PICTURE_EXTENSIONS
  135.  
  136.  
  137. def matches_whitelist(path: str, patterns: List[str]) -> bool:
  138.     """Check if path matches any whitelist regex pattern."""
  139.     for pattern in patterns:
  140.         if re.search(pattern, path, re.IGNORECASE):
  141.             return True
  142.     return False
  143.  
  144.  
  145. def has_track_number(filename: str) -> bool:
  146.     """
  147.    Check if filename starts with a track number.
  148.    Handles formats like:
  149.    - "0001 song", "01. song", "01 - song", "01-song", "01 song", "1 song"
  150.    """
  151.     name = Path(filename).stem
  152.     return bool(re.match(TRACK_NUMBER_PATTERN, name))
  153.  
  154.  
  155. def _get_track_number(filename: str) -> Optional[int]:
  156.     """
  157.    Extracts the track number from the start of a filename.
  158.    Matches digits followed by at least one non-alphanumeric character.
  159.    """
  160.     match = re.match(r'^(\d+)[^a-zA-Z0-9]', filename)
  161.     if match:
  162.         return int(match.group(1))
  163.     return None
  164.  
  165.  
  166. def extract_year_from_folder(folder_name: str) -> str:
  167.     """
  168.    Extract year from folder name.
  169.    Returns the year string, "(unknown)", "(various)", or None.
  170.    """
  171.     # Check for special markers
  172.     for marker in VALID_YEAR_MARKERS:
  173.         if marker.lower() in folder_name.lower():
  174.             return marker
  175.  
  176.     # Check for year in parentheses: (2020)
  177.     year_match = re.search(r"\((\d{4})\)", folder_name)
  178.     if year_match:
  179.         return year_match.group(1)
  180.  
  181.     # Check for year at start: "2020 - Album Name"
  182.     start_year_match = re.match(r"^(\d{4})\s*[-.\s]", folder_name)
  183.     if start_year_match:
  184.         return start_year_match.group(1)
  185.  
  186.     return None
  187.  
  188.  
  189. def emit_path_warnings(
  190.     violations: Dict[str, List[str]],
  191.     warning_message: str
  192. ) -> int:
  193.     """
  194.    Emit one warning per unique path from collected violations.
  195.  
  196.    Args:
  197.        violations: Dict mapping path -> list of violating files
  198.        warning_message: Message template (path will be appended)
  199.  
  200.    Returns:
  201.        Number of unique paths with violations
  202.    """
  203.     for path, files in violations.items():
  204.         logger.warning(f"{warning_message}: {path}")
  205.         for filename in files:
  206.             logger.debug(f"  File: {filename}")
  207.  
  208.     return len(violations)
  209.  
  210.  
  211. # =============================================================================
  212. # LINTER RULES
  213. # =============================================================================
  214.  
  215. def rule_folder_depth(
  216.     library_path: str,
  217.     max_depth: int = 2
  218. ) -> int:
  219.     """
  220.    Rule: Folder structure should only be artist/album/song.ext.
  221.    No deeper folders should exist.
  222.  
  223.    Returns: Number of violations found.
  224.    """
  225.     logger.info("Checking folder structure depth...")
  226.     violations: Dict[str, List[str]] = {}
  227.     library_root = Path(library_path)
  228.  
  229.     for root, dirs, files in os.walk(library_path):
  230.         rel_path = Path(root).relative_to(library_root)
  231.         depth = len(rel_path.parts)
  232.  
  233.         if depth > max_depth:
  234.             path_key = get_artist_album_path(root)
  235.             if path_key not in violations:
  236.                 violations[path_key] = []
  237.             violations[path_key].append(root)
  238.  
  239.     count = emit_path_warnings(violations, "Folder depth violation")
  240.     logger.info(f"Folder depth violations: {count}")
  241.     return count
  242.  
  243.  
  244. def rule_allowed_extensions(
  245.     library_path: str,
  246.     allowed: Set[str] = None
  247. ) -> int:
  248.     """
  249.    Rule: Only allowed file extensions should exist in the library.
  250.  
  251.    Returns: Number of violations found.
  252.    """
  253.     if allowed is None:
  254.         allowed = ALLOWED_EXTENSIONS
  255.  
  256.     logger.info("Checking for disallowed file extensions...")
  257.     # Track both extensions and files per path
  258.     violations: Dict[str, Dict[str, Set[str]]] = {}  # path -> {"extensions": {...}, "files": {...}}
  259.  
  260.     for root, dirs, files in os.walk(library_path):
  261.         for filename in files:
  262.             ext = get_extension(filename)
  263.             if ext and ext not in allowed:
  264.                 path_key = get_artist_album_path(root)
  265.                 if path_key not in violations:
  266.                     violations[path_key] = {"extensions": set(), "files": []}
  267.                 violations[path_key]["extensions"].add(ext)
  268.                 violations[path_key]["files"].append(f"{filename} (.{ext})")
  269.  
  270.     # Emit warnings with extension list
  271.     for path, data in violations.items():
  272.         ext_list = ", ".join(f".{e}" for e in sorted(data["extensions"]))
  273.         logger.warning(f"Disallowed file extensions: {path}   ({ext_list})")
  274.         for filename in data["files"]:
  275.             logger.debug(f"  File: {filename}")
  276.  
  277.     count = len(violations)
  278.     logger.info(f"Disallowed extension violations: {count}")
  279.     return count
  280.  
  281.  
  282. def rule_lossy_folder_placement(
  283.     library_path: str,
  284.     lossy_exts: Set[str] = None,
  285.     indicator: str = None
  286. ) -> int:
  287.     """
  288.    Rule: Non-flac audio files should only exist in folders containing [Lossy].
  289.  
  290.    Returns: Number of violations found.
  291.    """
  292.     if lossy_exts is None:
  293.         lossy_exts = LOSSY_EXTENSIONS
  294.     if indicator is None:
  295.         indicator = LOSSY_INDICATOR
  296.  
  297.     logger.info("Checking lossy audio file placement...")
  298.     violations: Dict[str, List[str]] = {}
  299.  
  300.     for root, dirs, files in os.walk(library_path):
  301.         if indicator.lower() in root.lower():
  302.             continue
  303.  
  304.         for filename in files:
  305.             ext = get_extension(filename)
  306.             if ext in lossy_exts:
  307.                 path_key = get_artist_album_path(root)
  308.                 if path_key not in violations:
  309.                     violations[path_key] = []
  310.                 violations[path_key].append(filename)
  311.  
  312.     count = emit_path_warnings(violations, "Lossy file outside [Lossy] folder")
  313.     logger.info(f"Lossy placement violations: {count}")
  314.     return count
  315.  
  316.  
  317. def rule_artist_picture(
  318.     library_path: str,
  319.     picture_exts: Set[str] = None,
  320.     picture_names: Set[str] = None,
  321.     max_size: int = None
  322. ) -> int:
  323.     """
  324.    Rule: Artist folders should contain exactly one picture (jpg/png) named
  325.    'artist' (or similar), and it should be < max_size bytes.
  326.  
  327.    Returns: Number of violations found.
  328.    """
  329.     if picture_exts is None:
  330.         picture_exts = PICTURE_EXTENSIONS
  331.     if picture_names is None:
  332.         picture_names = ARTIST_PICTURE_NAMES
  333.     if max_size is None:
  334.         max_size = MAX_PICTURE_SIZE
  335.  
  336.     logger.info("Checking artist pictures...")
  337.     violations: Dict[str, List[str]] = {}
  338.     library_root = Path(library_path)
  339.  
  340.     for artist_folder in library_root.iterdir():
  341.         if not artist_folder.is_dir():
  342.             continue
  343.  
  344.         artist_pictures = []
  345.         for item in artist_folder.iterdir():
  346.             if item.is_file() and is_picture_file(item.name):
  347.                 stem = item.stem.lower()
  348.                 if stem in picture_names:
  349.                     artist_pictures.append(item)
  350.  
  351.         path_key = artist_folder.name
  352.  
  353.         # Missing artist picture
  354.         if not artist_pictures:
  355.             if path_key not in violations:
  356.                 violations[path_key] = []
  357.             violations[path_key].append("Missing artist picture")
  358.             continue
  359.  
  360.         # Multiple artist pictures
  361.         if len(artist_pictures) > 1:
  362.             if path_key not in violations:
  363.                 violations[path_key] = []
  364.             violations[path_key].append(f"Multiple artist pictures: {[p.name for p in artist_pictures]}")
  365.  
  366.         # Check picture size
  367.         #for pic in artist_pictures:
  368.         #    size = pic.stat().st_size
  369.         #    if size > max_size:
  370.         #        if path_key not in violations:
  371.         #            violations[path_key] = []
  372.         #        violations[path_key].append(f"{pic.name} ({size:,} bytes > {max_size:,} bytes)")
  373.  
  374.     count = emit_path_warnings(violations, "Artist picture issue")
  375.     logger.info(f"Artist picture violations: {count}")
  376.     return count
  377.  
  378.  
  379. def rule_album_picture(
  380.     library_path: str,
  381.     picture_exts: Set[str] = None,
  382.     picture_names: Set[str] = None,
  383.     max_size: int = None
  384. ) -> int:
  385.     """
  386.    Rule: Album folders should contain exactly one cover picture (jpg/png),
  387.    and it should be < max_size bytes.
  388.  
  389.    Returns: Number of violations found.
  390.    """
  391.     if picture_exts is None:
  392.         picture_exts = PICTURE_EXTENSIONS
  393.     if picture_names is None:
  394.         picture_names = ALBUM_PICTURE_NAMES
  395.     if max_size is None:
  396.         max_size = MAX_PICTURE_SIZE
  397.  
  398.     logger.info("Checking album pictures...")
  399.     violations: Dict[str, List[str]] = {}
  400.     library_root = Path(library_path)
  401.  
  402.     for artist_folder in library_root.iterdir():
  403.         if not artist_folder.is_dir():
  404.             continue
  405.  
  406.         for album_folder in artist_folder.iterdir():
  407.             if not album_folder.is_dir():
  408.                 continue
  409.  
  410.             album_pictures = []
  411.             for item in album_folder.iterdir():
  412.                 if item.is_file() and is_picture_file(item.name):
  413.                     stem = item.stem.lower()
  414.                     if stem in picture_names:
  415.                         album_pictures.append(item)
  416.  
  417.             path_key = f"{artist_folder.name}/{album_folder.name}"
  418.  
  419.             # Missing album picture
  420.             if not album_pictures:
  421.                 if path_key not in violations:
  422.                     violations[path_key] = []
  423.                 violations[path_key].append("Missing album picture (expected cover.jpg or cover.png)")
  424.                 continue
  425.  
  426.             # Multiple album pictures
  427.             if len(album_pictures) > 1:
  428.                 if path_key not in violations:
  429.                     violations[path_key] = []
  430.                 violations[path_key].append(f"Multiple album pictures: {[p.name for p in album_pictures]}")
  431.  
  432.             # Check picture size
  433.             #for pic in album_pictures:
  434.             #    size = pic.stat().st_size
  435.             #    if size > max_size:
  436.             #        if path_key not in violations:
  437.             #            violations[path_key] = []
  438.             #        violations[path_key].append(f"{pic.name} ({size:,} bytes > {max_size:,} bytes)")
  439.  
  440.     count = emit_path_warnings(violations, "Album picture issue")
  441.     logger.info(f"Album picture violations: {count}")
  442.     return count
  443.  
  444.  
  445. def rule_album_year_prefix(
  446.     library_path: str,
  447.     valid_markers: Set[str] = None
  448. ) -> int:
  449.     """
  450.    Rule: Album folder names should have a year prefix or special marker
  451.    like "(unknown)" or "(various)".
  452.  
  453.    Returns: Number of violations found.
  454.    """
  455.     if valid_markers is None:
  456.         valid_markers = VALID_YEAR_MARKERS
  457.  
  458.     logger.info("Checking album folder naming...")
  459.     violations: Dict[str, List[str]] = {}
  460.     library_root = Path(library_path)
  461.  
  462.     for artist_folder in library_root.iterdir():
  463.         if not artist_folder.is_dir():
  464.             continue
  465.  
  466.         for album_folder in artist_folder.iterdir():
  467.             if not album_folder.is_dir():
  468.                 continue
  469.  
  470.             year = extract_year_from_folder(album_folder.name)
  471.  
  472.             if year is None:
  473.                 path_key = f"{artist_folder.name}/{album_folder.name}"
  474.                 if path_key not in violations:
  475.                     violations[path_key] = []
  476.                 violations[path_key].append(f"Missing year prefix (expected (YYYY) or {valid_markers})")
  477.  
  478.     count = emit_path_warnings(violations, "Album folder missing year prefix")
  479.     logger.info(f"Album year prefix violations: {count}")
  480.     return count
  481.  
  482.  
  483. def rule_track_numbers(
  484.     library_path: str,
  485.     whitelist: List[str] = None
  486. ) -> int:
  487.     """
  488.    Rule: Song files should have a track number prefix.
  489.    Paths matching whitelist patterns are exempt.
  490.  
  491.    Returns: Number of violations found.
  492.    """
  493.     if whitelist is None:
  494.         whitelist = TRACK_NUMBER_WHITELIST
  495.  
  496.     logger.info("Checking song track numbers...")
  497.     violations: Dict[str, List[str]] = {}
  498.     library_root = Path(library_path)
  499.  
  500.     for root, dirs, files in os.walk(library_path):
  501.         for filename in files:
  502.             if not is_audio_file(filename):
  503.                 continue
  504.  
  505.             if matches_whitelist(root, whitelist):
  506.                 continue
  507.  
  508.             if not has_track_number(filename):
  509.                 path_key = get_artist_album_path(root)
  510.                 if path_key not in violations:
  511.                     violations[path_key] = []
  512.                 violations[path_key].append(filename)
  513.  
  514.     count = emit_path_warnings(violations, "Song missing track number")
  515.     logger.info(f"Track number violations: {count}")
  516.     return count
  517.  
  518. def rule_duplicate_track_numbers(library_path: str) -> int:
  519.     """
  520.    Rule: No two tracks in the same album should have the same track number.
  521.  
  522.    Returns: Number of violations found.
  523.    """
  524.     logger.info("Checking for duplicate track numbers...")
  525.     violations: Dict[str, List[str]] = {}
  526.  
  527.     for root, dirs, files in os.walk(library_path):
  528.         # Only process directories that contain files
  529.         if not files:
  530.             continue
  531.  
  532.         track_map: Dict[int, List[str]] = {}
  533.         for filename in files:
  534.             track_num = _get_track_number(filename)
  535.             if track_num is not None:
  536.                 if track_num not in track_map:
  537.                     track_map[track_num] = []
  538.                 track_map[track_num].append(filename)
  539.  
  540.         path_key = get_artist_album_path(root)
  541.         for track_num, filenames in track_map.items():
  542.             if len(filenames) > 1:
  543.                 if path_key not in violations:
  544.                     violations[path_key] = []
  545.  
  546.                 error_msg = f"Track {track_num} assigned to multiple files: {', '.join(filenames)}"
  547.                 violations[path_key].append(error_msg)
  548.                 logger.debug(f"Duplicate found in {path_key}: {error_msg}")
  549.  
  550.     count = emit_path_warnings(violations, "Duplicate track numbers")
  551.     logger.info(f"Duplicate track violations: {count}")
  552.     return count
  553.  
  554. def rule_sequential_track_numbers(library_path: str) -> int:
  555.     """
  556.    Rule: Track numbers should be sequential (start from 1, no gaps).
  557.  
  558.    Returns: Number of violations found.
  559.    """
  560.     logger.info("Checking for sequential track numbering...")
  561.     violations: Dict[str, List[str]] = {}
  562.  
  563.     for root, dirs, files in os.walk(library_path):
  564.         if not files:
  565.             continue
  566.  
  567.         # Extract all track numbers found in the folder
  568.         track_nums = sorted({_get_track_number(f) for f in files if _get_track_number(f) is not None})
  569.  
  570.         if not track_nums:
  571.             continue
  572.  
  573.         path_key = get_artist_album_path(root)
  574.         album_violations = []
  575.  
  576.         # Check if sequence starts at 1
  577.         if track_nums[0] != 1:
  578.             album_violations.append(f"Starts at {track_nums[0]} instead of 1")
  579.  
  580.         # Check for gaps
  581.         expected_sequence = list(range(1, len(track_nums) + 1))
  582.         if track_nums != expected_sequence:
  583.             missing = set(expected_sequence) - set(track_nums)
  584.             if missing:
  585.                 album_violations.append(f"Missing track numbers: {sorted(list(missing))}")
  586.  
  587.             # Check for unexpected high numbers (gaps that don't change count)
  588.             for i in range(len(track_nums) - 1):
  589.                 if track_nums[i+1] != track_nums[i] + 1:
  590.                     gap_msg = f"Gap detected between {track_nums[i]} and {track_nums[i+1]}"
  591.                     if gap_msg not in album_violations:
  592.                         album_violations.append(gap_msg)
  593.  
  594.         if album_violations:
  595.             violations[path_key] = album_violations
  596.             for detail in album_violations:
  597.                 logger.debug(f"Sequence error in {path_key}: {detail}")
  598.  
  599.     count = emit_path_warnings(violations, "Non-sequential track numbers")
  600.     logger.info(f"Sequential track violations: {count}")
  601.     return count
  602.  
  603.  
  604. # =============================================================================
  605. # MAIN FUNCTION
  606. # =============================================================================
  607.  
  608. def count_album_folders(library_path: str) -> int:
  609.     """Count total album folders for progress reporting."""
  610.     count = 0
  611.     library_root = Path(library_path)
  612.  
  613.     for artist_folder in library_root.iterdir():
  614.         if artist_folder.is_dir():
  615.             for album_folder in artist_folder.iterdir():
  616.                 if album_folder.is_dir():
  617.                     count += 1
  618.  
  619.     return count
  620.  
  621.  
  622. def main():
  623.     """
  624.    Main entry point for the music library linter.
  625.    Runs all configured rules and reports total violations.
  626.    """
  627.     total_violations = 0
  628.  
  629.     print(f"\nMusic Library Linter")
  630.     print(f"Library: {LIBRARY_PATH}")
  631.     print(f"Log file: {LOG_FILE}\n")
  632.  
  633.     # Verify library path exists
  634.     if not os.path.exists(LIBRARY_PATH):
  635.         logger.error(f"Library path does not exist: {LIBRARY_PATH}")
  636.         return 1
  637.  
  638.     # Count folders for progress
  639.     album_count = count_album_folders(LIBRARY_PATH)
  640.     print(f"Found {album_count} album folders to process.\n")
  641.  
  642.     # -------------------------------------------------------------------------
  643.     # RULE CALLS - Comment out any rules you want to disable
  644.     # -------------------------------------------------------------------------
  645.  
  646.     print("Processing rule: folder depth...")
  647.     total_violations += rule_folder_depth(LIBRARY_PATH)
  648.  
  649.     print("Processing rule: allowed extensions...")
  650.     total_violations += rule_allowed_extensions(LIBRARY_PATH)
  651.  
  652.     print("Processing rule: lossy folder placement...")
  653.     total_violations += rule_lossy_folder_placement(LIBRARY_PATH)
  654.  
  655.     print("Processing rule: artist pictures...")
  656.     total_violations += rule_artist_picture(LIBRARY_PATH)
  657.  
  658.     print("Processing rule: album pictures...")
  659.     total_violations += rule_album_picture(LIBRARY_PATH)
  660.  
  661.     #print("Processing rule: album year prefixes...")
  662.     #total_violations += rule_album_year_prefix(LIBRARY_PATH)
  663.  
  664.     #print("Processing rule: track numbers...")
  665.     #total_violations += rule_track_numbers(LIBRARY_PATH)
  666.  
  667.     print("Processing rule: unique track numbers...")
  668.     total_violations = rule_duplicate_track_numbers(LIBRARY_PATH)
  669.  
  670.     print("Processing rule: sequential track numbers...")
  671.     total_violations = rule_sequential_track_numbers(LIBRARY_PATH)
  672.  
  673.     # -------------------------------------------------------------------------
  674.  
  675.     # Report final results
  676.     print("\n" + "=" * 50)
  677.     if total_violations == 0:
  678.         logger.info("Linter complete. Total violations: 0")
  679.         print("All checks passed! No violations found.")
  680.     else:
  681.         logger.warning(f"Linter complete. Total violations: {total_violations}")
  682.         print(f"Found {total_violations} total violation(s).")
  683.         print(f"See {LOG_FILE} for details.")
  684.  
  685.     print("=" * 50 + "\n")
  686.  
  687.     return total_violations
  688.  
  689.  
  690. if __name__ == "__main__":
  691.     exit(main())
  692.  
Add Comment
Please, Sign In to add comment